# Epic 11 — Infrastructure Layer

**date:** 2026-05-12
**scope:** `infrastructure/` — persistence, inference, tools, model management
**status:** ready to implement

---

## resume instructions

1. open project at `/home/kami/Programs/correx`
2. read this file top to bottom before writing any code
3. complete tasks in order — hard boundary after task 3 before tools work begins
4. do NOT explore beyond files listed in each task
5. in-memory stores and mocks are NOT acceptable — this epic is real implementations only

---

## prerequisite — `:core:tools` contract extension (before epic 11)

these three additions belong in `:core:tools`, not infrastructure. complete them first as a separate commit before touching any `infrastructure/` code.

### `ToolResult`

**file:** `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolResult.kt`

```kotlin
sealed interface ToolResult {
    data class Success(
        val invocationId: ToolInvocationId,
        val output: String,
        val metadata: Map<String, String> = emptyMap(),
    ) : ToolResult

    data class Failure(
        val invocationId: ToolInvocationId,
        val reason: String,
        val recoverable: Boolean,
    ) : ToolResult
}
```

### `ToolExecutor`

**file:** `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolExecutor.kt`

```kotlin
interface ToolExecutor {
    suspend fun execute(request: ToolRequest): ToolResult
}
```

### `FileAffectingTool`

**file:** `core/tools/src/main/kotlin/com/correx/core/tools/contract/FileAffectingTool.kt`

marker interface for tools that touch the filesystem. sandbox uses this to know which paths to back up:

```kotlin
interface FileAffectingTool : Tool {
    fun affectedPaths(request: ToolRequest): Set<Path>
}
```

---

## scope (what IS in epic 11)

- `SqliteEventStore` — already done, skip
- `LlamaCppInferenceProvider` — OpenAI-compatible REST client
- `LlamaCppTokenizer`
- `ModelManager` + `ManagedInferenceProvider` — load/unload/health/residency
- `ToolRegistry` — immutable after bootstrap
- `SandboxedToolExecutor` — `/tmp` isolation + backup/restore + approval gating
- `ShellTool` — argv-based, allowlist-gated
- `FilesystemTool` — path-allowlisted, traversal-safe
- `InfrastructureModule` — wiring

## scope (what is NOT in epic 11)

- postgres, snapshots, migrations
- ollama / vllm providers
- docker / network tools
- telemetry exporters
- process namespace isolation (seccomp/unshare) — parked
- scheduler / concurrency / backpressure
- CLI / API wiring

---

## dependency inventory

