fix(approvals): a REJECTED decision must not satisfy the stage approval gate on retry

This commit is contained in:
2026-07-12 13:08:42 +04:00
parent 8c45650e21
commit 1fd878eb9b
2 changed files with 70 additions and 2 deletions
@@ -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) {
@@ -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")