diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt index b9cf320d..ab6ee780 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -661,8 +661,11 @@ class DefaultSessionOrchestrator( .map { it.requestId } .toSet() stageRequestIds.isNotEmpty() && events.any { - (it.payload as? ApprovalDecisionResolvedEvent) - ?.requestId in stageRequestIds + val decision = it.payload as? ApprovalDecisionResolvedEvent + // A REJECTED decision must NOT satisfy the gate on retry/resume, else the stage + // runs unapproved. Only APPROVED/AUTO_APPROVED count as a prior approval. + decision?.requestId in stageRequestIds && + decision?.outcome != ApprovalOutcome.REJECTED } } if (!alreadyApproved) { diff --git a/testing/integration/src/test/kotlin/FreestyleApprovalGateTest.kt b/testing/integration/src/test/kotlin/FreestyleApprovalGateTest.kt index 76d3d7f1..797a6696 100644 --- a/testing/integration/src/test/kotlin/FreestyleApprovalGateTest.kt +++ b/testing/integration/src/test/kotlin/FreestyleApprovalGateTest.kt @@ -209,6 +209,71 @@ class FreestyleApprovalGateTest { runJob.join() } + @Test + fun `a prior REJECTED decision does not satisfy the gate — it re-prompts, not runs unapproved`(): Unit = + runBlocking { + val sessionId = SessionId("freestyle-gate-reject") + // Pre-seed the log as if the architect gate had already been prompted and REJECTED (the + // retry/resume situation): a matching request + a REJECTED decision for it. The pre-fix + // predicate matched any decision for the request and skipped the gate, running unapproved. + val requestId = com.correx.core.events.types.ApprovalRequestId("seeded-req") + eventStore.append( + com.correx.testing.fixtures.EventFixtures.newEvent( + com.correx.core.events.types.EventId("seed-req"), + sessionId, + ApprovalRequestedEvent( + requestId = requestId, + tier = Tier.T2, + validationReportId = com.correx.core.events.types.ValidationReportId("vr"), + riskSummaryId = null, + sessionId = sessionId, + stageId = architectStage, + projectId = null, + toolName = null, + ), + ), + ) + eventStore.append( + com.correx.testing.fixtures.EventFixtures.newEvent( + com.correx.core.events.types.EventId("seed-dec"), + sessionId, + com.correx.core.events.events.ApprovalDecisionResolvedEvent( + decisionId = com.correx.core.events.types.ApprovalDecisionId("seeded-dec"), + requestId = requestId, + outcome = ApprovalOutcome.REJECTED, + status = ApprovalStatus.COMPLETED, + tier = Tier.T2, + resolutionTimestamp = Clock.System.now(), + reason = "operator said no", + ), + ), + ) + + val orchestrator = buildOrchestrator() + val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)) + val runJob = launch { orchestrator.run(sessionId, freestyleGraph(), config) } + + // The gate must re-prompt: a SECOND architect approval request appears (the seeded one is + // the first). With the bug the seeded REJECTED decision satisfied the gate and no new + // request was emitted — the architect ran and the workflow completed unapproved. + withTimeout(5_000) { + while ( + eventStore.read(sessionId) + .mapNotNull { it.payload as? ApprovalRequestedEvent } + .count { it.stageId == architectStage && it.toolName == null } < 2 + ) { + yield() + } + } + + assertTrue( + eventStore.read(sessionId).none { it.payload is WorkflowCompletedEvent }, + "Workflow must not complete off a REJECTED decision", + ) + runJob.cancel() + runJob.join() + } + @Test fun `approval resumes workflow and architect produces execution plan`(): Unit = runBlocking { val sessionId = SessionId("freestyle-gate-2")