| module | key types | concrete import | file path |
|---|---|---|---|
| `:core:events` | `EventStore` | `import com.correx.core.events.stores.EventStore` | `core/events/src/main/kotlin/com/correx/core/events/stores/EventStore.kt` |
| `:core:events` | `NewEvent` | `import com.correx.core.events.events.NewEvent` | `core/events/src/main/kotlin/com/correx/core/events/events/EventEnvelope.kt` |
| `:core:events` | `EventMetadata` | `import com.correx.core.events.events.EventMetadata` | `core/events/src/main/kotlin/com/correx/core/events/events/EventMetadata.kt` |
| `:core:events` | `TypeId` | `import com.correx.core.utils.TypeId` | `core/events/src/main/kotlin/com/correx/core/utils/TypeId.kt` |
| `:core:inference` | `InferenceProvider` | `import com.correx.core.inference.InferenceProvider` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceProvider.kt` |
| `:core:inference` | `InferenceRequest` | `import com.correx.core.inference.InferenceRequest` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceRequest.kt` |
| `:core:inference` | `InferenceResponse` | `import com.correx.core.inference.InferenceResponse` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceResponse.kt` |
| `:core:inference` | `InferenceRouter` | `import com.correx.core.inference.InferenceRouter` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceRouter.kt` |
| `:core:inference` | `GenerationConfig` | `import com.correx.core.inference.GenerationConfig` | `core/inference/src/main/kotlin/com/correx/core/inference/GenerationConfig.kt` |
| `:core:inference` | `TokenUsage` | `import com.correx.core.inference.TokenUsage` | `core/events/src/main/kotlin/com/correx/core/inference/TokenUsage.kt` |
| `:core:inference` | `FinishReason` | `import com.correx.core.inference.FinishReason` | `core/inference/src/main/kotlin/com/correx/core/inference/FinishReason.kt` |
| `:core:inference` | `ProviderId` | `import com.correx.core.events.types.ProviderId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
| `:core:inference` | `ModelCapability` | `import com.correx.core.inference.ModelCapability` | `core/inference/src/main/kotlin/com/correx/core/inference/ModelCapability.kt` |
| `:core:inference` | `CapabilityScore` | `import com.correx.core.inference.CapabilityScore` | `core/inference/src/main/kotlin/com/correx/core/inference/ModelCapability.kt` |
| `:core:inference` | `ProviderHealth` | `import com.correx.core.inference.ProviderHealth` | `core/inference/src/main/kotlin/com/correx/core/inference/ProviderHealth.kt` |
| `:core:approvals` | `ApprovalEngine` | `import com.correx.core.approvals.domain.ApprovalEngine` | `core/approvals/src/main/kotlin/com/correx/core/approvals/domain/ApprovalEngine.kt` |
| `:core:approvals` | `ApprovalRequest` | `import com.correx.core.approvals.model.DomainApprovalRequest` | `core/approvals/src/main/kotlin/com/correx/core/approvals/model/DomainApprovalRequest.kt` |
| `:core:approvals` | `ApprovalContext` | `import com.correx.core.approvals.model.ApprovalContext` | `core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalContext.kt` |
| `:core:approvals` | `ApprovalMode` | `import com.correx.core.sessions.ApprovalMode` | `core/sessions/src/main/kotlin/com/correx/core/sessions/ApprovalMode.kt` |
| `:core:approvals` | `Tier` | `import com.correx.core.approvals.Tier` | `core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt` |
| `:core:tools` | `Tool` | `import com.correx.core.tools.contract.Tool` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt` |
| `:core:tools` | `ToolRequest` | `import com.correx.core.events.events.ToolRequest` | `core/events/src/main/kotlin/com/correx/core/events/events/ToolRequest.kt` |
| `:core:tools` | `ToolInvocationId` | `import com.correx.core.events.types.ToolInvocationId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
| `:core:tools` | `ToolResult` | `import com.correx.core.tools.contract.ToolResult` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolResult.kt` |
| `:core:tools` | `ToolExecutor` | `import com.correx.core.tools.contract.ToolExecutor` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolExecutor.kt` |
| `:core:tools` | `FileAffectingTool` | `import com.correx.core.tools.contract.FileAffectingTool` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/FileAffectingTool.kt` |
| `:core:sessions` | `SessionId` | `import com.correx.core.events.types.SessionId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
| `:core:sessions` | `StageId` | `import com.correx.core.events.types.StageId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
| `infrastructure:persistence:sqlite` | `SqliteEventStore` | `import com.correx.infrastructure.persistence.sqlite.SqliteEventStore` | `infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt` |

---

## task 1 — `LlamaCppInferenceProvider`

**module:** `infrastructure:inference:llama_cpp`
**location:** `infrastructure/inference/llama_cpp/`

### what it does

calls llama-server's OpenAI-compatible REST API. implements `InferenceProvider`.

endpoints (constructed from `baseUrl = "http://127.0.0.1:10000"`):
- inference: `$baseUrl/v1/chat/completions`
- health: `$baseUrl/health`
- tokenize: `$baseUrl/tokenize`

### build deps

```groovy
implementation project(':core:inference')
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
```

### request/response mapping

```kotlin
@Serializable
data class ChatCompletionRequest(
    val model: String,
    val messages: List<ChatMessage>,
    val temperature: Double,
    @SerialName("top_p") val topP: Double,
    @SerialName("max_tokens") val maxTokens: Int,
    @SerialName("stop") val stopSequences: List<String> = emptyList(),
    val seed: Long? = null,
    val stream: Boolean = false,
)

@Serializable
data class ChatMessage(val role: String, val content: String)

@Serializable
data class ChatCompletionResponse(
    val id: String,
    val choices: List<Choice>,
    val usage: Usage,
)

@Serializable
data class Choice(
    val message: ChatMessage,
    @SerialName("finish_reason") val finishReason: String,
)

@Serializable
data class Usage(
    @SerialName("prompt_tokens") val promptTokens: Int,
    @SerialName("completion_tokens") val completionTokens: Int,
    @SerialName("total_tokens") val totalTokens: Int,
)
```

### context pack → messages mapping

- L0 (system) → `role = "system"`
- L1 (task) → `role = "user"`
- L2 (history/tools) → `role = "assistant"` or `role = "user"` depending on `sourceType`
- if `ContextPack` is empty → single `user` message with empty string (do not crash)

