Lanework Pro is a subscription — the tier seam, StoreKit 2, and Settings

Phase 3 of the one-app pivot (DESIGN 12 ▸ The entitlement / Distribution,
ruled 2026-07-30; card c3a3ddd5). New Kanban/Tier/: Tier (.free/.pro —
deliberately no .lapsed case; unsubscribed and lapsed are one state) and
the pure decision Tier.resolve(from:now:) over SubscriptionFacts
(expiration + willAutoRenew), unit-tested through all five named states:
free, active, lapsed, offline-grace, never-online.

The facts are a persisted cache (standard defaults), not a live view:
StoreKit ages an expired subscription out of currentEntitlements locally,
so an offline device and a real lapse are indistinguishable from that
property alone — the cache holds the last answer, empty entitlements
read as silence, and holds end only on a definitive answer (revocation,
or the subscription-group status read Settings performs). That is 12's
offline-grace trade, resolved toward the paying user.

ProEntitlement is the local adapter (currentEntitlements +
Transaction.updates, started from launch, never from a test host);
ProStorefront holds everything networked (product load, purchase,
AppStore.sync) and only the Settings section ever constructs one — the
split is the enforcement of "never network on the open path".
beginSession reads the tier once at composition; BoardSession.tier is a
let with no path back in, so a lapse never rebinds an open session.
makeHistoryProvider now takes the tier; both tiers bind the native stack
until pro-m1 builds the git provider — the seam's consumer is named, not
invented early.

Settings gains the Pro section (subscribe with localized price, manage,
restore; a quiet unreachable line, no indefinite spinner) — the third of
the exactly-three Pro mentions; the About line gains its "…in Settings"
pointer now that there is a Settings to point at. A successful purchase
or restore offers once to reopen open boards (close + reopen through the
ordinary paths). Configuration.storekit wired into the scheme's run
action for ASC-free exercise; RELEASE.md gains the pro-m1 store-side
steps and the rule that the product must not be configured before then.

1901 tests in 319 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 18:14:58 -04:00
parent 2c6b8fe63a
commit 3b19883593
15 changed files with 1459 additions and 15 deletions
+4
View File
@@ -53,6 +53,8 @@ Lanework is in early development. This list tracks what has actually shipped and
- **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12).
- **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription will unlock is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **None of that is built yet** — pro-m1 and pro-m2 are the milestones that build it, and until they ship both tiers run the same native undo stack over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else.
## Development
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`:
@@ -73,6 +75,8 @@ macOS 26+, Swift 6 (strict concurrency), SwiftUI, sandboxed. Internal codename `
**One app target** (DESIGN/12-editions.md ▸ The target). `project.yml` declares exactly three: the app `Kanban`, the unit bundle `KanbanTests` hosted by it, and the UI bundle `KanbanUITests`; one scheme, `Kanban`, builds and tests all of them. The 2026-07-27 two-target edition split — a second `KanbanPro` application, its `LaneworkPro` scheme, a `KanbanProTests` bundle bound to it by `-module-alias`, and `scripts/verify-editions.sh` — was retired on 2026-07-30 in favour of one app with Lanework Pro as a subscription inside it. The `dev.rzen.indie.kanban-board` UTI and the three pasteboard types are exported once, by the one app that owns them.
**StoreKit runs locally in development.** `Configuration.storekit` at the repo root declares the "Lanework Pro" subscription group and its monthly product (`dev.rzen.indie.kanban.pro.monthly`), and `project.yml` attaches it to the scheme's *run* action, so a ⌘R build shows a real price and completes a real purchase with no App Store Connect product and no sandbox account. It is a run-action setting only — builds, tests and `scripts/release.sh` are untouched, and the file is `buildPhase: none`, so it never reaches the shipped bundle. The same three strings live in App Store Connect (created by hand — RELEASE.md) and in `Kanban/Tier/ProProducts.swift`; a mismatch shows up in the app as "Can't reach the App Store right now."
**Accessibility is verified, not assumed** (DESIGN/10-accessibility.md § Verification). `KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over all eight surfaces the design names — the board with the trash shown and hidden, the card window in Preview, Edit and raw source, welcome, the template chooser, the board popover — and every violation is a test failure with nothing waived. The manual half — the per-release VoiceOver smoke script and the consolidated accessibility checklist — is `KanbanUITests/AccessibilityVerification.md`.
**The golden paths are verified end to end too.** `KanbanUITests/EndToEndFlowTests.swift` drives create card, create lane, inline rename, a pointer drag across lanes, cut/paste across lanes, undo and redo of a move, and the whole current trash grammar — delete into `.trash/` with no confirmation, View ▸ Show Trash, restore by ⌘X/⌘V back out, and Empty Trash… with its confirmation. `FailFastLaunchTests.swift` launches onto a board with one unparseable `index.md` and asserts the loud failure: no board window, welcome carrying the loader's own sentence naming the offending file, and nothing on disk repaired. `LargeBoardPerformanceTests.swift` measures launch and one interaction against an 8 × 40 board under explicit wall-clock budgets (XCTest baselines do not travel between machines, so the gate is an assertion rather than a baseline). The three flows that stay manual — instantiating a template, Duplicate's save-panel fallback, and File ▸ Open… — are the sandbox's Powerbox panels, which live in another process; they are written down as manual steps in `KanbanUITests/EndToEndVerification.md` rather than automated flakily.