Files
lanework/Kanban/Tier/Tier.swift
T
rzen 2e4dde5655 Bind the undo provider to the board, not the tier
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
2026-07-31 18:53:38 -04:00

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
}
}