refactor(events): record tool capabilities on the invocation event

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 19:46:44 +00:00
parent 78e7a4fefb
commit 9b003d02ea
6 changed files with 37 additions and 19 deletions
@@ -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<ToolCapability> = emptySet(),
) : EventPayload
@Serializable
@@ -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,
}
@@ -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 &&
@@ -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<ReadFilesState> {
@@ -33,7 +35,7 @@ class ReadFilesProjection(private val sessionId: SessionId) : Projection<ReadFil
}
private fun recordPending(state: ReadFilesState, payload: ToolInvocationRequestedEvent): ReadFilesState {
if (payload.sessionId != sessionId || payload.toolName != FILE_READ_TOOL) return state
if (payload.sessionId != sessionId || ToolCapability.FILE_READ !in payload.capabilities) return state
val paths = candidatePathStrings(emptyMap(), payload.request.parameters)
if (paths.isEmpty()) return state
return state.copy(pending = state.pending + (payload.invocationId.value to paths))
@@ -47,8 +49,4 @@ class ReadFilesProjection(private val sessionId: SessionId) : Projection<ReadFil
readPaths = state.readPaths + paths,
)
}
private companion object {
const val FILE_READ_TOOL = "file_read"
}
}
@@ -12,6 +12,7 @@ import com.correx.core.events.types.EventId
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.datetime.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -38,6 +39,7 @@ class ReadFilesProjectionTest {
toolName = "file_read",
tier = Tier.T1,
request = ToolRequest(ToolInvocationId(invId), session, StageId("st"), "file_read", mapOf("path" to path)),
capabilities = setOf(ToolCapability.FILE_READ),
),
)
@@ -1,9 +0,0 @@
package com.correx.core.tools.contract
enum class ToolCapability {
FILE_READ,
FILE_WRITE,
NETWORK_ACCESS,
SHELL_EXEC,
PROCESS_SPAWN
}