### `LlamaCppInferenceProvider`

```kotlin
class LlamaCppInferenceProvider(
    private val modelId: String,
    private val baseUrl: String = "http://127.0.0.1:10000",
    private val httpClient: HttpClient = defaultHttpClient(),
) : InferenceProvider {
    override val id: ProviderId = ProviderId("llama-cpp:$modelId")
    override val tokenizer: Tokenizer = LlamaCppTokenizer(baseUrl, httpClient)

    override suspend fun infer(request: InferenceRequest): InferenceResponse
    override fun healthCheck(): ProviderHealth
    override fun capabilities(): Set<CapabilityScore>
}
```

### `healthCheck()`

GET `$baseUrl/health`. 200 → `ProviderHealth.Healthy`, otherwise `ProviderHealth.Unhealthy`.

### `capabilities()`

hardcoded, pattern-matched on `modelId`:
- contains "coder" → `ModelCapability.CODING` score 0.9
- contains "r1" or "deepseek" → `ModelCapability.REASONING` score 0.9
- default → `ModelCapability.GENERAL` score 0.7

### constraints
- `stream = false` always
- do NOT implement retry logic — `RetryCoordinator`'s responsibility
- do NOT catch `CancellationException` — let it propagate
- throw `InferenceProviderException` on non-2xx HTTP responses
- `latencyMs` = wall clock time of the HTTP call

---

## task 2 — `LlamaCppTokenizer`

**module:** `infrastructure:inference:llama_cpp`

```kotlin
class LlamaCppTokenizer(
    private val baseUrl: String,
    private val httpClient: HttpClient,
) : Tokenizer {
    override fun tokenize(text: String): List<Int>
    override fun count(text: String): Int = tokenize(text).size
}
```

POST `$baseUrl/tokenize` with `{"content": text}`, parse `{"tokens": [...]}`.

fallback: if endpoint unavailable → approximate `text.length / 4`, log warning, do NOT throw.

---

## task 3 — `ModelManager` + `ManagedInferenceProvider`

**module:** `infrastructure:inference:llama_cpp`

manages llama-server process lifecycle. single model at a time, enforced by `Mutex`.

### residency modes

```kotlin
enum class ResidencyMode {
    PERSISTENT,   // never unload
    DYNAMIC,      // unload after idle timeout
    EPHEMERAL,    // unload immediately after infer completes
}
```

### `ModelDescriptor`

```kotlin
data class ModelDescriptor(
    val modelId: String,
    val modelPath: String,
    val residencyMode: ResidencyMode,
    val idleTimeoutMs: Long = 60_000L,
    val contextSize: Int = 8192,
)
```

### `ModelManager` interface

```kotlin
interface ModelManager {
    suspend fun load(descriptor: ModelDescriptor): InferenceProvider
    suspend fun unload(modelId: String)
    fun currentModel(): ModelDescriptor?
    fun healthCheck(): ProviderHealth
}
```

### `DefaultModelManager`

model swap via **process restart** — kill existing llama-server, spawn new with `--model` flag.

```kotlin
class DefaultModelManager(
    private val llamaServerBin: String = "llama-server",
    private val host: String = "127.0.0.1",
    private val port: Int = 10000,
    private val healthTimeoutMs: Long = 30_000L,
    private val eventStore: EventStore,
) : ModelManager
```

**`load()` flow — event appended AFTER committed state:**
1. acquire `Mutex`
2. if `currentDescriptor?.modelId == descriptor.modelId` → no-op, return existing provider
3. if process running: `process.destroyForcibly()`, wait for exit, clear state, append `ModelUnloadedEvent`
4. spawn: `ProcessBuilder(llamaServerBin, "--model", descriptor.modelPath, "--ctx-size", descriptor.contextSize.toString(), "--host", host, "--port", port.toString())`
5. redirect stdout/stderr to `~/.local/share/correx/logs/llama-server.log` — create dir if absent
6. poll GET `http://$host:$port/health` until 200 — if `healthTimeoutMs` exceeded: throw `ModelLoadException` (no event emitted on failure)
7. set `currentProcess`, `currentDescriptor`
8. append `ModelLoadedEvent` ← only after process is healthy and state committed
9. release `Mutex`, return `ManagedInferenceProvider(delegate, this, descriptor)`

