Skip to content

fix(license): admit session reactivation and fence stale replies - #197

Merged
johnny4young merged 8 commits into
mainfrom
fix/license-session-reactivation
Oct 8, 2026
Merged

johnny4young merged 8 commits into
mainfrom
fix/license-session-reactivation

Conversation

@johnny4young

@johnny4young johnny4young commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

LicenseKeyPurchaseHandler admits 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; staleActivation asserts that only the superseded OLD seat is released.

Decisions taken

  • Releasing the superseded seat is best-effort and never changes local state; a network failure there leaves the seat to be reclaimed from the Lemon Squeezy account, as before.
  • The "superseded" reason string stays unlocalized like the other storageUnavailable reasons 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)

  • git diff --check passed locally
  • make format, make lint and make test were attempted locally but Swift/Xcode is absent from this Linux executor
  • Exact-head native CI 37379159381 passed strict swift-format, SwiftLint (0 violations), 1505 package tests in 233 suites, 83.9% production-source coverage above the unchanged 80% floor, StoreKit checks, and macOS/iOS builds
  • Preservation models from the other finding lanes do not qualify this licensing repair; its fake-transport regression suite passed natively on the exact head

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

@johnny4young
johnny4young marked this pull request as ready for review October 6, 2026 03:47

@johnny4young johnny4young left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 changed operationID (activation begins one, then fences again on success) and is discarded.
  • Overlapping refreshes: only the newest refreshID may 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 .activated branch 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 calls store.clear() even when no record decodes. That is reasonable, since it also clears a corrupt record.

CI is green.


Generated by Claude Code

claude added 4 commits October 6, 2026 15:50
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
@johnny4young
johnny4young merged commit e4ec63b into main Oct 8, 2026
4 checks passed
@johnny4young
johnny4young deleted the fix/license-session-reactivation branch October 8, 2026 06:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants