From 67384691e0fed8d9f51299ce35b0c374a6a441d4 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 25 Jun 2026 06:46:41 +0000 Subject: [PATCH] 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 --- .../tools/task/TaskCreateTool.kt | 35 ++++++++++++------ .../tools/task/TaskUpdateTool.kt | 29 ++++++++++++--- .../tools/task/TaskToolsTest.kt | 36 ++++++++++++++++--- 3 files changed, 82 insertions(+), 18 deletions(-) diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt index 5792dd0b..e5691dcb 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt @@ -3,6 +3,7 @@ package com.correx.infrastructure.tools.task import com.correx.core.approvals.Tier import com.correx.core.events.events.ToolRequest import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskNoteAuthor import com.correx.core.tasks.TaskService import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.ToolCapability @@ -55,6 +56,10 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { put("type", "boolean") 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( "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 // can show the originating run. 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( invocationId = request.invocationId, 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? { val invalid = validateRequest(request) as? ValidationResult.Invalid if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false) - val duplicates = if (request.boolParam("force")) { - emptyList() - } else { - service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!) + if (request.boolParam("force")) { + if (request.stringParam("force_reason") == null) { + return fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.") + } + return null } + val duplicates = service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!) return duplicates.takeIf { it.isNotEmpty() }?.let { val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" } - ToolResult.Failure( - request.invocationId, - "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or pass force=true.", - recoverable = false, - ) + fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.") } } + + private fun fail(request: ToolRequest, reason: String): ToolResult.Failure = + ToolResult.Failure(request.invocationId, reason, recoverable = false) } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 571cb1d2..3710fef9 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -81,7 +81,11 @@ class TaskUpdateTool( putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") } putJsonObject("force") { 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")) }) @@ -101,6 +105,7 @@ class TaskUpdateTool( if (service.getTask(taskId) == null) return fail(request, "No such task: $id") val action = request.stringParam("action") + forceReasonGate(request)?.let { return it } claimBlock(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) } + recordForceNote(request, taskId, action) recordWorkingTask(request, taskId, action) 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? { if (action != "claim" || request.boolParam("force")) return null val unmet = service.blockers(taskId) if (unmet.isEmpty()) return null 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( request, "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.", ) } diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index e6b395eb..dd614d72 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -174,17 +174,33 @@ class TaskToolsTest { } @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"))) 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 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)), ) + 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) + 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 @@ -204,10 +220,22 @@ class TaskToolsTest { assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true)) assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated - // force=true claims anyway, with a warning surfaced. - val forced = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true))) + // force without a reason is rejected. + 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")) 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