Repo-nested boards bind native undo in every tier (25d2513): the no-undo case is gone, makeHistoryProvider answers git or native, and the native path provably never touches the enclosing repository's .git. Session undo steps anchor by card identity, never by path (9119aa1): HistoryAnchor carries the card UUID (plus comment/draft vocabulary) and apply-time validation resolves the current folder via the same both-container walk writeCardBody uses — a board-side lane or trash move no longer stales the coarse close step, while a genuine field collision still skips it whole. 2448 tests in 423 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
134 lines
7.8 KiB
Swift
134 lines
7.8 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), and the same native stack the free tier uses on every
|
|
/// board without app-managed git, repo-nested ones included.
|
|
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
|
|
}
|
|
}
|