The subscription machinery leaves the code — Kanban/Tier excised, StoreKit wiring unwound

The 2026-08-08 one-version ruling (12-editions.md ▸ PIVOT 2026-08-08) carried out: Kanban/Tier/
deleted wholesale (Tier, ProEntitlement, ProProducts, ProStorefront, the never-rendered
ProSettingsSection) with TierTests and Configuration.storekit, whose project.yml resource entry
and scheme storeKitConfiguration go with it. AppModel loses the entitlement, the currentTier
seam, BoardSession.tier, and the purchase flow's reopenOpenBoards (its only caller was the
storefront); AppDelegate's launch keeps only the appearance application. The three tests
pinning the recorded tier and the reopen are deleted with their subject. The network-client
entitlement stays — the sync capability to come needs it regardless — and the HistoryProviding
seam stands untouched. 2,686 unit tests green (2,707 minus the 21 that tested what left).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 13:18:54 -04:00
parent 871083e5ca
commit e1f89d9cf9
15 changed files with 48 additions and 1465 deletions
+2 -76
View File
@@ -284,9 +284,8 @@ struct NativeHistoryProviderTests {
/// A provider with no `NSUndoManager` anywhere in it which is the point: `BoardUndoManager` is
/// tested against *this* rather than against the native stack, because what has to be true is that
/// the adapter works for any implementation of the seam (Pro's git provider once bound the same
/// protocol, in pro-m1, before app-managed git was excised entirely,
/// `strategy/01-git-excision.md`).
/// the adapter works for any implementation of the seam (the git provider bound the same protocol,
/// in pro-m1, before app-managed git was excised entirely, `strategy/01-git-excision.md`).
@MainActor
private final class FakeHistoryProvider: HistoryProviding {
@@ -597,79 +596,6 @@ struct BoardSessionHistoryTests {
session.undoManager.undo()
#expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound")
}
@Test("The session records the tier it composed under — and the provider root never sees it")
func theTierIsARecordedFact() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
// 12-editions.md The entitlement: the tier is read at composition, once, and recorded.
// **PIVOT 2026-08-07**: it is no longer *handed* anywhere, and the 2026-08-08 excision took
// the last argument beside it the seam takes a store and nothing else. What is pinned here
// is what replaced the old matrix: one call per board, and a session still carrying the tier
// for the base/Pro split yet to be ruled.
var seen = 0
model.currentTier = { .pro }
model.makeHistoryProvider = { _ in
seen += 1
return NativeHistoryProvider()
}
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(seen == 1, "called once per board, with no tier to consult")
#expect(session.tier == .pro, "the session carries the fact it composed under")
}
@Test("A tier change never reaches a session that is already open")
func aLapseNeverRebindsAnOpenSession() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
#expect(try #require(model.session(for: ref)).tier == .pro)
// The subscription lapses mid-session the one thing 12 The entitlement says must not
// disturb a board that is already on screen: "an open board finishes with the provider it
// composed". (Its second clause "the next open composes the native stack over inert
// `.git`" went with PIVOT 2026-08-07, when for one day the next open could compose a git
// stack instead; the 2026-08-08 excision (`strategy/01-git-excision.md`) then restored the
// original clause outright every open composes the native stack over inert `.git` again,
// permanently. What is pinned here is the recorded fact's immutability, which is what makes
// the first clause true whatever a future split gates.)
model.currentTier = { .free }
#expect(try #require(model.session(for: ref)).tier == .pro)
}
@Test("The purchase flow's reopen ends every session, because that is what recomposing means")
func reopeningEndsTheSessions() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let ref = try openBoard(model, at: fixture.root)
#expect(model.hasOpenBoards)
// 12 The entitlement: "Subscribe takes effect at each board's next open ... The purchase
// flow offers to reopen open boards." There is no rebinding-in-place to test for, and that
// is the finding: reopening *is* ending the session and composing a new one, so what this
// pins is the ending. The reopen half needs SwiftUI's window actions, which this host has
// none of the URLs simply buffer until an opener exists (`AppModel.openBoard`), which is
// the same path a cold-launch Finder open already takes.
await model.reopenOpenBoards()
#expect(model.session(for: ref) == nil)
#expect(model.hasOpenBoards == false)
#expect(model.storeRegistry.openBoardCount == 0, "the store and its watcher went with the session")
}
}
// MARK: - The command surface
-223
View File
@@ -1,223 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// **The tier decision** (12-editions.md The entitlement).
///
/// Every test here goes through `Tier.resolve(from:now:)` with facts and a date the test wrote down
/// no StoreKit, no App Store account, no clock. That is the point of the seam: the semantics 12
/// rules are semantics about *cached facts and a date*, and everything else in the Pro stack
/// (`ProEntitlement`, `ProStorefront`) exists to produce those facts honestly. Pinning the decision
/// here is what makes the adapter thin enough to read.
///
/// The five states 12 names are the five suites' worth of cases below, in its own order:
/// active, lapsed, offline-grace, never-online, and free with free and never-online deliberately
/// arriving at the same input, because 12 says nothing may distinguish them.
@Suite("Tier ▸ the entitlement decision")
struct TierDecisionTests {
/// A fixed instant, so nothing here depends on when the suite runs.
static let now = Date(timeIntervalSinceReferenceDate: 800_000_000)
private static func resolve(_ facts: SubscriptionFacts) -> Tier {
Tier.resolve(from: facts, now: now)
}
// MARK: - Free
@Test("No cached transaction reads as the free tier")
func freeTier() {
#expect(Self.resolve(.none) == .free)
}
@Test("An auto-renew flag with no expiry behind it is still the free tier")
func autoRenewWithoutAnExpiryIsNotAnEntitlement() {
// The offline-grace hold is about an expiry that *passed*, never about the flag on its own.
// A fact set that carries a renew flag and no period is not a subscription in any state.
#expect(Self.resolve(SubscriptionFacts(expiration: nil, willAutoRenew: true)) == .free)
}
// MARK: - Active
@Test("An expiry in the future is Pro")
func activeSubscription() {
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 20), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
@Test("An active subscription the user has already cancelled is Pro until its expiry")
func cancelledButStillInsideThePaidPeriod() {
// "A cancellation (auto-renew off) lapses **at expiry**" not when it is made. The paid
// period is paid for.
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 3), willAutoRenew: false)
#expect(Self.resolve(facts) == .pro)
}
@Test("Offline changes nothing about an active subscription")
func offlineWithAnActiveSubscription() {
// 12: "Offline with an active subscription is indistinguishable from online." Pinned by the
// absence of a reachability parameter as much as by this assertion there is nothing to
// pass in that could make this answer differently.
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 400), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
// MARK: - Lapsed
@Test("A cancellation whose expiry has passed is the free tier")
func lapsedSubscription() {
// 12: "A cancellation (auto-renew off) lapses at expiry, offline or not."
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60), willAutoRenew: false)
#expect(Self.resolve(facts) == .free)
}
@Test("A long-lapsed cancellation is the same free tier as never having subscribed")
func lapsedAndUnsubscribedAreOneState() {
// "Unsubscribed and lapsed are **one state** the inert posture, nothing lost, histories
// frozen not forfeited." Nothing downstream may tell these two apart, and the decision is
// where that starts: one answer, from two histories.
let lapsed = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60 * 24 * 365), willAutoRenew: false)
#expect(Self.resolve(lapsed) == Self.resolve(.none))
}
// MARK: - Offline grace
@Test("An expiry that passed with auto-renew still on holds the entitlement")
func offlineGraceHoldsForThePayingUser() {
// 12: "an on-disk expiry passing while offline, with the last known state *active and
// auto-renew on*, holds the entitlement until StoreKit actually refreshes and answers."
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
@Test("The offline-grace hold does not decay with time")
func theHoldHasNoTimeout() {
// Deliberately no expiry-of-the-expiry: the hold ends when StoreKit *answers*
// (`ProEntitlement.adopt`), not when a timer this function knows nothing about runs out.
// 12 weighs the cost and takes it: "a wrong hold gives away days of local commits Apple's
// own billing grace makes the same trade."
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60 * 24 * 90), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
@Test("Auto-renew is the only thing separating a hold from a lapse")
func theFlagIsTheWholeDifference() {
let expired = Self.now.addingTimeInterval(-60 * 60 * 24)
#expect(Self.resolve(SubscriptionFacts(expiration: expired, willAutoRenew: true)) == .pro)
#expect(Self.resolve(SubscriptionFacts(expiration: expired, willAutoRenew: false)) == .free)
}
// MARK: - Never online
@Test("A fresh install that has never been online reads as the free tier")
func neverOnline() {
// 12: "A fresh install that has never been online has no cached transactions and reads as
// the free tier until the first refresh honest and self-correcting." The input is
// identical to `freeTier` above and that is the finding, not a duplicate: a device that has
// not asked yet and an account that never subscribed are the same state, and the app has no
// vocabulary for telling them apart.
#expect(Self.resolve(.none) == .free)
}
@Test("The first refresh is what corrects a never-online install")
func theFirstRefreshCorrectsIt() {
// The self-correction, as the decision sees it: nothing about the free answer is sticky
// the same function over the facts a refresh produces answers Pro immediately.
#expect(Self.resolve(.none) == .free)
let refreshed = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 30), willAutoRenew: true)
#expect(Self.resolve(refreshed) == .pro)
}
// MARK: - The boundary
@Test("An expiry falling exactly on now is past")
func theExpiryBoundaryIsExclusive() {
// A StoreKit expiration date is the instant the period ends, not the last instant it covers.
// With auto-renew off that means the lapse takes effect at the boundary rather than a tick
// after it; with auto-renew on the grace hold catches it, which is the paying user's side.
#expect(Self.resolve(SubscriptionFacts(expiration: Self.now, willAutoRenew: false)) == .free)
#expect(Self.resolve(SubscriptionFacts(expiration: Self.now, willAutoRenew: true)) == .pro)
}
@Test("The decision is a pure function of its two arguments")
func theSameInputsAlwaysAnswerTheSame() {
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-1), willAutoRenew: true)
let answers = (0..<5).map { _ in Tier.resolve(from: facts, now: Self.now) }
#expect(Set(answers).count == 1)
// And a *different* date genuinely moves it the clock is an argument, not decoration.
let before = Tier.resolve(from: facts, now: Self.now.addingTimeInterval(-60))
#expect(before == .pro, "still inside the paid period")
}
}
// MARK: - The cached facts
/// The entitlement's cache, exercised through its injected `UserDefaults` the same injection every
/// other app-side store in this project takes (`AppStateHome`), and here it also keeps a suite from
/// editing the developer's own subscription state.
///
/// StoreKit itself is deliberately absent: `ProEntitlement.start()` and
/// `refreshFromLocalTransactions()` are the only members that touch it, neither is called here, and
/// what is left is exactly the seam worth pinning that the facts round-trip, that the tier follows
/// them, and that adopting `.none` ends a hold.
@MainActor
@Suite("Tier ▸ the entitlement's cache")
struct ProEntitlementCacheTests {
/// A defaults domain of this suite's own. Removed at the end of each test so the cases cannot
/// see each other's writes.
private static func makeDefaults() -> (UserDefaults, () -> Void) {
let name = "dev.rzen.indie.Kanban.tier-tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
return (defaults, { UserDefaults.standard.removePersistentDomain(forName: name) })
}
@Test("A fresh domain has no facts, and the tier is free")
func emptyCache() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
let entitlement = ProEntitlement(defaults: defaults)
#expect(entitlement.facts == .none)
#expect(entitlement.tier == .free)
}
@Test("Adopted facts survive into the next launch")
func factsRoundTrip() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
let facts = SubscriptionFacts(expiration: Date().addingTimeInterval(60 * 60 * 24 * 30), willAutoRenew: true)
ProEntitlement(defaults: defaults).adopt(facts)
// A second object over the same domain is what a relaunch is.
let relaunched = ProEntitlement(defaults: defaults)
#expect(relaunched.facts == facts)
#expect(relaunched.tier == .pro)
}
@Test("Adopting nothing ends the entitlement — StoreKit's definitive answer")
func adoptingNoneEndsAHold() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
let entitlement = ProEntitlement(defaults: defaults)
// An offline-grace hold: the expiry passed, the last we heard it was renewing.
entitlement.adopt(SubscriptionFacts(expiration: Date().addingTimeInterval(-60), willAutoRenew: true))
#expect(entitlement.tier == .pro)
// and then StoreKit answers (a revocation, or a group status reading expired).
entitlement.adopt(.none)
#expect(entitlement.tier == .free)
#expect(ProEntitlement(defaults: defaults).tier == .free, "and the answer is cached, not just held")
}
@Test("A cache that will not decode is treated as no cache")
func aCorruptCacheIsEmpty() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
defaults.set(Data("not json".utf8), forKey: AppPreferences.subscriptionFactsKey)
#expect(ProEntitlement(defaults: defaults).tier == .free)
}
}