Repository navigation
fix(license): admit session reactivation and fence stale replies - #197
Merged
Merged
Conversation
johnny4young
marked this pull request as ready for review
October 6, 2026 03:47
johnny4young
commented
Oct 6, 2026
johnny4young
left a comment
Owner
Author
There was a problem hiding this comment.
Summary: LicenseKeyPurchaseHandler gains an operation fence (operationID) and a latest-refresh fence (refreshID). A verified activation now clears the revokedThisSession latch, using persistedRecord() for readback. deactivate clears local state before the network call.
Verdict: ready. I walked the state machine across every await reentrancy point on the main actor and found no bug:
- Refresh vs. activation: refreshes never call
beginOperation. A refresh started before or during an activation sees a changedoperationID(activation begins one, then fences again on success) and is discarded. - Overlapping refreshes: only the newest
refreshIDmay apply, and it also re-checks key and instance identity against the stored record. - Activation vs. deactivation: a deactivation during a pending activation supersedes it, and an old deactivation reply can no longer clear a newer activation. This fixes the original bug.
- Re-activation after revocation: this used to always report "did not persist" because
storedRecord()returned nil under the latch. That is now fixed.
🟡 Nits:
- When an activation is superseded by a deactivation, Lemon Squeezy has already consumed an activation slot that is never stored locally. Recoverable from the LS account, but consider calling
activation.deactivate(record)in the superseded.activatedbranch so the slot is released. "A newer license action superseded this request"is user-visible through.storageUnavailable(reason:)and is not localized. Also check that the UI does not present it as a storage failure.deactivate()now callsstore.clear()even when no record decodes. That is reasonable, since it also clears a corrupt record.
CI is green.
Generated by Claude Code
When a newer license action superseded a pending activation, its reply was discarded locally but Lemon Squeezy had already created an instance for it, leaving an activation slot used on the account. Deactivate that instance, unless it is the one the newer action stored.
… once - Apply the superseded fence only to a successful activation; a stale rejected or unreachable reply changes no local state and now reports its real result instead of a storage failure - Move seat release for a superseded activation into one helper - Read the stored record once when fencing a refresh reply - Let readback recover in the failed-admission test so only the session latch can keep the tier at Free
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
LicenseKeyPurchaseHandleradmits a verified re-activation after a revocation or sign-out in the same session, and fences stale replies with an operation generation (user actions) and a latest-refresh token (background revalidation). Details below.Review follow-ups
b24a751— fix(license): release the seat a superseded activation consumed. When a newer license action supersedes a pending activation, the reply is still discarded locally, but the Lemon Squeezy instance it created is now deactivated, unless it is the very instance the newer action stored. The fake transport now returns a distinct instance per activation (as Lemon Squeezy does) and records deactivations;staleActivationasserts that only the supersededOLDseat is released.Decisions taken
storageUnavailablereasons in this handler.How it was tested (this round)
swiftc -parse+swift-format lint --strict(Swift 6.0.3 container) on changed files; not compiled locally, relies on macOS CI (see below).Merge order
Independent; merge before the v0.10.0 release PR #193.
CI: macOS jobs for the new head were queued when this description was written; check the PR checks tab.
Original description (state at
8854927):Problem
A session revocation latch hid a newly persisted activation during readback, causing valid reactivation to fail until restart. Older suspended license replies could mutate a newer activation.
Repair
Verify new persisted identity independently of the old latch, then clear it only after successful readback. Fence user activation/deactivation generations and affected refresh record identity. Keep refresh admission separate so background revalidation cannot supersede a user's pending activation; successful admission invalidates refreshes started during that activation. Apply local sign-out before deactivation suspends so an older response cannot clear a later entitlement.
Regression coverage added
Deactivate/reactivate and revoke/reactivate on one handler; delayed old refresh/deactivation responses; superseded old activation; refresh during activation; failed save/readback retains Free after revocation. All use canned fake transport/storage with no real license traffic. Existing vendor-identity, grace, offline and entitlement tests remain intact.
Exact-head validation
Previous head 08382ba passed macOS/iOS builds, StoreKit checks and strict swift-format. Its sole SwiftLint defect was the fake transport's Data-to-String conversion; this head repairs that fixture with failable UTF-8 decoding. The exact published head is now fully qualified by the native CI gates below.
Published head: 8854927
Uploaded/local staged tree: e1a28498e44d86707ad7752d88351f0bbd6c6e3b (verified identical before publication)
Combined source review
All five repair trees plus unmerged MCP PR195 at c90e07d combine without textual conflicts in a disposable local tree. Native combined execution remains unverified. MCP optional binary content may become nil after legitimate deletion/GC; the captured row hash is never replaced by a newer body lookup. No stronger availability promise for deleted content or new public-stack dependency is inferred.
Ready for human review only. No merge/release, live Keychain, clipboard, real user library or backup access.
Human-review readiness
Scoped implementation and regression coverage are complete at unchanged head 8854927. Fresh verification confirms both required native jobs and GitGuardian successful, the expected non-blocking beta-canary skip, and no pending discussion comments, submitted reviews or unresolved inline review threads. Review readiness does not authorize merge, release or deployment.
🤖 Generated with Claude Code
https://claude.ai/code/session_01ExmnfUngrDYjAheGvYwMnn
Generated by Claude Code