The 2026-07-31 re-ruling: gitless boards bind the native stack in every tier — a Pro upgrade no longer removes undo from mode-none boards — and Pro git boards bind the git provider; repo-nested stays the no-undo case under Pro, while the free tier (which never runs detection) binds native there too, per 12's inert posture. Add-git now swaps a live native substrate mid-session: the in-flight stack is cleared with the discarded provider, the git trail seeds from the root commit, and the same BoardUndoManager instance keeps nil-target menu validation fresh. 2405 tests in 413 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
134 lines
7.7 KiB
Swift
134 lines
7.7 KiB
Swift
import Foundation
|
|
|
|
// MARK: - Tier
|
|
|
|
/// Which tier a board session composes under (12-editions.md ▸ The tiers).
|
|
///
|
|
/// **Two cases, and there will never be a third here.** Lanework Teams is deferred and will "never
|
|
/// share an app group or any cross-app state with Lanework" (12 ▸ The tiers, ruled 2026-07-30) —
|
|
/// whatever it becomes, it is a different app, not a third case of this enum.
|
|
///
|
|
/// Nothing about this type is a *feature flag*. It is the answer to one question — free or Pro —
|
|
/// asked once per board session at composition (`AppModel.beginSession`), recorded on the session,
|
|
/// and never asked again for that board. What consumes it is the provider seam
|
|
/// (12 ▸ The provider seam); see `AppModel.makeHistoryProvider`.
|
|
public enum Tier: String, Sendable, Equatable, Codable, CaseIterable {
|
|
|
|
/// Lanework. Boards are plain folders, mode `none` everywhere, undo is macOS-native
|
|
/// (13-native-undo.md), and any `.git` the app meets is inert (12 ▸ The free tier and `.git`).
|
|
///
|
|
/// **This is also the lapsed tier.** "Unsubscribed and lapsed are one state — the inert posture,
|
|
/// nothing lost, histories frozen not forfeited" (12 ▸ The entitlement). There is deliberately no
|
|
/// `.lapsed` case: a case nothing may act on differently is a distinction the design forbids from
|
|
/// existing at all.
|
|
case free
|
|
|
|
/// Lanework Pro — an active auto-renewable subscription. It is what puts git on the table; which
|
|
/// substrate a given board then binds is the *board's* answer, not this case's (re-ruled
|
|
/// 2026-07-31 — `AppModel.makeHistoryProvider`): the git provider on a git board
|
|
/// (06-history-undo.md, 07-sync-collab.md), the same native stack the free tier uses on a gitless
|
|
/// one, nothing on a repo-nested one.
|
|
case pro
|
|
}
|
|
|
|
// MARK: - SubscriptionFacts
|
|
|
|
/// **What the app knows locally about the subscription** — the whole input to the tier decision,
|
|
/// beside a date.
|
|
///
|
|
/// ### Why a cached fact struct rather than a live StoreKit read
|
|
///
|
|
/// 12-editions.md ▸ The entitlement makes two demands that pull in the same direction. Pro state is
|
|
/// "a local read, never a network call ... the open path gains no network dependency"; and offline
|
|
/// grace "resolves toward the paying user" — "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."
|
|
///
|
|
/// The second demand is the reason this type exists as *stored* state rather than as a view onto
|
|
/// `Transaction.currentEntitlements`. StoreKit computes entitlement validity locally, so a
|
|
/// subscription whose expiry has passed drops out of `currentEntitlements` **whether or not the
|
|
/// device has been able to ask the App Store about it** — an offline device and a genuinely lapsed
|
|
/// subscription look identical from that property alone. Holding the last answer ourselves is what
|
|
/// lets the two be told apart in the only direction the design cares about: a *cancellation* (auto
|
|
/// renew off) lapses at its expiry with no network needed, while a *renewal we simply have not heard
|
|
/// about yet* keeps the user paid-up until StoreKit says otherwise (`ProEntitlement.adopt`).
|
|
///
|
|
/// ### Never-subscribed and never-online are one shape, on purpose
|
|
///
|
|
/// `expiration == nil` means "no cached transaction" and covers both the user who has never
|
|
/// subscribed and the fresh install that "has no cached transactions and reads as the free tier
|
|
/// until the first refresh — honest and self-correcting" (12). Nothing distinguishes them because
|
|
/// nothing may: they are the same tier, reached by the same route, correcting themselves the same
|
|
/// way.
|
|
///
|
|
/// `Codable` because these facts are cached across launches in `UserDefaults`
|
|
/// (`AppPreferences.subscriptionFactsKey`) — that cache *is* the "local read" the open path performs.
|
|
public struct SubscriptionFacts: Codable, Sendable, Equatable {
|
|
|
|
/// When the current subscription period ends, as StoreKit last reported it.
|
|
///
|
|
/// `nil` is the no-cached-transaction state — see the type's note. A non-`nil` value is never
|
|
/// evidence on its own that the subscription is *live*: an expiry in the past is either a lapse
|
|
/// or an offline hold, and `willAutoRenew` is what decides which.
|
|
public var expiration: Date?
|
|
|
|
/// Whether the subscription was set to renew, at the last moment StoreKit told us anything.
|
|
///
|
|
/// This is the whole of the offline-grace rule. Auto-renew **on** with a passed expiry is a
|
|
/// renewal the device has not heard about — hold. Auto-renew **off** with a passed expiry is a
|
|
/// cancellation that has run out — lapse, "offline or not" (12 ▸ The entitlement).
|
|
public var willAutoRenew: Bool
|
|
|
|
public init(expiration: Date?, willAutoRenew: Bool) {
|
|
self.expiration = expiration
|
|
self.willAutoRenew = willAutoRenew
|
|
}
|
|
|
|
/// No cached transaction: never subscribed, never online, or an entitlement StoreKit has
|
|
/// definitively withdrawn (a refund, a revocation). All three read as the free tier, and that is
|
|
/// the point — see the type's note.
|
|
public static let none = SubscriptionFacts(expiration: nil, willAutoRenew: false)
|
|
}
|
|
|
|
// MARK: - The decision
|
|
|
|
public extension Tier {
|
|
|
|
/// **The tier decision, as a pure function of cached facts and a date.**
|
|
///
|
|
/// Every semantic here is 12-editions.md ▸ The entitlement's, in its own order:
|
|
///
|
|
/// 1. **No cached transaction → free.** "A fresh install that has never been online has no
|
|
/// cached transactions and reads as the free tier until the first refresh." The
|
|
/// never-subscribed user takes the identical branch, which is what makes unsubscribed and
|
|
/// lapsed one state.
|
|
/// 2. **Expiry in the future → Pro.** "Offline with an active subscription is indistinguishable
|
|
/// from online" — there is no reachability term in this function because there is no
|
|
/// reachability term in the rule.
|
|
/// 3. **Expiry passed, auto-renew on → Pro.** The offline-grace hold: "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." The *answering* is `ProEntitlement`'s job
|
|
/// — this function's job is only to resolve toward the paying user until it happens.
|
|
/// 4. **Expiry passed, auto-renew off → free.** "A cancellation (auto-renew off) lapses at
|
|
/// expiry, offline or not."
|
|
///
|
|
/// The design weighs both wrong-for-a-window directions and accepts them: "a wrong lapse pauses
|
|
/// auto-commits into one catch-up commit; a wrong hold gives away days of local commits — Apple's
|
|
/// own billing grace makes the same trade."
|
|
///
|
|
/// `nonisolated` and `static` because it is exactly as pure as that reads: no stored state, no
|
|
/// clock of its own, no StoreKit. `now` is a parameter rather than a `Date()` inside for the
|
|
/// reason `AppModel.shouldRestoreAtLaunch` takes its two `Bool`s — a decision worth this much
|
|
/// prose is worth being provable without a machine in a particular state.
|
|
///
|
|
/// The expiry comparison is strict (`>`), so an expiry falling exactly on `now` is *past*: a
|
|
/// StoreKit expiration date is the instant the period ends, not the last instant it covers, and
|
|
/// resolving the boundary the other way would extend every subscription by a tick for no reason.
|
|
/// At that boundary rule 3 is usually what answers anyway, which is the paying user's direction.
|
|
static func resolve(from facts: SubscriptionFacts, now: Date) -> Tier {
|
|
guard let expiration = facts.expiration else { return .free }
|
|
if expiration > now { return .pro }
|
|
return facts.willAutoRenew ? .pro : .free
|
|
}
|
|
}
|