From 9b003d02eac518f809040f2a23e55c607a08710a Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 19:46:44 +0000 Subject: [PATCH] refactor(events): record tool capabilities on the invocation event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capability is the canonical signal the plane-2 gates dispatch on, but ToolInvocationRequestedEvent only carried the tool name — so ReadFilesProjection classified reads by a hardcoded "file_read" string, bypassing the abstraction and (worse) re-deriving a semantic fact at replay from whatever the name meant. That violates the event-sourcing invariant that replay reads recorded facts, not live state. Now the tool's requiredCapabilities are recorded on the event at emission, and the projection classifies by ToolCapability.FILE_READ. To let the lowest module carry it, ToolCapability moves from core:tools into core:events keeping its package, so every existing import resolves unchanged and core:tools imports it from below. The field is defaulted, so pre-existing events deserialize as empty. Co-Authored-By: Claude Opus 4.8 --- .../correx/core/events/events/ToolEvents.kt | 5 +++++ .../core/tools/contract/ToolCapability.kt | 21 +++++++++++++++++++ .../orchestration/SessionOrchestrator.kt | 1 + .../core/toolintent/ReadFilesProjection.kt | 18 +++++++--------- .../toolintent/ReadFilesProjectionTest.kt | 2 ++ .../core/tools/contract/ToolCapability.kt | 9 -------- 6 files changed, 37 insertions(+), 19 deletions(-) create mode 100644 core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt delete mode 100644 core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt index b6a22f1f..66c33901 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt @@ -4,6 +4,7 @@ import com.correx.core.approvals.Tier import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.contract.ToolCapability import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -52,6 +53,10 @@ data class ToolInvocationRequestedEvent( val toolName: String, val tier: Tier, val request: ToolRequest, + // The tool's declared capabilities, recorded at emission so replay classifies the call by what + // it could actually do (e.g. FILE_READ/FILE_WRITE) without re-deriving from the live registry. + // Defaulted for backward compatibility: events written before this field deserialize as empty. + val capabilities: Set = emptySet(), ) : EventPayload @Serializable diff --git a/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt b/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt new file mode 100644 index 00000000..1474960b --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt @@ -0,0 +1,21 @@ +package com.correx.core.tools.contract + +import kotlinx.serialization.Serializable + +/** + * What a tool is allowed to do, independent of its name. Plane-2 (`core:toolintent`) rules dispatch + * on these — never on tool names — and they are recorded on + * [com.correx.core.events.events.ToolInvocationRequestedEvent] at emission, so replay classifies a + * call by the capability it actually had rather than re-deriving it from the live registry. + * + * Lives in `core:events` (the lowest module) so events can carry it; `core:tools` and everything + * above import it from here — the package name is unchanged, so the move is import-transparent. + */ +@Serializable +enum class ToolCapability { + FILE_READ, + FILE_WRITE, + NETWORK_ACCESS, + SHELL_EXEC, + PROCESS_SPAWN, +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 624829e2..9d685bdf 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -648,6 +648,7 @@ abstract class SessionOrchestrator( toolName = toolCall.function.name, tier = tier, request = request, + capabilities = tool?.requiredCapabilities ?: emptySet(), ), ) if (toolCall.function.name != STAGE_COMPLETE_TOOL && diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt index 5c3af946..16ee828f 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt @@ -6,6 +6,7 @@ import com.correx.core.events.events.ToolInvocationRequestedEvent import com.correx.core.events.types.SessionId import com.correx.core.sessions.projections.Projection import com.correx.core.toolintent.rules.candidatePathStrings +import com.correx.core.tools.contract.ToolCapability /** Reads still awaiting their completion event, plus the paths confirmed read so far. */ data class ReadFilesState( @@ -14,11 +15,12 @@ data class ReadFilesState( ) /** - * Folds one session's tool events into the set of file paths it has successfully read. A `file_read` - * counts only once its [ToolExecutionCompletedEvent] lands — a read that failed (file not found) - * never enters the set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. - * Paths are kept as the raw arguments; the rule resolves both sides through the probe, so - * `./Foo.kt` read and `Foo.kt` written still match. The same projection precedent as + * Folds one session's tool events into the set of file paths it has successfully read. A call counts + * as a read by its recorded [ToolCapability.FILE_READ] capability (not its tool name), and only once + * its [ToolExecutionCompletedEvent] lands — a read that failed (file not found) never enters the + * set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. Paths are kept as + * the raw arguments; the rule resolves both sides through the probe, so `./Foo.kt` read and + * `Foo.kt` written still match. Same projection precedent as * [com.correx.core.sessions.projections.EgressAllowlistProjection]. */ class ReadFilesProjection(private val sessionId: SessionId) : Projection { @@ -33,7 +35,7 @@ class ReadFilesProjection(private val sessionId: SessionId) : Projection