feat(tasks): force overrides require a recorded reason

Bypassing a gate with force=true now requires a force_reason, recorded as a
"[force] ..." note on the task so the override is auditable (visible in history
and the context bundle) rather than a silent escape hatch. Applies to the agent
tools: task_create (dedup override) and task_update (claim over unmet blockers,
complete with no in-scope writes). force without a reason is rejected.

The REST POST /tasks create override (operator/CLI path) still takes a bare force
and is left for a separate call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 06:46:41 +00:00
parent 6a779861c2
commit 67384691e0
3 changed files with 82 additions and 18 deletions
@@ -3,6 +3,7 @@ package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ProjectId import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.tasks.TaskService import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolCapability
@@ -55,6 +56,10 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
put("type", "boolean") put("type", "boolean")
put("description", "Create even if an active task with the same title exists. Default false.") put("description", "Create even if an active task with the same title exists. Default false.")
} }
putJsonObject("force_reason") {
put("type", "string")
put("description", "Required when force=true: why a duplicate is acceptable. Recorded on the task.")
}
} }
put( put(
"required", "required",
@@ -88,6 +93,12 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
// Provenance: tie the new task to the session that spawned it so its context bundle // Provenance: tie the new task to the session that spawned it so its context bundle
// can show the originating run. // can show the originating run.
service.linkOriginSession(task.taskId, request) service.linkOriginSession(task.taskId, request)
// A forced creation bypassed the duplicate guard — record why, for the audit trail.
if (request.boolParam("force")) {
request.stringParam("force_reason")?.let {
service.addNote(task.taskId, TaskNoteAuthor.AGENT, "[force] created despite the duplicate guard: $it")
}
}
return ToolResult.Success( return ToolResult.Success(
invocationId = request.invocationId, invocationId = request.invocationId,
output = "Created ${task.taskId.value}: ${task.state.title}", output = "Created ${task.taskId.value}: ${task.state.title}",
@@ -95,22 +106,26 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
) )
} }
/** Required-field validation, then a duplicate-title guard (unless force) — null when OK to create. */ /**
* Required-field validation; then either a force-reason requirement (force=true must carry a
* force_reason, recorded after creation) or the duplicate-title guard. Null when OK to create.
*/
private fun precheck(request: ToolRequest): ToolResult.Failure? { private fun precheck(request: ToolRequest): ToolResult.Failure? {
val invalid = validateRequest(request) as? ValidationResult.Invalid val invalid = validateRequest(request) as? ValidationResult.Invalid
if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false) if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
val duplicates = if (request.boolParam("force")) { if (request.boolParam("force")) {
emptyList() if (request.stringParam("force_reason") == null) {
} else { return fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.")
service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!) }
return null
} }
val duplicates = service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!)
return duplicates.takeIf { it.isNotEmpty() }?.let { return duplicates.takeIf { it.isNotEmpty() }?.let {
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" } val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
ToolResult.Failure( fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
request.invocationId,
"Possible duplicate(s): $listed. Reuse one (task_context/task_update), or pass force=true.",
recoverable = false,
)
} }
} }
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
ToolResult.Failure(request.invocationId, reason, recoverable = false)
} }
@@ -81,7 +81,11 @@ class TaskUpdateTool(
putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") } putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") }
putJsonObject("force") { putJsonObject("force") {
put("type", "boolean") put("type", "boolean")
put("description", "Claim even if the task has unmet blockers. Default false.") put("description", "Override a gate (claim over unmet blockers; complete with no in-scope writes). Default false.")
}
putJsonObject("force_reason") {
put("type", "string")
put("description", "Required when force=true: why the override is justified. Recorded as a note on the task.")
} }
} }
put("required", buildJsonArray { add(JsonPrimitive("id")) }) put("required", buildJsonArray { add(JsonPrimitive("id")) })
@@ -101,6 +105,7 @@ class TaskUpdateTool(
if (service.getTask(taskId) == null) return fail(request, "No such task: $id") if (service.getTask(taskId) == null) return fail(request, "No such task: $id")
val action = request.stringParam("action") val action = request.stringParam("action")
forceReasonGate(request)?.let { return it }
claimBlock(request, taskId, action)?.let { return it } claimBlock(request, taskId, action)?.let { return it }
completeBlock(request, taskId, action)?.let { return it } completeBlock(request, taskId, action)?.let { return it }
@@ -121,6 +126,7 @@ class TaskUpdateTool(
request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) } request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) }
recordForceNote(request, taskId, action)
recordWorkingTask(request, taskId, action) recordWorkingTask(request, taskId, action)
val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN" val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN"
@@ -132,13 +138,28 @@ class TaskUpdateTool(
) )
} }
/** Refuse to claim a task with unmet blockers (escapable with force) — fail before any mutation. */ /** A force override must carry a recorded justification — reject force=true with no force_reason. */
private fun forceReasonGate(request: ToolRequest): ToolResult.Failure? =
if (request.boolParam("force") && request.stringParam("force_reason") == null) {
fail(request, "force=true requires 'force_reason' explaining the override.")
} else {
null
}
/** A forced claim/complete bypassed a gate — record why on the task, for the audit trail. */
private suspend fun recordForceNote(request: ToolRequest, taskId: TaskId, action: String?) {
if (!request.boolParam("force")) return
val reason = request.stringParam("force_reason") ?: return
service.addNote(taskId, TaskNoteAuthor.AGENT, "[force] ${action ?: "update"}: $reason")
}
/** Refuse to claim a task with unmet blockers (escapable with force+reason) — fail before any mutation. */
private fun claimBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? { private fun claimBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? {
if (action != "claim" || request.boolParam("force")) return null if (action != "claim" || request.boolParam("force")) return null
val unmet = service.blockers(taskId) val unmet = service.blockers(taskId)
if (unmet.isEmpty()) return null if (unmet.isEmpty()) return null
val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" } val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" }
return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or pass force=true.") return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or force=true with force_reason.")
} }
/** /**
@@ -155,7 +176,7 @@ class TaskUpdateTool(
return fail( return fail(
request, request,
"Cannot complete ${taskId.value} — no changes under its affected_paths ($scope) this " + "Cannot complete ${taskId.value} — no changes under its affected_paths ($scope) this " +
"session. Make the change, or pass force=true.", "session. Make the change, or force=true with force_reason.",
) )
} }
@@ -174,17 +174,33 @@ class TaskToolsTest {
} }
@Test @Test
fun `task_create blocks a duplicate title and force overrides`() = runBlocking { fun `task_create blocks a duplicate title and force needs a recorded reason`() = runBlocking {
create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "g"))) create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "g")))
val dup = create.execute(request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g"))) val dup = create.execute(request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g")))
assertTrue(dup is ToolResult.Failure) assertTrue(dup is ToolResult.Failure)
assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true)) assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true))
val forced = create.execute( // force without a reason is rejected.
val noReason = create.execute(
request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", "force" to true)), request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", "force" to true)),
) )
assertTrue(noReason is ToolResult.Failure)
assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason"))
// force with a reason creates, and records the reason as a note.
val forced = create.execute(
request(
"task_create",
mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g",
"force" to true, "force_reason" to "separate refresh path for mobile"),
),
)
assertTrue(forced is ToolResult.Success) assertTrue(forced is ToolResult.Success)
val id = (forced as ToolResult.Success).metadata.getValue("taskId")
val note = service.getTask(TaskId(id))!!.state.notes.single()
assertTrue(note.body.contains("[force]"))
assertTrue(note.body.contains("separate refresh path for mobile"))
} }
@Test @Test
@@ -204,10 +220,22 @@ class TaskToolsTest {
assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true)) assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true))
assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated
// force=true claims anyway, with a warning surfaced. // force without a reason is rejected.
val forced = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true))) val noReason = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true)))
assertTrue(noReason is ToolResult.Failure)
assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason"))
assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status)
// force with a reason claims, records the reason as a note, and warns.
val forced = update.execute(
request(
"task_update",
mapOf("id" to "auth-2", "action" to "claim", "force" to true, "force_reason" to "prep work, auth-1 lands soon"),
),
)
assertTrue((forced as ToolResult.Success).output.contains("WARNING")) assertTrue((forced as ToolResult.Success).output.contains("WARNING"))
assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status) assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status)
assertTrue(service.getTask(TaskId("auth-2"))!!.state.notes.any { it.body.contains("[force]") && it.body.contains("prep work") })
} }
@Test @Test