**`unload()` flow — event appended AFTER committed state:**
1. acquire `Mutex`
2. `process.destroyForcibly()`, wait for exit
3. clear `currentProcess`, `currentDescriptor`
4. append `ModelUnloadedEvent` ← only after process is dead and state cleared
5. release `Mutex`

### `ManagedInferenceProvider`

wraps `LlamaCppInferenceProvider`, notifies manager for residency policy:

```kotlin
class ManagedInferenceProvider(
    private val delegate: InferenceProvider,
    private val manager: DefaultModelManager,
    private val descriptor: ModelDescriptor,
) : InferenceProvider by delegate {

    override suspend fun infer(request: InferenceRequest): InferenceResponse {
        manager.touch(descriptor.modelId)           // reset DYNAMIC idle timer
        val response = delegate.infer(request)
        manager.onInferenceCompleted(descriptor)    // trigger EPHEMERAL unload if applicable
        return response
    }
}
```

`DefaultModelManager` internal methods (not on `ModelManager` interface):
- `touch(modelId)` — resets DYNAMIC idle timer coroutine
- `onInferenceCompleted(descriptor)` — calls `unload()` immediately if `EPHEMERAL`, no-op otherwise

**DYNAMIC timer behaviour:**
- timer coroutine starts on first `touch()` after load
- each `touch()` cancels and restarts the timer
- on timeout: calls `unload()`

### constraints
- do NOT manage GPU memory directly — llama-server handles that
- do NOT emit events before process state is committed — event log must reflect truth, not intent
- `ModelLoadException` thrown on health timeout — no event emitted in this case
- spawned process log dir created on first use if absent

---

## task 4 — `ToolRegistry`

**module:** `infrastructure:tools`

immutable after construction:

```kotlin
class ToolRegistry private constructor(
    private val tools: Map<String, Tool>,
) {
    fun resolve(name: String): Tool? = tools[name]
    fun all(): List<Tool> = tools.values.toList()

    companion object {
        fun build(vararg tools: Tool): ToolRegistry =
            ToolRegistry(tools.associateBy { it.name })

        fun build(tools: List<Tool>): ToolRegistry =
            ToolRegistry(tools.associateBy { it.name })
    }
}
```

no `register()` method — `private constructor` enforces bootstrap-only population.

---

## task 5 — `SandboxedToolExecutor`

**module:** `infrastructure:tools`

```kotlin
class SandboxedToolExecutor(
    private val delegate: ToolExecutor,
    private val registry: ToolRegistry,
    private val approvalEngine: ApprovalEngine,
    private val eventStore: EventStore,
    private val workDir: Path = Path("/tmp/correx-sandbox"),
) : ToolExecutor {
    override suspend fun execute(request: ToolRequest): ToolResult
}
```

execution flow:
1. resolve tool from `registry` by `request.toolName` — if not found: return `ToolResult.Failure(recoverable = false)`
2. check approval via `tool.tier`:
   ```kotlin
   when (tool.tier) {
       Tier.T0, Tier.T1 -> { /* proceed */ }
       Tier.T2, Tier.T3 -> { /* evaluate approval */ }
   }
   ```
   if not approved: emit `ToolExecutionRejectedEvent`, return `ToolResult.Failure(recoverable = false)`
3. emit `ToolExecutionStartedEvent`
4. create working dir: `/tmp/correx-sandbox/{sessionId}/{invocationId}/`
5. if tool implements `FileAffectingTool`: call `tool.affectedPaths(request)`, copy each to `{workDir}/{filename}.bak` via `Files.copy`
6. if backup fails: return `ToolResult.Failure` before delegating — do NOT proceed
7. delegate to underlying `ToolExecutor`
8. on `ToolResult.Success`: `Files.move` result to original path, delete `.bak` files, emit `ToolExecutionCompletedEvent`
9. on `ToolResult.Failure`: restore `.bak` files via `Files.move`, delete working dir, emit `ToolExecutionFailedEvent`

### constraints
- working dir is fresh per invocation — no shared state
- `.bak` files always cleaned up — no leaks regardless of outcome
- do NOT use `File.deleteOnExit()` — explicit cleanup only
- backup/restore uses `Files.copy` + `Files.move` — not shell commands

---

## task 6 — `ShellTool`

**module:** `infrastructure:tools:shell`

uses `argv: List<String>` — no shell string parsing, no shell invocation.

