commit f2864e88dcaadc307889787df22b01503f3ac594
parent 8ccd9e1ac8a437b4ebd8989fe7f3422a0cdd8532
Author: triesap <tyson@radroots.org>
Date: Wed, 12 Aug 2026 19:42:51 +0000
identity: release removal requests safely
- own each native removal request through an explicit presenter lease
- release expired and replaced requests exactly once before state cleanup
- quarantine false, exceptional, or cancellation-uncertain releases
- preserve request-bound confirmation with terminal lifecycle diagnostics
Diffstat:
2 files changed, 295 insertions(+), 44 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt
@@ -3,6 +3,7 @@ package org.harvestcircle.application
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
class HarvestCirclePresenter(
private val runtime: HarvestCircleRuntime,
@@ -27,7 +29,7 @@ class HarvestCirclePresenter(
private var subscriptionJob: Job? = null
private var commandJob: Job? = null
private var pendingRecovery: GeneratedIdentityRecovery? = null
- private var pendingRemoval: IdentityRemovalRequest? = null
+ private var pendingRemoval: PendingRemovalLease? = null
private var pendingRetry: PendingRetry? = null
private val closeMutex = Mutex()
private var closeReceipt: ShutdownReceipt? = null
@@ -209,8 +211,7 @@ class HarvestCirclePresenter(
private fun requestIdentityRemoval(intent: HarvestCircleIntent.RequestIdentityRemoval) {
launchOperation {
- pendingRemoval?.let { previous -> runtime.cancelIdentityRemoval(previous.requestId) }
- pendingRemoval = null
+ pendingRemoval?.let { previous -> releaseRemovalRequest(previous, RemovalStatus.NONE) }
updateState {
copy(
removalConfirmation = null,
@@ -218,8 +219,12 @@ class HarvestCirclePresenter(
)
}
val request = runtime.requestIdentityRemoval(intent.identityId)
- check(request.expiresAt.value > clock.now().value) { "Identity removal request is already expired" }
- pendingRemoval = request
+ val lease = PendingRemovalLease(request)
+ pendingRemoval = lease
+ if (request.isExpired()) {
+ releaseRemovalRequest(lease, RemovalStatus.FAILED)
+ throw ApplicationFailure(removalExpiredProblem())
+ }
updateState {
copy(
removalConfirmation = request.toConfirmation(),
@@ -230,28 +235,24 @@ class HarvestCirclePresenter(
}
private fun cancelIdentityRemoval(intent: HarvestCircleIntent.CancelIdentityRemoval) {
- val request = matchingRemoval(intent.identityId, intent.requestId) ?: return
+ val lease = matchingRemoval(intent.identityId, intent.requestId) ?: return
launchOperation {
- runtime.cancelIdentityRemoval(request.requestId)
- if (pendingRemoval == request) {
- pendingRemoval = null
- updateState {
- copy(
- removalConfirmation = null,
- removalStatus = RemovalStatus.NONE,
- )
- }
+ val expired = lease.request.isExpired()
+ releaseRemovalRequest(lease, if (expired) RemovalStatus.FAILED else RemovalStatus.NONE)
+ if (expired) {
+ throw ApplicationFailure(removalExpiredProblem())
}
}
}
private fun confirmIdentityRemoval(intent: HarvestCircleIntent.ConfirmIdentityRemoval) {
- val request = matchingRemoval(intent.identityId, intent.requestId) ?: return
+ val lease = matchingRemoval(intent.identityId, intent.requestId) ?: return
+ val request = lease.request
val operationId = operationIds.next()
launchOperation(
operationId = operationId,
onAccepted = {
- pendingRemoval = null
+ lease.phase = RemovalLeasePhase.Confirming
updateState {
copy(
removalConfirmation = null,
@@ -260,6 +261,10 @@ class HarvestCirclePresenter(
}
},
) {
+ if (request.isExpired()) {
+ releaseRemovalRequest(lease, RemovalStatus.FAILED)
+ throw ApplicationFailure(removalExpiredProblem(operationId))
+ }
try {
val result =
runtime.execute(
@@ -269,6 +274,8 @@ class HarvestCirclePresenter(
),
)
acceptResult(result, operationId)
+ lease.phase = RemovalLeasePhase.Confirmed
+ if (pendingRemoval === lease) pendingRemoval = null
updateState {
copy(
removalConfirmation = null,
@@ -277,16 +284,14 @@ class HarvestCirclePresenter(
)
}
mutableEffects.tryEmit(HarvestCircleEffect.IdentityRemoved(request.identityId))
- } finally {
- if (state.value.removalStatus != RemovalStatus.COMPLETED) {
- runtime.cancelIdentityRemoval(request.requestId)
- updateState {
- copy(
- removalConfirmation = null,
- removalStatus = RemovalStatus.FAILED,
- )
- }
+ } catch (error: CancellationException) {
+ withContext(NonCancellable) {
+ releaseRemovalRequest(lease, RemovalStatus.FAILED)
}
+ throw error
+ } catch (error: Exception) {
+ releaseRemovalRequest(lease, RemovalStatus.FAILED)
+ throw error
}
}
}
@@ -294,13 +299,22 @@ class HarvestCirclePresenter(
private fun matchingRemoval(
identityId: IdentityId,
requestId: RemovalRequestId,
- ): IdentityRemovalRequest? {
- val request = pendingRemoval
+ ): PendingRemovalLease? {
+ val lease = pendingRemoval
val confirmation = state.value.removalConfirmation
- if (request == null || confirmation == null || state.value.removalStatus != RemovalStatus.AWAITING_CONFIRMATION) {
+ if (lease?.phase == RemovalLeasePhase.ReleaseFailed) {
+ acceptFailure(ApplicationFailure(removalReleaseFailedProblem()), null)
+ return null
+ }
+ if (lease == null ||
+ lease.phase != RemovalLeasePhase.Open ||
+ confirmation == null ||
+ state.value.removalStatus != RemovalStatus.AWAITING_CONFIRMATION
+ ) {
rejectUnavailable("Identity removal confirmation is not available.")
return null
}
+ val request = lease.request
if (request.identityId != identityId ||
request.requestId != requestId ||
confirmation.identityId != identityId ||
@@ -309,21 +323,54 @@ class HarvestCirclePresenter(
rejectUnavailable("Identity removal confirmation does not match the current request.")
return null
}
- if (request.expiresAt.value <= clock.now().value) {
- pendingRemoval = null
- updateState {
- copy(
- removalConfirmation = null,
- removalStatus = RemovalStatus.FAILED,
- commandStatus = CommandStatus.FAILED_TERMINAL,
- problem = "Identity removal confirmation has expired.",
- )
+ return lease
+ }
+
+ private suspend fun releaseRemovalRequest(
+ lease: PendingRemovalLease,
+ resultingStatus: RemovalStatus,
+ ) {
+ if (pendingRemoval !== lease || lease.phase !in RELEASABLE_REMOVAL_PHASES) {
+ throw ApplicationFailure(removalReleaseFailedProblem())
+ }
+ lease.phase = RemovalLeasePhase.Releasing
+ val released =
+ try {
+ runtime.cancelIdentityRemoval(lease.request.requestId)
+ } catch (error: CancellationException) {
+ quarantineRemovalLease(lease)
+ acceptFailure(ApplicationFailure(removalReleaseFailedProblem()), null)
+ throw error
+ } catch (_: Exception) {
+ quarantineRemovalLease(lease)
+ throw ApplicationFailure(removalReleaseFailedProblem())
}
- return null
+ if (!released) {
+ quarantineRemovalLease(lease)
+ throw ApplicationFailure(removalReleaseFailedProblem())
+ }
+ lease.phase = RemovalLeasePhase.Released
+ if (pendingRemoval === lease) pendingRemoval = null
+ updateState {
+ copy(
+ removalConfirmation = null,
+ removalStatus = resultingStatus,
+ )
+ }
+ }
+
+ private fun quarantineRemovalLease(lease: PendingRemovalLease) {
+ lease.phase = RemovalLeasePhase.ReleaseFailed
+ updateState {
+ copy(
+ removalConfirmation = null,
+ removalStatus = RemovalStatus.FAILED,
+ )
}
- return request
}
+ private fun IdentityRemovalRequest.isExpired(): Boolean = expiresAt.value <= clock.now().value
+
private fun launchOperation(
requireReady: Boolean = true,
operationId: OperationId? = null,
@@ -469,6 +516,20 @@ private data class PendingRetry(
val operationId: OperationId,
)
+private class PendingRemovalLease(
+ val request: IdentityRemovalRequest,
+ var phase: RemovalLeasePhase = RemovalLeasePhase.Open,
+)
+
+private enum class RemovalLeasePhase {
+ Open,
+ Releasing,
+ ReleaseFailed,
+ Confirming,
+ Confirmed,
+ Released,
+}
+
private fun IdentityRemovalRequest.toConfirmation(): IdentityRemovalConfirmation =
IdentityRemovalConfirmation(
identityId = identityId,
@@ -498,6 +559,26 @@ private fun Throwable.toProblem(operationId: OperationId?): ApplicationProblem =
safeMessage = "The application command failed.",
)
+private fun removalReleaseFailedProblem(): ApplicationProblem =
+ ApplicationProblem(
+ code = ApplicationErrorCode.InvalidApplicationState,
+ category = ApplicationErrorCategory.Lifecycle,
+ retryable = false,
+ recoveryAction = RecoveryAction.RestartApplication,
+ operationId = null,
+ safeMessage = "The identity removal request could not be released safely.",
+ )
+
+private fun removalExpiredProblem(operationId: OperationId? = null): ApplicationProblem =
+ ApplicationProblem(
+ code = ApplicationErrorCode.InvalidApplicationState,
+ category = ApplicationErrorCategory.Lifecycle,
+ retryable = false,
+ recoveryAction = RecoveryAction.None,
+ operationId = operationId,
+ safeMessage = "Identity removal confirmation has expired.",
+ )
+
private val READY_ROUTES =
setOf(
HarvestCircleRoute.IDENTITIES,
@@ -506,3 +587,4 @@ private val READY_ROUTES =
)
private const val EFFECT_BUFFER_CAPACITY = 8
private const val COMMAND_DEADLINE_MILLIS = 5_000UL
+private val RELEASABLE_REMOVAL_PHASES = setOf(RemovalLeasePhase.Open, RemovalLeasePhase.Confirming)
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt
@@ -322,14 +322,171 @@ class HarvestCirclePresenterTest {
presenter.close()
}
+ @Test
+ fun hcSl002AlreadyExpiredRemovalIsReleasedExactlyOnceBeforeFailure() =
+ runTest {
+ val runtime = FakePresenterRuntime().also { it.removalExpiresAt = UnixSeconds(10) }
+ val presenter = presenter(runtime)
+ runCurrent()
+
+ presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(IdentityId.fromPublicKeyHex("01".repeat(32))))
+ advanceUntilIdle()
+
+ assertEquals(listOf("request:removal-1", "cancel:removal-1"), runtime.removalEvents)
+ assertNull(presenter.state.value.removalConfirmation)
+ assertEquals(RemovalStatus.FAILED, presenter.state.value.removalStatus)
+ assertEquals("Identity removal confirmation has expired.", presenter.state.value.problem)
+ presenter.close()
+ }
+
+ @Test
+ fun hcSl003PendingExpiryReleasesOnceAndNeverConfirms() =
+ runTest {
+ var now = 10L
+ val runtime = FakePresenterRuntime()
+ val presenter = presenter(runtime, clock = ApplicationClock { UnixSeconds(now) })
+ runCurrent()
+ val identityId = IdentityId.fromPublicKeyHex("01".repeat(32))
+ presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId))
+ advanceUntilIdle()
+ val requestId =
+ presenter.state.value.removalConfirmation
+ ?.requestId ?: error("confirmation")
+
+ now = 60
+ presenter.dispatch(HarvestCircleIntent.ConfirmIdentityRemoval(identityId, requestId))
+ advanceUntilIdle()
+
+ assertTrue(runtime.confirmedRemovalRequests.isEmpty())
+ assertEquals(listOf("request:removal-1", "cancel:removal-1"), runtime.removalEvents)
+ assertEquals(RemovalStatus.FAILED, presenter.state.value.removalStatus)
+ assertEquals("Identity removal confirmation has expired.", presenter.state.value.problem)
+ presenter.close()
+ }
+
+ @Test
+ fun hcSl004FalseCancellationIsTerminalAndCannotBeRetriedOrReplaced() =
+ runTest {
+ val runtime = FakePresenterRuntime().also { it.removalCancellationResult = false }
+ val presenter = presenter(runtime)
+ runCurrent()
+ val identityId = IdentityId.fromPublicKeyHex("01".repeat(32))
+ presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId))
+ advanceUntilIdle()
+ val requestId =
+ presenter.state.value.removalConfirmation
+ ?.requestId ?: error("confirmation")
+
+ presenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval(identityId, requestId))
+ advanceUntilIdle()
+ presenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval(identityId, requestId))
+ presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId))
+ advanceUntilIdle()
+
+ assertEquals(listOf("request:removal-1", "cancel:removal-1"), runtime.removalEvents)
+ assertEquals(1, runtime.removalRequestCalls)
+ assertEquals(RemovalStatus.FAILED, presenter.state.value.removalStatus)
+ assertEquals("The identity removal request could not be released safely.", presenter.state.value.problem)
+ assertEquals(
+ ApplicationErrorCode.InvalidApplicationState,
+ presenter.state.value.lastProblem
+ ?.code,
+ )
+ assertEquals(
+ ApplicationErrorCategory.Lifecycle,
+ presenter.state.value.lastProblem
+ ?.category,
+ )
+ assertFalse(
+ presenter.state.value.lastProblem
+ ?.retryable ?: true,
+ )
+ presenter.close()
+ }
+
+ @Test
+ fun hcSl004CancellationExceptionQuarantinesTheLeaseWithoutRetry() =
+ runTest {
+ val runtime = FakePresenterRuntime().also { it.removalCancellationError = CancellationException("uncertain") }
+ val presenter = presenter(runtime)
+ runCurrent()
+ val identityId = IdentityId.fromPublicKeyHex("01".repeat(32))
+ presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId))
+ advanceUntilIdle()
+ val requestId =
+ presenter.state.value.removalConfirmation
+ ?.requestId ?: error("confirmation")
+
+ presenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval(identityId, requestId))
+ advanceUntilIdle()
+ presenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval(identityId, requestId))
+ advanceUntilIdle()
+
+ assertEquals(listOf("request:removal-1", "cancel:removal-1"), runtime.removalEvents)
+ assertEquals(RemovalStatus.FAILED, presenter.state.value.removalStatus)
+ assertEquals("The identity removal request could not be released safely.", presenter.state.value.problem)
+ assertEquals(CommandStatus.FAILED_TERMINAL, presenter.state.value.commandStatus)
+ presenter.close()
+ }
+
+ @Test
+ fun hcSl004FailedConfirmationPerformsOneCheckedRelease() =
+ runTest {
+ val runtime = FakePresenterRuntime()
+ val presenter = presenter(runtime)
+ runCurrent()
+ val identityId = IdentityId.fromPublicKeyHex("01".repeat(32))
+ presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId))
+ advanceUntilIdle()
+ val requestId =
+ presenter.state.value.removalConfirmation
+ ?.requestId ?: error("confirmation")
+ runtime.nextFailure = problem(retryable = false)
+
+ presenter.dispatch(HarvestCircleIntent.ConfirmIdentityRemoval(identityId, requestId))
+ advanceUntilIdle()
+
+ assertTrue(runtime.confirmedRemovalRequests.isEmpty())
+ assertEquals(listOf("request:removal-1", "cancel:removal-1"), runtime.removalEvents)
+ assertEquals(RemovalStatus.FAILED, presenter.state.value.removalStatus)
+ assertNull(presenter.state.value.removalConfirmation)
+ presenter.close()
+ }
+
+ @Test
+ fun hcSl004ReplacementWaitsForSuccessfulPriorRelease() =
+ runTest {
+ val runtime = FakePresenterRuntime()
+ val presenter = presenter(runtime)
+ runCurrent()
+ val identityId = IdentityId.fromPublicKeyHex("01".repeat(32))
+
+ presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId))
+ advanceUntilIdle()
+ presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId))
+ advanceUntilIdle()
+
+ assertEquals(
+ listOf("request:removal-1", "cancel:removal-1", "request:removal-2"),
+ runtime.removalEvents,
+ )
+ assertEquals(
+ RemovalRequestId.from("removal-2"),
+ presenter.state.value.removalConfirmation
+ ?.requestId,
+ )
+ presenter.close()
+ }
+
private fun TestScope.presenter(
runtime: FakePresenterRuntime,
ids: DeterministicOperationIds = DeterministicOperationIds(),
+ clock: ApplicationClock = ApplicationClock { UnixSeconds(10) },
): HarvestCirclePresenter =
HarvestCirclePresenter(
runtime = runtime,
scope = this,
- clock = ApplicationClock { UnixSeconds(10) },
+ clock = clock,
operationIds = ids,
)
}
@@ -360,8 +517,14 @@ private class FakePresenterRuntime(
var shutdownCalls = 0
var generatedCancellationCalls = 0
var removalCancellationCalls = 0
+ var removalRequestCalls = 0
+ var removalCancellationResult = true
+ var removalCancellationFailure: ApplicationProblem? = null
+ var removalCancellationError: Throwable? = null
+ var removalExpiresAt = UnixSeconds(60)
var removalFailure: ApplicationProblem? = null
val confirmedRemovalRequests = mutableListOf<RemovalRequestId>()
+ val removalEvents = mutableListOf<String>()
val importedSecrets = mutableListOf<String>()
override suspend fun bootstrap(): ApplicationSnapshot {
@@ -407,18 +570,24 @@ private class FakePresenterRuntime(
override suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest {
removalFailure?.let { throw ApplicationFailure(it) }
+ removalRequestCalls += 1
+ val requestId = RemovalRequestId.from("removal-$removalRequestCalls")
+ removalEvents += "request:${requestId.value}"
return IdentityRemovalRequest(
- requestId = RemovalRequestId.from("removal-1"),
+ requestId = requestId,
identityId = identityId,
deletesLocalCredential = true,
signsOut = false,
- expiresAt = UnixSeconds(60),
+ expiresAt = removalExpiresAt,
)
}
override suspend fun cancelIdentityRemoval(requestId: RemovalRequestId): Boolean {
removalCancellationCalls += 1
- return true
+ removalEvents += "cancel:${requestId.value}"
+ removalCancellationError?.let { throw it }
+ removalCancellationFailure?.let { throw ApplicationFailure(it) }
+ return removalCancellationResult
}
override suspend fun shutdown(): ShutdownReceipt {