A catalog that came back non-empty but without the unlock product left unlockProduct nil and productLoadError false — a combination neither the paywall nor the Settings row has a branch for, so both sat on a spinner forever. Two commits fix it; this review adds the test that actually pins the fix, plus the spec amendments the change made necessary.
fetchProducts() only treated a fully empty catalog as an error, so one mistyped identifier in App Store Connect produced an indefinite spinner with no error and no Retry.tipProducts, silently emptying an already-unlocked user's tip jar. Assignments are now unconditional and the error is decided afterwards.Catalog.loadFailed so the tests reach it, verified by mutation.design.md still showed the buggy isEmpty block. Both amended, plus Decision 14 and two quick decisions.StoreManager.products, which is write-only dead state predating this branch.Ready to push
The fix is correct, minimal, and lands in UI that already existed — no new strings, no view changes. Both UI consumers were verified to check productLoadError before unwrapping unlockProduct, so the one theoretical inconsistent state is structurally unreachable.
One real gap was found and fixed during this review: the line that is the fix was not covered by any test. All six new tests asserted on the Catalog value; productLoadError appeared in the test file only inside comments. Reverting the rule to the old fetched.isEmpty left the entire suite green — the regression tests written for T-1841 did not pin T-1841. The rule now lives on Catalog.loadFailed and is asserted directly; a mutation check confirms reverting it now fails exactly one test, the one that names the bug.
Local validation is the operative signal here (CI is billing-blocked and Linux-only, so it cannot run this suite at all). SwiftLint: 0 violations in 545 files. StoreManagerTests: 25/25 passing on the full flag set. The 201 failures in the whole-suite run are entirely WebKit-live suites — none touch StoreManager, and nothing this branch changes is reachable from them. See the attribution note below for how that was established.
af97da5 T-1841: Fix partial StoreKit results stranding the paywall f5cd91e T-1841: Keep tip products a partial catalog did return working-tree Fixes applied in this review Prism sells one thing that unlocks unlimited note exports, plus three optional “tip” purchases. When the app starts, it asks Apple's App Store for the details of those four items — their names and prices. It needs the real price from Apple because prices differ by country and can change.
The unlock screen shows one of three things: an error with a Retry button, a purchase button with the price, or a loading spinner while it waits.
The app assumed the App Store either returns everything or nothing. It doesn't. If one of the four items is misconfigured, the App Store quietly returns the other three and says nothing about the missing one.
So if the unlock item specifically went missing, the app saw a non-empty answer, concluded “that worked”, and recorded no error — but it also had no unlock product to build a purchase button from. The screen fell through to its third case: the spinner. That spinner had nothing left to wait for. It span forever, showed no error, and offered no way to try again. A user who wanted to pay simply could not.
This is a dead end on the one screen where the app asks for money, and it looks like the app is broken rather than the store being misconfigured.
Three production changes in prism/Services/StoreManager.swift, plus tests and spec updates.
StoreProduct protocol with two members (id, price) and extension Product: StoreProduct {}. This exists because StoreKit.Product has no public initialiser — it cannot be constructed in a test at all, so any logic typed to [Product] is untestable by construction.nonisolated static func selectCatalog<P: StoreProduct>(from:) -> Catalog<P> that performs the two independent selections: find the unlock product, filter and price-sort the tips.fetchProducts() reduced to the StoreKit call plus unconditional assignments, with productLoadError = catalog.loadFailed decided after them.The ordering is the load-bearing part. The first attempt at this fix used an early return when the unlock product was missing — which is precisely the shape this ticket targets — and that path never assigned tipProducts. SettingsView.supportSection routes every unlocked user to tipJarSection, which iterates tipProducts with no error branch and no retry, so an already-unlocked user would have seen a blank “Support Prism” section. Deciding the error after branch-free assignments makes that regression unrepresentable rather than merely absent.
The review moved the rule itself from the assignment site onto Catalog.loadFailed. That looks cosmetic and isn't: fetchProducts() assigns to Product-typed stored properties, so no unit test can drive it. With the rule inline at the assignment, it sat entirely outside test reach. On Catalog it is covered, and the assignment degrades to a forwarding line with no independent decision to regress.
Products.storekit lists all four products, so an SKTestSession over it can never produce the partial catalog the bug is about — you would need a second, deliberately-incomplete config file whose only purpose is to be wrong. It also reintroduces StoreKit IPC into the unit-test bundle, which Decision 12 exists to keep out.productLoadError as stored state. It is not redundant with unlockProduct == nil: the pre-first-fetch state is (false, nil) and renders the spinner, which is correct. The two properties encode a tri-state held together by a single write site.nonisolated on selectCatalog is load-bearing, not decorative. The app target sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; the test targets do not. Without the keyword the function would inherit MainActor isolation and the new tests — plain non-@MainActor functions in a nonisolated struct — could not call it synchronously. A future “cleanup” that drops it will break the tests in a way whose cause is not local to the diff.
The retroactive conformance is compile-time safe. extension Product: StoreProduct {} is checked structurally on every build; if StoreKit renamed price or changed its type, that line fails to compile. No @retroactive annotation is required because StoreProduct is ours. The residual risk is purely semantic (fixture values that don't resemble a real storefront), which no seam removes. Catalog<P> was constrained to P: StoreProduct during review so the type carries the same contract as the function that builds it.
Observation tearing was considered and refuted. Lines 275–292 are one synchronous MainActor region with no suspension between the unlockProduct write and the productLoadError write. @Observable invalidates on willSet and SwiftUI schedules re-render on the main run loop; a view body is itself MainActor-isolated and cannot interleave. No observer can see (unlock: nil, error: false) transiently.
The change is contained: no view, string catalog, or entitlement code is touched. It is independent of the T-1868 observation clock and the T-1779 presenter split, both of which concern entitlementState rather than productLoadError.
Both UI consumers were read and confirmed to branch productLoadError → unlockProduct → spinner, in that order, and SettingsView uses the same order in both its trailing value and its accessibility label — so VoiceOver and the visual row cannot disagree.
(error: true, unlock: non-nil) holds only because the sole re-fetch is gated behind the error flag. Adding a re-fetch on foreground — plausible, since verifyEntitlements() already does exactly that — would make one transient network failure blank a usable purchase button. Both views check the flag first, so it would present as a spurious error rather than a crash. Left alone here because changing the catch branch would contradict Req 2.13's failure clause.catch path — but it is a genuine behavioural delta that the commit narrative did not name. Now documented in the code comment and in Decision 14's negative consequences.applyCatalog(_:) seam does not compile — the stored properties are Product-typed, so a method generic over StoreProduct cannot assign to them, and making them generic would change the type's public API.StoreManager.products has no reader anywhere in the app or tests — a vestige of the original design where unlockProduct/tipProducts were computed from it. retryProductFetch() also gives no in-flight feedback, and this fix routes more users into that button.prism/Services/StoreManager.swift
Why it matters. This is the fix. StoreKit returns the identifiers it can resolve and silently omits the rest, so the empty-vs-non-empty test was never the right question. Both the paywall and the Settings unlock row branch error -> product -> spinner, and a partial catalog landed in the spinner branch with nothing left to wait for.
What to look at. prism/Services/StoreManager.swift:271-300 (fetchProducts)
prismTests/StoreManagerTests.swift
Why it matters. All six new tests asserted on the Catalog value; `productLoadError` appeared in the test file only inside comments. Reverting the rule to `fetched.isEmpty` left the whole suite green, so the regression tests written for T-1841 did not pin T-1841. Verified by mutation: with the rule on Catalog, the same revert now fails exactly one test — the one that names the bug.
What to look at. prism/Services/StoreManager.swift:315-329 (Catalog.loadFailed); prismTests/StoreManagerTests.swift:143-235
prism/Services/StoreManager.swift
Why it matters. Round 1 found the first cut's early return fired on exactly the shape this ticket targets and never assigned tipProducts — silently emptying an already-unlocked user's tip jar, which has no error branch and no retry affordance.
What to look at. prism/Services/StoreManager.swift:274-292
specs/inapp-purchase/requirements.md
Why it matters. Req 2.13 read 'returns an empty result or fails' — the exact rule the bug consisted of. Req 6.10 points at 2.13 for its trigger, so the whole chain read wrong. design.md still carried the buggy `if fetched.isEmpty` block verbatim.
What to look at. specs/inapp-purchase/requirements.md:53; design.md:297-320
prism/Services/StoreManager.swift
Why it matters. Two review agents independently spotted it. The old code assigned only inside the non-empty branch, so an empty result preserved the previous catalog; the new code assigns unconditionally, so an empty success now clears it. Unreachable today (the only Retry is offered from a paywall an unlocked user never sees), but it is a real delta the second commit's parity claim did not cover.
What to look at. prism/Services/StoreManager.swift:279-292 (comment); Decision 14 negative consequences
The repo's precedent is mixed and worth naming: T-1779 (a bugfix) earned a full entry, T-1868 (also a StoreManager bugfix, also introducing a testability seam) earned none. The distinguishing factor is that T-1868 fixed a race the spec never described — nothing written became wrong — whereas T-1841 makes Req 2.13 false. That puts it in the T-1779/T-1812 shape, so a full entry applies, plus Q1 (the StoreProduct seam) and Q2 (log diagnosability) in a new Quick Decisions table.
A review suggestion proposed productLoadError = unlockProduct == nil in the catch, so a failed refresh holding a last-known-good product would not blank the purchase button. Rejected: it directly contradicts Req 2.13's failure clause (a fetch that fails SHALL expose the error state), the state it protects against is unreachable under the current call graph, and widening error semantics on the throw path is a design change rather than a bugfix.
One agent proposed extracting the assignments into a @MainActor func applyCatalog(_ fetched: [some StoreProduct]) testable against the existing test-only initialiser. Verified and rejected: unlockProduct is Product? and tipProducts is [Product], so a method generic over StoreProduct cannot assign to them, and making the stored properties generic would change the type's public API for every consumer. Recorded in Decision 14's alternatives so it is not re-proposed.
Two agents disagreed — one wanted StoreProduct moved to its own file (matching KeyValueStoreProtocol, EntitlementSource), the other wanted it nested inside StoreManager. Both are defensible: MermaidRendererProtocol and WindowSceneProviding are declared inline in their consumers, so house style is genuinely mixed. Left alone as a coin-flip that churns a file for no behavioural gain.
One agent recommended cutting roughly half the added prose. Declined: this codebase is deliberately prose-heavy (the adjacent T-1868 observation-clock comment runs 16 lines, and the decision logs run to full ADRs), so trimming here would make the file inconsistent with its own neighbours rather than cleaner. The one comment that reaches into another file's private member was verified accurate against PaywallSheet.swift:79-88.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | prismTests/StoreManagerTests.swift — regression tests did not pin the regression | All six new tests asserted on Catalog; productLoadError appeared only in comments. Reverting the fix to `fetched.isEmpty` left the suite green — the fix was unpinned by the tests written for it, and two test names claimed load-error outcomes they did not assert. | Moved the rule to `Catalog.loadFailed`, asserted it in five tests, renamed the two overreaching names. Mutation-verified: the revert now fails exactly one test. |
| major | specs/inapp-purchase/requirements.md — Req 2.13 contradicted the code | Req 2.13 read 'returns an empty result or fails' — precisely the rule the bug consisted of. Req 6.10 depends on it for its trigger condition. | Amended in place to 'fails, OR returns a result that does not contain the unlock product — whether empty or merely partial', with the tip asymmetry stated and a (T-1841, Decision 14) reference. |
| major | specs/inapp-purchase/design.md — stale fetchProducts block | design.md still showed the buggy `if fetched.isEmpty` implementation verbatim, and separately showed unlockProduct/tipProducts as computed properties they have not been for some time. | Both blocks updated to the shipped shape with an amendment note. The computed-property block was corrected because the new block would otherwise contradict it. |
| major | specs/inapp-purchase/decision_log.md — no record of changed failure semantics | The paywall's failure semantics changed and a testability seam was introduced, with nothing in the decision log. Precedent (T-1779, T-1812) covers exactly this shape. | Added Decision 14 with four rejected alternatives and honest negative consequences, plus a new Quick Decisions table with Q1 (StoreProduct seam) and Q2 (log counts). |
| minor | prism/Services/StoreManager.swift — undocumented empty-catalog delta | Assignments became unconditional, so a successful empty fetch now clears state the old code preserved. Independently found by two agents; the second commit's 'full parity in every reachable case' claim did not cover it. | Behaviour kept (a successful empty result is evidence; the catch path is not) and documented in the code comment and Decision 14's negative consequences. |
| minor | prism/Services/StoreManager.swift — log lost empty-vs-partial diagnosability | Both the empty and the partial catalog now log the same message, so a total App Store Connect misconfiguration is indistinguishable from one wrong identifier — the exact thing the message exists to diagnose. | Message now interpolates returned/expected counts. Recorded as Q2. |
| minor | CHANGELOG.md — omitted the Settings row and the tip jar | The entry described only the paywall. The same flag flip changes the Settings unlock row from an endless spinner to 'Unavailable'. Every factual claim in the entry was verified literally, including the quoted string against Localizable.xcstrings — no overclaim found. | Added the Settings clause and an explicit statement that tips are unaffected, so an already-unlocked reader knows nothing changes for them. |
| minor | CLAUDE.md — invariant not captured | The IAP section had no bullet describing product-load failure semantics. T-1868 set the precedent of recording both the rule and its test seam there. | Extended bullet 1 with the rule, the tip asymmetry, and why the rule lives on Catalog rather than at the assignment. |
| minor | Catalog<P> was unconstrained | `struct Catalog<P>` allowed Catalog<Int>; the type did not carry the contract of the function that builds it. | Constrained to `Catalog<P: StoreProduct>`. |
| nit | specs/bugfixes/partial-storekit-results-strand-paywall/ | Empty untracked stub directory left by the fix-bug workflow. Git-invisible, so `git status` stayed clean. | Removed. No report written — recent precedent shows bugfix reports are the exception (1 of the last 12 bugfix merges), so this is not a gap. |
| minor | prism/Services/StoreManager.swift:51 — products is write-only | `private(set) var products: [Product]` has no reader anywhere in prism/, prismTests/, or prismUITests/. It is @Observable state that publishes a change nobody observes — a vestige of the original design where unlockProduct/tipProducts were computed from it. | Left alone. Pre-existing (present on origin/main), and it is declared public state in design.md, so removing it is an API change that belongs in its own ticket rather than widening a bugfix. |
| minor | catch branch can hold a stale non-nil unlockProduct | A throw sets productLoadError while leaving unlockProduct/tipProducts untouched, so (error: true, unlock: non-nil) is unreachable rather than impossible. A future foreground re-fetch would make one transient network failure blank a usable purchase button. | Left alone. The proposed fix contradicts Req 2.13's failure clause, and both views check the flag first so it would present as a spurious error, not a crash. Recorded in Decision 14's negative consequences. |
| nit | test double naming and placement | `FakeProduct` is the only Fake* double in a suite that otherwise uses Mock*, and it sits mid-suite rather than in the file's existing 'Test doubles' section. | Skipped — cosmetic churn in a test file, and the local comment makes its role obvious. |
| nit | CHANGELOG.md:25-26 — duplicated T-1805 entry | Two near-identical T-1805 entries differing only in their final clause, already on origin/main. | Skipped — pre-existing on main and unrelated to this branch; folding it here would put an unrelated edit in a bugfix PR. |
| nit | retryProductFetch gives no in-flight feedback | Tapping Retry produces no visible change until the fetch resolves, and this fix routes more users into that button. | Skipped — pre-existing UI gap, needs a spinner state and arguably a new string. Belongs with T-2223. |
Click to expand.
diff --git a/prism/Services/StoreManager.swift b/prism/Services/StoreManager.swiftindex a8c41bd..9b4d69b 100644--- a/prism/Services/StoreManager.swift+++ b/prism/Services/StoreManager.swift@@ -4,6 +4,19 @@ import StoreKit private let logger = Logger.prism(category: "Store") +/// The only two facts `StoreManager.selectCatalog(from:)` needs about a+/// fetched product: which product it is, and how it sorts in the tip jar.+///+/// `Product` values cannot be constructed outside StoreKit, so the selection+/// is written against this protocol instead — that is what lets tests feed it+/// a partial catalog and assert what comes out (T-1841).+protocol StoreProduct {+ var id: String { get }+ var price: Decimal { get }+}++extension Product: StoreProduct {}+ /// Manages StoreKit 2 product fetching, entitlement verification, and the /// export counter — the facts that describe the *account*, app-wide. ///@@ -258,16 +271,38 @@ final class StoreManager { private func fetchProducts() async { do { let fetched = try await Product.products(for: ProductID.all)- if fetched.isEmpty {- logger.error("Product.products returned empty — check product IDs in App Store Connect")- productLoadError = true- } else {- products = fetched- unlockProduct = fetched.first { $0.id == ProductID.unlock }- tipProducts = fetched- .filter { ProductID.tips.contains($0.id) }- .sorted { $0.price < $1.price }- productLoadError = false+ let catalog = Self.selectCatalog(from: fetched)+ products = fetched+ unlockProduct = catalog.unlock+ tipProducts = catalog.tips++ // Decided AFTER the assignments above, and deliberately never+ // short-circuiting them: the unlock product gating the paywall and+ // the tips filling the tip jar are independent concerns. An+ // already-unlocked user's tip jar must survive a catalog that+ // omitted only the unlock product (T-1841).+ //+ // The assignments are also unconditional, where the pre-T-1841 code+ // made them only on a non-empty result. A fetch that succeeds and+ // returns nothing is positive evidence that the store has nothing+ // for us, not an absence of evidence, so it clears the selections+ // rather than preserving a stale catalog — unlike the `catch` below,+ // where the fetch never reported anything and the last known good+ // catalog is still the best available answer.+ //+ // The purchase flow itself is unusable without the unlock product,+ // whether the catalog came back empty OR merely partial. Before+ // T-1841 only the empty case was treated as a failure, so a+ // partial result left `unlockProduct` nil and `productLoadError`+ // false — a combination `PaywallSheet.purchaseSection` has no+ // branch for, so it sat on its `ProgressView` forever with no+ // error and no retry affordance.+ productLoadError = catalog.loadFailed+ if productLoadError {+ // The count separates a total misconfiguration from a single+ // wrong identifier, which the message alone no longer does now+ // that both the empty and the partial catalog land here.+ logger.error("Product.products returned \(fetched.count) of \(ProductID.all.count) products, without the unlock product — check product IDs in App Store Connect") } } catch { logger.error("Failed to fetch products: \(error.localizedDescription)")@@ -275,6 +310,37 @@ final class StoreManager { } } + /// The two independent selections `fetchProducts()` makes over a fetched+ /// catalog. Either may be absent without invalidating the other.+ struct Catalog<P: StoreProduct> {+ /// The unlock product, or nil when the catalog did not return it.+ let unlock: P?+ /// Every tip product the catalog did return, sorted by price.+ let tips: [P]++ /// Whether this catalog leaves the purchase flow unusable, which is+ /// exactly what `productLoadError` reports. The rule lives here rather+ /// than at the assignment in `fetchProducts()` so that it is covered by+ /// the tests — `fetchProducts()` itself is unreachable from a unit test,+ /// because its assignments are typed to `Product` and StoreKit will not+ /// let a test construct one (T-1841).+ var loadFailed: Bool { unlock == nil }+ }++ /// Pure selection backing `fetchProducts()`. Generic over+ /// `StoreProduct` rather than taking `[Product]` because `Product` cannot+ /// be constructed outside StoreKit — this is what makes the selection+ /// unit-testable, and in particular what pins the T-1841 rule that a+ /// catalog missing the unlock product still yields its tips.+ nonisolated static func selectCatalog<P: StoreProduct>(from fetched: [P]) -> Catalog<P> {+ Catalog(+ unlock: fetched.first { $0.id == ProductID.unlock },+ tips: fetched+ .filter { ProductID.tips.contains($0.id) }+ .sorted { $0.price < $1.price }+ )+ }+ private func updateEntitlement(from transaction: Transaction) { guard transaction.productID == ProductID.unlock else { return } applyTransactionEvent(unlocked: transaction.revocationDate == nil)
diff --git a/prismTests/StoreManagerTests.swift b/prismTests/StoreManagerTests.swiftindex b985588..ae38ad4 100644--- a/prismTests/StoreManagerTests.swift+++ b/prismTests/StoreManagerTests.swift@@ -131,6 +131,113 @@ struct StoreManagerTests { #expect(store.remainingFreeExports == 0) } + // MARK: - Catalog selection (T-1841)+ //+ // Before the fix, `fetchProducts()` only flagged `productLoadError` when+ // `Product.products` returned a fully empty result. A catalog that came+ // back non-empty but omitted the unlock product specifically left+ // `unlockProduct` nil AND `productLoadError` false — a combination+ // `PaywallSheet.purchaseSection` has no branch for, so it stayed on its+ // `ProgressView` forever with no error and no retry affordance.+ //+ // `Product` cannot be constructed outside StoreKit, so these tests pin+ // `selectCatalog(from:)` — the pure selection `fetchProducts()` delegates+ // to — rather than the StoreKit call itself. `fetchProducts()` assigns to+ // `Product`-typed properties, so no unit test can reach it directly; what+ // it can be held to is making no decision of its own, which is why the+ // load-error rule lives on `Catalog.loadFailed` and is asserted below+ // rather than being spelled out at the assignment.++ /// Stand-in for `Product`, which StoreKit will not let a test construct.+ private struct FakeProduct: StoreProduct {+ let id: String+ let price: Decimal+ }++ private static let unlockProduct = FakeProduct(id: StoreManager.ProductID.unlock, price: 9.99)+ private static let smallTip = FakeProduct(id: StoreManager.ProductID.tipSmall, price: 1.99)+ private static let mediumTip = FakeProduct(id: StoreManager.ProductID.tipMedium, price: 4.99)+ private static let largeTip = FakeProduct(id: StoreManager.ProductID.tipLarge, price: 9.99)++ @Test("An empty catalog yields no unlock product and no tips")+ func selectCatalogEmpty() {+ let catalog = StoreManager.selectCatalog(from: [FakeProduct]())++ #expect(catalog.unlock == nil)+ #expect(catalog.loadFailed)+ #expect(catalog.tips.isEmpty)+ }++ @Test("A catalog with only tips is a load failure")+ func selectCatalogPartialReportsMissingUnlock() {+ // The exact bug scenario: a non-empty, partial catalog. Before the fix+ // this shape reported no load error, which is what stranded the+ // paywall — so `loadFailed` is the assertion that pins T-1841.+ let catalog = StoreManager.selectCatalog(from: [Self.smallTip, Self.mediumTip])++ #expect(catalog.unlock == nil)+ #expect(catalog.loadFailed)+ }++ @Test("A catalog with only tips still yields those tips")+ func selectCatalogPartialKeepsTips() {+ // Regression guard for the shape an ALREADY-UNLOCKED user hits: the+ // unlock product is missing (and irrelevant to them), but their tip+ // jar must still be populated from the tips that did come back. An+ // earlier attempt at T-1841 returned early on a missing unlock and+ // never assigned `tipProducts`, silently emptying the tip jar in+ // Settings — the unlock product gates the paywall, not the tip jar.+ let catalog = StoreManager.selectCatalog(from: [Self.mediumTip, Self.smallTip])++ #expect(catalog.tips.map(\.id) == [+ StoreManager.ProductID.tipSmall,+ StoreManager.ProductID.tipMedium+ ])+ }++ @Test("A catalog with the unlock product and no tips is not a load failure")+ func selectCatalogUnlockOnly() {+ let catalog = StoreManager.selectCatalog(from: [Self.unlockProduct])++ #expect(catalog.unlock?.id == StoreManager.ProductID.unlock)+ #expect(!catalog.loadFailed)+ #expect(catalog.tips.isEmpty)+ }++ @Test("A complete catalog yields the unlock product and every tip, price-sorted")+ func selectCatalogComplete() {+ let catalog = StoreManager.selectCatalog(+ from: [Self.largeTip, Self.unlockProduct, Self.smallTip, Self.mediumTip]+ )++ #expect(catalog.unlock?.id == StoreManager.ProductID.unlock)+ #expect(!catalog.loadFailed)+ // The unlock product is never a tip, even though it shares a price+ // with the large tip.+ #expect(catalog.tips.map(\.id) == [+ StoreManager.ProductID.tipSmall,+ StoreManager.ProductID.tipMedium,+ StoreManager.ProductID.tipLarge+ ])+ }++ @Test("A catalog missing one tip keeps the others and reports no load error")+ func selectCatalogMissingSingleTipIsTolerated() {+ // Deliberate asymmetry with the unlock product (T-1841): an absent+ // individual tip strands no UI, so the tip jar simply omits it rather+ // than failing the whole fetch.+ let catalog = StoreManager.selectCatalog(+ from: [Self.unlockProduct, Self.smallTip, Self.largeTip]+ )++ #expect(catalog.unlock?.id == StoreManager.ProductID.unlock)+ #expect(!catalog.loadFailed)+ #expect(catalog.tips.map(\.id) == [+ StoreManager.ProductID.tipSmall,+ StoreManager.ProductID.tipLarge+ ])+ }+ // MARK: - Entitlement ordering (T-1868) // // `StoreManager` is MainActor-isolated, but a verification scan suspends
diff --git a/specs/inapp-purchase/decision_log.md b/specs/inapp-purchase/decision_log.mdindex 9270f41..a26ee7a 100644--- a/specs/inapp-purchase/decision_log.md+++ b/specs/inapp-purchase/decision_log.md@@ -1,5 +1,12 @@ # Decision Log: In-App Purchase +## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-17 | Make the catalog selection generic over a two-property `StoreProduct` protocol, with `extension Product: StoreProduct {}` | `Product` cannot be constructed outside StoreKit, so a selection typed to `[Product]` has no unit test at all. Follows the existing `KeyValueStoreProtocol` and `EntitlementSource` seams. The empty extension is checked structurally on every build, so it cannot drift from `Product` silently (T-1841) |+| Q2 | 2026-08-17 | Log the returned/expected product counts alongside the missing-unlock error | Both the empty and the partial catalog now log the same message, so without the counts a total App Store Connect misconfiguration is indistinguishable from one wrong identifier (T-1841) |+ ## Decision 1: Export Action Scope **Date**: 2026-04-04@@ -469,3 +476,56 @@ Both wiring halves in `prismApp.swift` are pinned by source-structural tests in The cross-scene rule is pinned by `purchaseInAnotherSceneRunsTheBlockedScenesExport` and `restoreInAnotherSceneRunsTheBlockedScenesExport`, which never touch the blocked presenter — production establishes nothing on it, and a test that sets its state by hand cannot see the defect. That is precisely what hid the regression during the first pass: `twoBlockedExportsKeepIndependentRetries` set the second window's flag itself, so the suite stayed green over a case production got wrong. Its counterpart `dismissalWithoutEntitlementDoesNotRetry` keeps the swipe-away and Ask-to-Buy halves pinned, so the fix cannot be reduced to "always retry". ---++## Decision 14: A Catalog Without the Unlock Product Is a Load Failure; a Missing Tip Is Not++**Date**: 2026-08-17+**Status**: accepted++### Context++`fetchProducts()` treated exactly one shape as a product-load failure: `Product.products(for:)` returning a fully empty array (Req 2.13 as originally written). StoreKit does not guarantee an all-or-nothing catalog — it returns the identifiers it could resolve and silently omits the rest, so a single mistyped or not-yet-approved identifier in App Store Connect yields a non-empty result missing one product.++When the missing product was the unlock product, the fetch was recorded as a success: `unlockProduct` stayed nil and `productLoadError` stayed false. `PaywallSheet.purchaseSection` and the Settings unlock row both branch error → product → spinner, and neither has a branch for "no error, no product" other than the spinner. The paywall therefore sat on a `ProgressView` indefinitely, showing no error, offering no Retry, and giving the user no way to purchase (T-1841).++### Decision++The load-error rule is "the catalog did not return the unlock product", which subsumes the empty case. A missing individual *tip* product is tolerated and does not set `productLoadError`.++The rule lives on `StoreManager.Catalog.loadFailed`, and `productLoadError` is assigned from it. The selection that produces the catalog is a pure `nonisolated static func selectCatalog(from:)`, generic over a `StoreProduct` protocol (Q1). `fetchProducts()` retains only the StoreKit call and unconditional assignments, and makes no decision of its own.++### Rationale++The asymmetry follows what each product gates. The purchase flow cannot run without the unlock product, so its absence must reach a UI state the user can act on — and the "Unable to load purchase options" + Retry state already existed for the empty catalog. A tip is one row in a list; its absence strands nothing, so failing the whole fetch over it would replace a working purchase button and three tips with an error screen.++Deciding the error *after* the assignments, and never short-circuiting them, is what keeps the two concerns independent. A first pass returned early when the unlock product was missing, which never assigned `tipProducts` — silently emptying the tip jar in Settings for an already-unlocked user, the one user for whom the missing unlock product is irrelevant. That user's tip jar has no error branch and no retry affordance, so it would have shown an empty "Support Prism" section with no explanation.++Putting the rule on `Catalog` rather than at the assignment is a testability constraint, not a stylistic one. `fetchProducts()` assigns to `Product`-typed properties and StoreKit will not let a test construct a `Product`, so no unit test can reach it. With the rule at the assignment, reverting it to `fetched.isEmpty` left the entire suite green — the fix was unpinned by the tests written for it. On `Catalog` the rule is covered, and the assignment is reduced to a forwarding line with no independent decision to regress.++### Alternatives Considered++- **Keep the empty-only rule and add a "no unlock product" branch to the two views**: rejected. It pushes a store-catalog concept into two view files that would each have to agree, and it invents a third UI state where the existing error state already says exactly the right thing and already offers Retry.+- **Require every identifier in `ProductID.all` to be present, or fail**: rejected. It makes a missing tip fatal to the purchase flow, taking a working unlock away from a user over a product they were not trying to buy.+- **Treat a partial catalog as a success and let the paywall show a spinner**: the status quo, i.e. the bug. Rejected: a spinner that never resolves is indistinguishable from a hang and offers no recovery.+- **Test through `SKTestSession` over `Products.storekit` instead of a pure function**: rejected. That file lists all four products, so it can never produce the partial catalog the bug is about — reproducing it needs a second, deliberately incomplete `.storekit` file. It also reintroduces StoreKit IPC into the unit-test bundle, which Decision 12 exists to keep out.+- **Assign from a generic `applyCatalog(_:)` on `StoreManager` so the assignments themselves are testable**: rejected because it does not compile. `unlockProduct` and `tipProducts` are `Product`-typed, so a method generic over `StoreProduct` cannot assign to them, and making the stored properties generic would change the type's public API for every consumer.++### Consequences++**Positive:**+- A partial catalog now reaches a state the user can act on, in both the paywall and Settings+- The tip jar survives a catalog that omitted only the unlock product, so an already-unlocked user is unaffected by a fault that does not concern them+- `productLoadError` has exactly one meaning — `catalog.unlock == nil` — instead of two conditions that had drifted apart+- No new user-facing strings and no view changes: the fix routes into UI that already existed++**Negative:**+- The empty-catalog case changed shape. The assignments are now unconditional where the original code made them only on a non-empty result, so a *successful* fetch returning nothing clears `products`/`unlockProduct`/`tipProducts` rather than preserving the previous catalog. This is deliberate — a successful empty result is evidence the store has nothing, unlike the `catch` path where nothing was reported and the last known good catalog is kept — but it is a behaviour change beyond the reported bug, and it is unreachable today only because the Retry that could trigger it is offered solely from a paywall an unlocked user never sees+- The combination `productLoadError == true` with a non-nil `unlockProduct` is unreachable rather than impossible. It holds because the only re-fetch is gated behind the error flag; adding a re-fetch on foreground would make one transient network failure blank a usable purchase button. Both views check the flag first, so the state would present as an error rather than a crash+- `fetchProducts()` itself remains outside unit-test reach. The rule and the selection are covered; the three assignments and the forwarding line are held only by review and by the compiler+- The tip jar still has no empty or error state of its own. Pre-existing — the paths that empty `tipProducts` for an unlocked user (a thrown fetch, an empty catalog) were reachable before this change — and tracked as T-2223 rather than widened into this fix++### Impact++`StoreManager.fetchProducts()`, the new `Catalog`/`selectCatalog` pair, and `StoreProduct` (Q1). No view, string catalog, or entitlement code is touched; the T-1868 observation clock and the T-1779 presenter split are independent of `productLoadError`. Req 2.13 is amended in place, and the `fetchProducts()` block in `design.md` is updated to the shipped shape.++---
diff --git a/specs/inapp-purchase/design.md b/specs/inapp-purchase/design.mdindex a962c4a..3df598e 100644--- a/specs/inapp-purchase/design.md+++ b/specs/inapp-purchase/design.md@@ -163,16 +163,14 @@ final class StoreManager { static let freeExportLimit = 20 static let nudgeThreshold = 10 - // MARK: - Computed helpers+ // MARK: - Derived selections - var unlockProduct: Product? {- products.first { $0.id == ProductID.unlock }- }- - var tipProducts: [Product] {- products.filter { ProductID.tips.contains($0.id) }- .sorted { $0.price < $1.price }- }+ // Shipped as stored properties recomputed inside `fetchProducts()`, not as+ // the computed properties designed here, so that view bodies do not refilter+ // on every render. The selection itself lives in the pure+ // `selectCatalog(from:)` (T-1841, Decision 14).+ private(set) var unlockProduct: Product?+ private(set) var tipProducts: [Product] = [] // MARK: - Private @@ -296,18 +294,27 @@ private func verifyEntitlements() async { // MARK: - Product fetching (req 2.2, 2.13) +// Amended by T-1841 (Decision 14). The original design tested `fetched.isEmpty`,+// which left a partial catalog — tips returned, unlock product absent — with+// `unlockProduct` nil and `productLoadError` false, a combination the paywall+// has no branch for. The rule is now "the catalog did not return the unlock+// product", which subsumes the empty case. private func fetchProducts() async { do { let fetched = try await Product.products(for: ProductID.all)- if fetched.isEmpty {- // Empty result without error means invalid product IDs- logger.error("Product.products returned empty — check product IDs in App Store Connect")- productLoadError = true- } else {- products = fetched- productLoadError = false+ let catalog = Self.selectCatalog(from: fetched)+ // Unconditional: the unlock product gating the paywall and the tips+ // filling the tip jar are independent, so a catalog missing one still+ // assigns the other.+ products = fetched+ unlockProduct = catalog.unlock+ tipProducts = catalog.tips+ productLoadError = catalog.loadFailed // == (catalog.unlock == nil)+ if productLoadError {+ logger.error("Product.products returned \(fetched.count) of \(ProductID.all.count) products, without the unlock product") } } catch {+ // A fetch that never reported keeps the last known good catalog. logger.error("Failed to fetch products: \(error.localizedDescription)") productLoadError = true }
diff --git a/specs/inapp-purchase/requirements.md b/specs/inapp-purchase/requirements.mdindex 88ce225..8acf0f6 100644--- a/specs/inapp-purchase/requirements.md+++ b/specs/inapp-purchase/requirements.md@@ -50,7 +50,7 @@ This feature is introduced before annotation exports ship to the App Store, so n 10. <a name="2.10"></a>WHEN a transaction update indicates a revoked entitlement (refund), THEN the StoreManager SHALL set `entitlementState` to `locked` 11. <a name="2.11"></a>WHEN a transaction update indicates a revoked entitlement due to Family Sharing removal (user leaves family group or sharing disabled), THEN the StoreManager SHALL set `entitlementState` to `locked` 12. <a name="2.12"></a>WHEN a purchase returns a pending state (Ask to Buy / Strong Customer Authentication), THEN the system SHALL inform the user that the purchase is awaiting approval and SHALL NOT grant the entitlement until the transaction is approved via `Transaction.updates` -13. <a name="2.13"></a>WHEN `Product.products(for:)` returns an empty result or fails, THEN the StoreManager SHALL expose a `productLoadError` state +13. <a name="2.13"></a>WHEN `Product.products(for:)` fails, OR returns a result that does not contain the unlock product — whether empty or merely partial — THEN the StoreManager SHALL expose a `productLoadError` state. A missing individual tip product SHALL NOT set it (T-1841, Decision 14) ### 3. Export Counter
diff --git a/specs/inapp-purchase/implementation.md b/specs/inapp-purchase/implementation.mdindex 47561a3..4883806 100644--- a/specs/inapp-purchase/implementation.md+++ b/specs/inapp-purchase/implementation.md@@ -98,7 +98,7 @@ The macOS `NSSharingServicePicker` has no completion API, so the counter increme **Section 1 (Product Configuration):** All four product IDs registered in `Products.storekit` — non-consumable unlock with Family Sharing, three consumable tips. StoreKit 2 APIs used throughout. -**Section 2 (Store Manager Service):** `@Observable @MainActor` `StoreManager` injected via `.environment()`, parallel init fetches products + entitlements, `Transaction.updates` listener for app lifetime, tri-state `entitlementState`, `purchase()`/`restorePurchases()`/`retryProductFetch()` all present, `Transaction.finish()` called in the updates listener, refund/Family revocation flips to `.locked`, pending state held without granting entitlement, `productLoadError` flag for empty result and throw paths.+**Section 2 (Store Manager Service):** `@Observable @MainActor` `StoreManager` injected via `.environment()`, parallel init fetches products + entitlements, `Transaction.updates` listener for app lifetime, tri-state `entitlementState`, `purchase()`/`restorePurchases()`/`retryProductFetch()` all present, `Transaction.finish()` called in the updates listener, refund/Family revocation flips to `.locked`, pending state held without granting entitlement, `productLoadError` flag for the throw path and for any result that does not contain the unlock product, empty or partial (amended by T-1841, Decision 14 — it originally covered only the empty result). **Section 3 (Export Counter):** `NSUbiquitousKeyValueStore` primary store with `UserDefaults` mirror, max-merge on external change and on init transition, no reset on refund (no API), per-Apple-ID counters by virtue of KVS being per-user.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex ca953d3..8c650b6 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. - 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 applies its result once another one has taken over.+- The unlock screen no longer spins forever when the App Store returns some but not all of Prism's products (T-1841). Product fetching only reported an error when the whole catalog came back empty; if the store returned the tip products but omitted the unlock product specifically, the purchase button never appeared and the spinner never resolved, with no error message and no way to retry. A fetch that comes back without the unlock product is now treated as a load failure in every case, so it always ends in the existing "Unable to load purchase options" message with a Retry button, never an indefinite spinner. The unlock row in Settings, which sat on the same endless spinner, now reads "Unavailable" and still opens the unlock screen when tapped. Tips are unaffected: a catalog that returned the tips but not the unlock product still fills the tip jar, so an already-unlocked user sees nothing change. - A `prism://open?url=…` link now opens the address it names, even when that address mixes already-escaped and unescaped characters (T-2140). `…/my%20file and more.md` was fetched as `…/my%2520file%20and%20more.md` — a different resource, with no error shown — because the address had already been unescaped one layer by the time it was read, and was then escaped a second time in full. Investigating it surfaced a second fault of the same kind, live on every markdown link and image in every document: the escaping used for addresses turned an escaped `%2F` back into a real `/`, splitting one path segment into two. That silently broke any address that identifies something by an escaped path — a GitLab project URL, for instance, which 404s once `group%2Fproj` becomes `group/proj`. Escaped slashes and escaped ampersands now survive every route into the app: typed and pasted addresses, deep links, document links and images, `mailto:` links, and the GitHub blob-to-raw rewrite. One narrow side effect of reworking that rewrite: a GitHub address written with a doubled slash in it (`github.com//owner/repo/blob/…`) is no longer recognised as a file address, so it now reports an unsupported content type instead of opening. - In a document opened from a URL, an image or link whose query or anchor was already partly escaped no longer resolves to a corrupted address (T-1663). `/images/logo.png?token=a%20b c` was resolved as `?token=a%2520b%20c`, so the image failed to load and the link opened the wrong page: the already-escaped `%20` was escaped a second time because the whole query was treated as though none of it had been escaped yet. This affected both site-root addresses (starting with `/`) and the far more common document-relative form (`images/logo.png?token=a%20b c`); both are fixed. Query and anchor are now escaped the same way absolute URLs already were (T-1624) — an existing escape is left alone and only genuinely unescaped characters are encoded, so an escaped `%26` stays a literal character instead of decoding into a parameter separator and requesting a different resource.
diff --git a/CLAUDE.md b/CLAUDE.mdindex 0e69c3b..17acc8b 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -94,7 +94,7 @@ Implementation history and gotchas (the `<script>` data-island CSP trap, the nat 12. Linked images show a two-button overlay ("View Image" / "Follow Link") matching the MermaidPreviewCard pattern: iOS uses tap-to-reveal with 3s auto-dismiss; macOS uses hover ### In-App Purchase System-1. `StoreManager` (`@Observable @MainActor`) owns StoreKit 2 product fetching, entitlement verification, and the export counter — everything scoped to the ACCOUNT. It owns no paywall presentation state: that is per-scene and lives on `PaywallPresenter` (T-1779)+1. `StoreManager` (`@Observable @MainActor`) owns StoreKit 2 product fetching, entitlement verification, and the export counter — everything scoped to the ACCOUNT. It owns no paywall presentation state: that is per-scene and lives on `PaywallPresenter` (T-1779). `productLoadError` means "the catalog did not return the unlock product", empty or merely partial — StoreKit omits identifiers it cannot resolve rather than failing, and testing `fetched.isEmpty` left a partial catalog with `unlockProduct` nil and the flag false, a combination neither the paywall nor the Settings row has a branch for, so both spun forever (T-1841). A missing individual TIP is tolerated: the unlock product gates the paywall, the tips only fill the tip jar, so the two are selected independently and neither short-circuits the other — an early return on a missing unlock silently emptied an already-unlocked user's tip jar. The rule lives on `StoreManager.Catalog.loadFailed`, not at the assignment in `fetchProducts()`, because that method assigns to `Product`-typed properties and StoreKit will not let a test construct a `Product` — with the rule at the assignment, reverting it to `fetched.isEmpty` left the whole suite green. The selection itself is the pure `selectCatalog(from:)` over the `StoreProduct` seam (Decision 14) 2. `EntitlementState` is tri-state (`.loading` / `.locked` / `.unlocked`); the paywall and Settings UI branch on it. All writes flow through one commit point ordered by a monotonic observation clock — recency of information wins, never completion order — because a verification scan suspends mid-read and MainActor isolation does not cover that reentrancy window (T-1868). The StoreKit scan sits behind the injectable `EntitlementSource` seam so the ordering rules are unit-testable 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
GitHub Actions is billing-blocked and Linux-only for this project, so CI cannot execute this suite at all — main fails the same checks. Local results: SwiftLint 0 violations in 545 files; StoreManagerTests 25/25 passed (full flag set, single locale configuration, one worker, counts read from the result bundle rather than from TEST SUCCEEDED).
The whole-suite run reported 201 failures. Every one is a WebKit-live suite (WebSelectionNoteTests, WebReloadNavigationClaimTests, note-chrome semantics, and similar) — the known host-crash cascade, T-2219. Zero StoreManager failures.
Attribution, stated precisely: a clean origin/main worktree was checked out and the same target run against it. That run did not produce a final count — it reproduced the pathology instead and hung: tests that normally complete in milliseconds began taking 23–24 seconds each, and the host stopped writing mid-line. That is the T-2219 signature occurring on unmodified main, which is the attribution, even though it is not the tidy baseline number one would prefer. Combined with the failure set being disjoint from anything this branch touches, and the branch's own suite passing in isolation, the 201 failures are not attributable to these changes.
An earlier run also produced a corrupt result bundle and a locked build database. Both were self-inflicted — review agents ran builds against the same DerivedData concurrently — and disappeared once runs were serialised. Worth knowing if these numbers are ever re-derived.
The claim “the fix is now pinned” is worth distrusting on assertion alone, so it was tested: reverting Catalog.loadFailed to the old empty-only semantics fails exactly one test, selectCatalogPartialReportsMissingUnlock, and nothing else. Before this review's change, the same revert failed nothing. That is the difference between a regression test and a comment.
Stated plainly rather than glossed: fetchProducts() itself remains unreachable from any unit test, because it assigns to Product-typed properties and Product has no public initialiser. The rule and the selection are covered; the three assignments and the forwarding line are held by the compiler and by review. The generic applyCatalog workaround was tried and does not compile. If this matters more later, the honest options are making the stored properties generic (an API change) or a UI test over a deliberately-incomplete StoreKit configuration.
The tip jar's missing empty/error state stays out of scope (T-2223), as does StoreManager.products dead state and the Retry button's lack of feedback. The two things this review did widen into are the test change — because a regression test that does not pin the regression is a defect in the change itself — and the spec amendments, because the change made a written requirement false. Both are corrections to this branch, not new features.