```kotlin
class ShellTool(
    private val allowedExecutables: Set<String> = emptySet(),
    private val timeoutMs: Long = 30_000L,
) : Tool, ToolExecutor {
    override val name: String = "shell"
    override val tier: Tier = Tier.T2
    override val requiredCapabilities: Set<ToolCapability> =
        setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)

    override fun validateRequest(request: ToolRequest): ValidationResult
    override suspend fun execute(request: ToolRequest): ToolResult
}
```

`execute()`:
- extract `argv: List<String>` from `request.parameters["argv"]`
- first element is the executable — validate against `allowedExecutables`
- if not in allowlist: return `ToolResult.Failure(recoverable = false)` immediately
- spawn: `ProcessBuilder(argv).apply { environment().clear() }`
- capture stdout + stderr separately
- enforce `timeoutMs` — `process.destroyForcibly()` on timeout, return `ToolResult.Failure`
- exit code 0 → `ToolResult.Success(output = stdout)`
- non-zero → `ToolResult.Failure(reason = stderr, recoverable = false)`

### constraints
- do NOT invoke `bash -c` or any shell — `ProcessBuilder(argv)` directly
- do NOT use `Runtime.exec()`
- do NOT inherit parent process environment — `environment().clear()` explicitly
- stdout and stderr captured, never forwarded to parent stdout

---

## task 7a — `FileReadTool`

**file:** `infrastructure/tools/filesystem/FileReadTool.kt`
**module:** `infrastructure:tools:filesystem`

```kotlin
class FileReadTool(
    private val allowedPaths: Set<Path> = emptySet(),
) : Tool {
    override val name: String = "file_read"
    override val tier: Tier = Tier.T1
    override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)

    override fun validateRequest(request: ToolRequest): ValidationResult
}
```

also implements `ToolExecutor` directly.

supported operations (from `request.parameters["operation"]`):
- `read` — read file contents, return as `output`
- `list` — list directory contents at `path`
- `exists` — return `"true"` or `"false"`

does NOT implement `FileAffectingTool` — read operations require no backup.

path validation before ANY operation:
1. extract `path` from `request.parameters["path"]`
2. resolve to absolute via `toRealPath()`
3. check resolved path starts with at least one `allowedPaths` root
4. if not: return `ToolResult.Failure(recoverable = false)`

### constraints
- `allowedPaths` empty = deny ALL — fail-secure default
- `toRealPath()` resolves symlinks — re-validate after resolution
- do NOT follow symlinks outside allowed paths
- T1 — goes through `SandboxedToolExecutor` approval check but auto-approved

---

## task 7b — `FileWriteTool`

**file:** `infrastructure/tools/filesystem/FileWriteTool.kt`
**module:** `infrastructure:tools:filesystem`

```kotlin
class FileWriteTool(
    private val allowedPaths: Set<Path> = emptySet(),
) : Tool, FileAffectingTool {
    override val name: String = "file_write"
    override val tier: Tier = Tier.T2
    override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)

    override fun affectedPaths(request: ToolRequest): Set<Path>
    override fun validateRequest(request: ToolRequest): ValidationResult
}
```

also implements `ToolExecutor`.

supported operations:
- `write` — create or fully overwrite file at `path` with `content` parameter
- `delete` — delete file at `path`

`affectedPaths()` — extract `path` from request, resolve absolute, return as singleton set.

path validation — same as `FileReadTool` but applied to write targets.

### constraints
- `allowedPaths` empty = deny ALL
- `toRealPath()` on parent dir for new files — target file may not exist yet, validate parent
- T2 — requires approval via `SandboxedToolExecutor`
- backup/restore handled by `SandboxedToolExecutor` via `FileAffectingTool` — do NOT implement backup here

---

## task 7c — `FileEditTool`

**file:** `infrastructure/tools/filesystem/FileEditTool.kt`
**module:** `infrastructure:tools:filesystem`

```kotlin
class FileEditTool(
    private val allowedPaths: Set<Path> = emptySet(),
) : Tool, FileAffectingTool {
    override val name: String = "file_edit"
    override val tier: Tier = Tier.T3
    override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)

    override fun affectedPaths(request: ToolRequest): Set<Path>
    override fun validateRequest(request: ToolRequest): ValidationResult
}
```

also implements `ToolExecutor`.

supported operations (from `request.parameters["operation"]`):
- `append` — append `content` to end of existing file
- `replace` — find `target` string in file, replace with `replacement` — fails if `target` not found or matches multiple times
- `patch` — apply a unified diff from `patch` parameter via `ProcessBuilder("patch", "-p0")` — T3 because patch application is the hardest to verify

