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
+9 -138
View File
@@ -146,16 +146,6 @@ public enum AppPreferences {
public static var appearance: AppAppearance? {
UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppAppearance.init(rawValue:))
}
/// The cached subscription facts behind the tier decision (12-editions.md The entitlement)
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
///
/// A scalar default rather than a file in `AppStateHome` because it is two fields, which is the
/// line that type's own note draws. **Not a secret and not a receipt**: the signed transaction
/// store is StoreKit's and stays StoreKit's; this is a *cache of the last answer* whose worst
/// case if edited by hand is one wrong tier until the next refresh corrects it, which is the
/// same self-correction a fresh install already relies on.
public static let subscriptionFactsKey = "subscriptionFacts"
}
// MARK: - Launch failures
@@ -323,36 +313,6 @@ public final class AppModel {
/// reads the pasteboard once and collects every staged tree it no longer names.
public let clipboard: ClipboardStore
// MARK: The entitlement
/// **Lanework Pro's entitlement** (12-editions.md The entitlement) the cached, local answer
/// to "is this a subscriber?", owned here for the registries' reason: it is app-scoped, and a
/// test holds its own over its own defaults rather than colliding with the app's.
///
/// Nothing on the board-open path awaits anything through this object. See `ProEntitlement` for
/// why that is a property of its shape rather than a rule somebody has to remember.
public let entitlement: ProEntitlement
/// **The tier a board session composes under**, as an injectable seam.
///
/// Defaulted to the real entitlement's local read and separated from it so a test binds a tier
/// without needing a StoreKit transaction, an App Store account, or a second `AppModel`
/// initializer. `@MainActor` on the closure type because the entitlement it reads is main-actor
/// state, and `@ObservationIgnored` because nothing renders from it the tier reaches the UI,
/// where it reaches it at all, through `entitlement`.
///
/// **Read once per session, at composition, and never again** (12 The entitlement: "a lapse
/// never interrupts an open session"). `beginSession` is the only caller.
///
/// **Dormant since PIVOT 2026-08-07** (12-editions.md git left the paywall, and the 2026-08-08
/// ruling then excised git entirely): the answer is still read and still recorded on the session
/// (`BoardSession.tier`), and **nothing consults it any more** `makeHistoryProvider` lost the
/// axis, and the stack it used to gate has gone. The seam is kept unchanged, mechanics and all,
/// because the entitlement is correct for whatever the next base/Pro split turns out to gate;
/// what it does not do is decide undo.
@ObservationIgnored
public var currentTier: @MainActor () -> Tier = { .free }
// MARK: The provider seam
/// **The composition root for `HistoryProviding`** (12-editions.md The provider seam): what a
@@ -361,10 +321,11 @@ public final class AppModel {
/// **Every board gets the native stack, and the seam has one answer**
/// (13-native-undo.md's header; `strategy/01-git-excision.md`, ruled 2026-08-08 the app-managed
/// git substrate is excised, so `Kanban/History/` is the only one there is). Nothing about a board
/// decides this any more: not its tier (12 PIVOT 2026-08-07 took the last row the tier decided),
/// not whether it sits inside somebody's repository, not what is on disk beside it. A board
/// nobody has done anything special to and a board living in a user's git repo bind the same
/// stack, which was already true before this ruling and is now true by construction.
/// decides this any more: not what the user paid (12-editions.md PIVOT 2026-08-08 one
/// version, everything free, no edition axis left to consult), not whether it sits inside
/// somebody's repository, not what is on disk beside it. A board nobody has done anything
/// special to and a board living in a user's git repo bind the same stack, which was already
/// true before this ruling and is now true by construction.
///
/// ### Why it is still a seam
///
@@ -421,30 +382,12 @@ public final class AppModel {
/// (`BoardStore.registerStep`). The command surface disables through `undoManager`, which
/// answers the empty way over an absent substrate.
///
/// A `var` rather than a `let` beside `tier`, and now for no event at all: the one sanctioned
/// mid-session substrate swap was add-git's commanded mode flip, which went with the git
/// stack. It stays a `var` because a second provider is a live possibility
/// (`strategy/01-git-excision.md` Reversibility) and because nothing is bought by tightening
/// it; a tier lapse could never touch it `tier` has no setter, and since PIVOT 2026-08-07 it
/// has no say in this either.
/// A `var` rather than a `let`, and now for no event at all: the one sanctioned mid-session
/// substrate swap was add-git's commanded mode flip, which went with the git stack. It stays
/// a `var` because a second provider is a live possibility (`strategy/01-git-excision.md`
/// Reversibility) and because nothing is bought by tightening it.
public var history: (any HistoryProviding)?
/// **The tier this board composed under** (12-editions.md The entitlement) recorded,
/// and **dormant since PIVOT 2026-08-07**.
///
/// A `let`, on a value type, set once by `beginSession`. That is the entire mechanism behind
/// "a lapse never interrupts an open session": there is no setter, no observation, and
/// nothing anywhere that re-evaluates a live session's tier, so a subscription ending
/// mid-session is a fact about the *next* open and about nothing already on screen.
///
/// **Nothing reads it.** Git left the paywall (12 PIVOT 2026-08-07) and then left the app
/// (`strategy/01-git-excision.md`, ruled 2026-08-08), so no surface anywhere is decided by
/// this. It stays recorded because the entitlement's machinery stays built and correct for
/// whatever the re-decided base/Pro split gates, and because the fact a board opened under is
/// a composition-time answer the way the provider binding is: whatever asks next must get what
/// this board opened with, never what the entitlement says at render time.
public let tier: Tier
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
@@ -670,14 +613,6 @@ public final class AppModel {
zoom = BoardZoomStore(defaults: preferences)
appearance = AppearanceStore(defaults: preferences)
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
// Reads the cached facts and nothing else no StoreKit API is touched until
// `ProEntitlement.start()`, which the app's launch calls and a test host never does.
let entitlement = ProEntitlement(defaults: preferences)
self.entitlement = entitlement
// Bound after the stored properties are in place, so the closure captures the object rather
// than a half-built `self`. This is the app's default wiring; a test that wants a tier
// assigns over it.
currentTier = { entitlement.tier }
// Read once here rather than lazily, so File Open Recent is populated from the app's first
// menu pass a launch that restores boards never shows welcome, and a submenu that filled
// in only after the first close would look broken. It costs one bookmark-resolution sweep at
@@ -836,16 +771,6 @@ public final class AppModel {
/// drop 02 forbids it forbids a failure that was never surfaced disappearing, not one the user
/// has since fixed.
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
// **The entitlement read** (12-editions.md The entitlement): "Pro state is read from
// StoreKit's signed on-device transaction store at board-session composition the open path
// gains no network dependency." Synchronous, over facts already in memory. It is the *only*
// time this board asks: the answer becomes `BoardSession.tier` and nothing re-derives it.
//
// **Recorded, and consulted by nothing below** (12 PIVOT 2026-08-07 git left the paywall;
// `strategy/01-git-excision.md`, ruled 2026-08-08 git left the app). This line used to sit
// on the same line as the git state it gated; both the gate and the state are gone, and what
// is left is a dormant fact kept for the base/Pro split still to be ruled.
let tier = currentTier()
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior".
@@ -861,7 +786,6 @@ public final class AppModel {
store: store,
recordID: recordID,
history: history,
tier: tier,
// The lock's enablement half (13-native-undo.md Rules): Undo and Redo disable with the
// other mutating commands while the board refuses writes, and the stack survives to
// resume when it clears. Weak, so the adapter is never the reason a closed board's store
@@ -1131,59 +1055,6 @@ public final class AppModel {
return session.cardRefs.contains { cardSessions[$0]?.holdsUnsavedContent == true }
}
/// **The purchase flow's reopen offer, carried out** (12-editions.md The entitlement:
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
/// boards so the upgrade feels immediate").
///
/// ### Close and open, through the ordinary paths
///
/// There is no reopen-in-place mechanism here and deliberately so: the provider binding is a
/// composition-time fact, so "apply the new tier to this board" *means* end its session and
/// compose a new one. Doing that through `closeBoard` and `openBoard` the same two calls W and
/// welcome make is what keeps every guarantee those paths carry: the close flush runs in its
/// fixed order (card windows, pending work, registry stamp, teardown), the reopen resolves and
/// re-scopes the board's URL exactly as a fresh open does, and the registry records both.
///
/// ### Why the window is dismissed rather than reused
///
/// A board window's identity is its root path (`BoardWindowRef`), so reopening the same board
/// hands `openWindow(value:)` a ref it already has a window for which *focuses* that window
/// instead of building a new one, and the window it would focus is one whose host has already run
/// its one-shot load. Dismissing first is what makes the reopen an open. The dismissals are all
/// issued before any reopen, then given a run-loop turn to land: SwiftUI processes a window's
/// teardown asynchronously, and asking for a value's window in the same turn it was dismissed is
/// the one way this sequence can produce a focused corpse.
///
/// ### Declining costs nothing, today least of all
///
/// A board that says Not Now keeps the substrate it composed with, and since every board composes
/// the same one (`makeHistoryProvider`), that costs undo nothing at all. The offer exists because
/// "subscribe takes effect at each board's next open" (12 The entitlement) needs one, not
/// because anything breaks without it.
public func reopenOpenBoards() async {
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
let refs = sessions.keys.sorted { $0.path < $1.path }
guard !refs.isEmpty else { return }
// The store's `rootURL` rather than the ref's path: a board renamed while open keeps the
// path it was opened with, and reopening it there would open nothing (`BoardStore.rootURL`).
var roots: [URL] = []
for ref in refs {
guard let session = sessions[ref] else { continue }
roots.append(session.store.rootURL)
await closeBoard(ref: ref, cause: .userClose)
windowDismisser?(value: ref)
}
// One run-loop turn for the dismissals see the note above. `Task.sleep` rather than
// `Task.yield` because the main run loop, not the cooperative pool, is what has to advance.
try? await Task.sleep(for: .milliseconds(150))
for root in roots {
openBoard(at: root)
}
}
/// Quit: the same sequence, once per open board, **sequentially**.
///
/// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than