app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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 {