`affectedPaths()` — same as `FileWriteTool`, returns target path as singleton.

path validation — same pattern, file must exist for all operations.

### constraints
- `allowedPaths` empty = deny ALL
- `replace` must match exactly once — if zero or multiple matches: `ToolResult.Failure(recoverable = false)`
- `patch` invokes external `patch` binary via `ProcessBuilder` — do NOT use shell
- T3 — highest approval tier for file tools
- backup/restore handled by `SandboxedToolExecutor` — always backs up before any edit

---

## task 8 — integration wiring

**module:** `infrastructure`

```kotlin
object InfrastructureModule {
    fun createEventStore(dbPath: String): EventStore =
        SqliteEventStore(DriverManager.getConnection("jdbc:sqlite:$dbPath"))

    fun createModelManager(eventStore: EventStore): ModelManager =
        DefaultModelManager(eventStore = eventStore)

   fun createToolRegistry(config: ToolConfig): ToolRegistry =
      ToolRegistry.build(buildList {
         if (config.shell.enabled) add(ShellTool(config.shell.allowedExecutables))
         if (config.fileRead.enabled) add(FileReadTool(config.fileRead.allowedPaths))
         if (config.fileWrite.enabled) add(FileWriteTool(config.fileWrite.allowedPaths))
         if (config.fileEdit.enabled) add(FileEditTool(config.fileEdit.allowedPaths))
      })

    fun createToolExecutor(
        registry: ToolRegistry,
        approvalEngine: ApprovalEngine,
        eventStore: EventStore,
    ): ToolExecutor = SandboxedToolExecutor(
        delegate = DispatchingToolExecutor(registry),
        registry = registry,
        approvalEngine = approvalEngine,
        eventStore = eventStore,
    )
}
```

`DispatchingToolExecutor` — resolves tool from registry by `request.toolName`, delegates. returns `ToolResult.Failure` if not found.

`ToolConfig` stub until epic 12:
```kotlin
data class ToolConfig(
   val shell: ShellConfig = ShellConfig(),
   val fileRead: FileReadConfig = FileReadConfig(),
   val fileWrite: FileWriteConfig = FileWriteConfig(),
   val fileEdit: FileEditConfig = FileEditConfig(),
)
data class FileReadConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
data class FileWriteConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
data class FileEditConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set<String> = emptySet())
```

---

## minimum test targets

### `LlamaCppInferenceProviderTest`
- request maps `GenerationConfig` fields correctly to `ChatCompletionRequest`
- usage mapped to `TokenUsage` correctly
- non-2xx response → `InferenceProviderException` thrown
- `healthCheck()` returns `Healthy` on 200, `Unhealthy` otherwise

### `DefaultModelManagerTest`
- `load()` same `modelId` twice → no-op, no process restart
- `load()` different `modelId` → kills current process, starts new
- health check timeout → `ModelLoadException`, no `ModelLoadedEvent` emitted
- `ModelLoadedEvent` emitted only after health check passes
- `ModelUnloadedEvent` emitted only after process is dead

### `ManagedInferenceProviderTest`
- `EPHEMERAL`: `unload()` called after `infer()` completes
- `DYNAMIC`: idle timer resets on each `infer()` call
- `PERSISTENT`: `unload()` never called automatically

### `ShellToolTest`
- empty `allowedExecutables` → all commands denied
- executable not in allowlist → `Failure` before process spawn
- timeout → process killed, `Failure` returned
- non-zero exit → `Failure` with stderr as reason
- environment is cleared — no parent env inheritance

### `FilesystemToolTest`
- empty `allowedPaths` → all paths denied
- path traversal `../` → blocked after `toRealPath()` resolution
- symlink escape → blocked after re-validation
- `affectedPaths()` returns empty set for `read`/`list`/`exists`

### `SandboxedToolExecutorTest`
- approval rejected → `ToolExecutionRejectedEvent` emitted, delegate not called
- delegate returns `Failure` → `.bak` restored, working dir deleted
- backup creation fails → `Failure` before delegate call
- `.bak` files cleaned up on success

---

## explicitly out of scope

- postgres
- ollama / vllm
- docker tools
- network tools
- telemetry exporters
- process namespace isolation
- CLI / API wiring (epic 12)
- config file loading (epic 12)