# 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 = 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 } ``` --- ## 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, val temperature: Double, @SerialName("top_p") val topP: Double, @SerialName("max_tokens") val maxTokens: Int, @SerialName("stop") val stopSequences: List = 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, 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 } ``` ### `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 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, ) { fun resolve(name: String): Tool? = tools[name] fun all(): List = tools.values.toList() companion object { fun build(vararg tools: Tool): ToolRegistry = ToolRegistry(tools.associateBy { it.name }) fun build(tools: List): 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` — no shell string parsing, no shell invocation. ```kotlin class ShellTool( private val allowedExecutables: Set = emptySet(), private val timeoutMs: Long = 30_000L, ) : Tool, ToolExecutor { override val name: String = "shell" override val tier: Tier = Tier.T2 override val requiredCapabilities: Set = setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN) override fun validateRequest(request: ToolRequest): ValidationResult override suspend fun execute(request: ToolRequest): ToolResult } ``` `execute()`: - extract `argv: List` 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 = emptySet(), ) : Tool { override val name: String = "file_read" override val tier: Tier = Tier.T1 override val requiredCapabilities: Set = 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 = emptySet(), ) : Tool, FileAffectingTool { override val name: String = "file_write" override val tier: Tier = Tier.T2 override val requiredCapabilities: Set = setOf(ToolCapability.FILE_WRITE) override fun affectedPaths(request: ToolRequest): Set 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 = emptySet(), ) : Tool, FileAffectingTool { override val name: String = "file_edit" override val tier: Tier = Tier.T3 override val requiredCapabilities: Set = setOf(ToolCapability.FILE_WRITE) override fun affectedPaths(request: ToolRequest): Set 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 = emptySet()) data class FileWriteConfig(val enabled: Boolean = false, val allowedPaths: Set = emptySet()) data class FileEditConfig(val enabled: Boolean = false, val allowedPaths: Set = emptySet()) data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set = 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)