One Key, Five Macs — Keeping Activation Seats From Quietly Leaking Away
A Pier postmortem: a perpetual license caps activation at 5 devices, but if 'deactivate' only clears local state, the server-side seat stays occupied forever, and after a few machine changes users hit the wall and email you to free them by hand. The fix: deactivation must release the seat server-side, and on failure would rather not release than quietly leak.
Pier 2.0 goes paid, and one key activates on up to 5 Macs. That “5 devices” limit looks simple, but there’s an easy hole in getting it right: if deactivation only clears local state, the server-side seat stays occupied forever. A user reinstalls a few times, switches machines, and the 5 seats quietly leak away — then they hit the wall, with no recourse but to email me for a manual release. This post is about getting that seat lifecycle right.
I’ve written before about Pier’s backend-free license system: activation and validation both go through payment provider Dodo Payments’ public endpoints, no server on my side. That post was about the architecture; this one fills in a specific piece of correctness — deactivation.
The seat is server-side state; local clearing can’t touch it
First, what “activation” is on the server. Activating a machine calls Dodo’s activate, and the server creates an activation instance for that machine, consuming one of the 5 seats, and returns an instance id. Local keychain stores the key and that instance id.
The first version of deactivation did only half the job:
// v1: only clears the local keychain
func deactivate() {
Keychain.set(nil, for: K.licenseKey)
Keychain.set(nil, for: K.activationID)
}
Local is clean, the UI returns to unactivated. But that server-side instance still exists, still holding a seat. The user’s mental model is “I deactivated, so I freed up a machine’s slot”; in reality that machine’s seat sank without a trace. Reinstalling the app = one deactivate + one activate = a net loss of one seat. A few install/uninstall cycles, and the 5-device allowance runs dry without the user having any idea.
Deactivation must release server-side first
The fix makes deactivation truly symmetric to activation — call the server’s deactivate to release the seat first, then clear local:
func deactivate() async {
guard let key = Keychain.get(K.licenseKey),
let instanceID = Keychain.get(K.activationID) else {
clearLocalActivation(); return
}
isDeactivating = true
defer { isDeactivating = false }
do {
try await Dodo.deactivate(licenseKey: key, instanceID: instanceID)
clearLocalActivation() // server released the seat, only then clear local
} catch {
activationError = error.localizedDescription // failure: don't clear local
}
}
Note the order: only clear local after the server release succeeds. The reverse (clear local first, then call the API) reintroduces the leak — if local is cleared and the API happens to fail, nobody can ever reference that instance id again to release it, so the seat leaks permanently.
Failure semantics: rather not release than quietly leak
The real design lives in the exception branch. The deactivate request can end several ways, handled separately:
- Success (2xx): seat released, clear local, done;
- 4xx = the server already doesn’t recognize this instance (e.g. it was released long ago): equivalent to “already released,” so clear local too — the goal is met;
- 429 / 5xx / network down: server state unknown, keep local activation and prompt to retry. Never clear local here — once cleared, local loses the instance id, the only credential that can release the seat, and the seat leaks forever.
The rule in one line: either confirm it was released, or leave things as they are for the user to retry — never create the in-between state where “local thinks it’s gone but the server still holds it.” Better to make the user click retry once than to let a seat evaporate unnoticed. This is the same philosophy as the validation failure semantics in the architecture post — only change state on definitive evidence, hold steady when ambiguous — just in the opposite direction: validation is “don’t drop activation without solid evidence of invalidity,” deactivation is “don’t clear local without solid evidence of a successful release.”
Along the way I corrected a related bit of logic: when startup’s silent revalidate finds the license invalid and needs to roll back, it now does a purely local clear and no longer mistakenly calls the deactivate API — that path’s semantics are “the server already said this instance is invalid,” so calling release is redundant, even contradictory.
The UI has to keep up too
With the backend right, the front end’s honesty has to keep up. The deactivate button gained an in-progress spinner and a failure prompt (bilingual): when the user taps deactivate, if it’s stuck on the network, they should see “deactivating” rather than a silent state pretending it’s done; if it failed, they should see “didn’t deactivate, please retry” rather than a UI showing deactivated while the server still holds the seat. An async operation that can fail must have its UI honestly reflect its three states — in progress, success, failure — rather than optimistically pretending instant success.
And spelling out the machine-change flow
Once the code was right, the site FAQ got a machine-migration note too: one key, 5 Macs, just activate on the new machine directly, with nothing to do on the old one first; the key is the license and keeps working after reinstalls and OS upgrades; and if you ever genuinely run out of seats, one email frees up retired machines. Getting “deactivate releases the seat” right is the precondition for this self-service flow to hold up — otherwise every machine-switching user eventually turns into an email that needs handling by hand.
Takeaways
- A license with limited seats must release server-side on deactivation. Clearing only local is half the job — the server instance still holds a seat, and a few install/uninstall cycles quietly drain the allowance;
- The release order is “server success → clear local,” not the reverse. Clear local first and if the API fails you’ve lost the only credential to release the seat — a permanent leak;
- The failure branch decides whether seats leak. Treat 4xx as already-released, keep state and retry on 5xx / network down — never create the “local gone, server still holds” in-between. Rather make the user retry than let a seat vanish silently;
- An async operation that can fail must show its three states honestly. In-progress spinner, success clear, failure retry prompt — don’t use optimistic UI to fake instant success; the user needs to know whether it actually deactivated.
Comments