26 KiB
Epic 11 — Infrastructure Layer
date: 2026-05-12
scope: infrastructure/ — persistence, inference, tools, model management
status: ready to implement
resume instructions
- open project at
/home/kami/Programs/correx - read this file top to bottom before writing any code
- complete tasks in order — hard boundary after task 3 before tools work begins
- do NOT explore beyond files listed in each task
- 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
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
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:
interface FileAffectingTool : Tool {
fun affectedPaths(request: ToolRequest): Set<Path>
}
scope (what IS in epic 11)
SqliteEventStore— already done, skipLlamaCppInferenceProvider— OpenAI-compatible REST clientLlamaCppTokenizerModelManager+ManagedInferenceProvider— load/unload/health/residencyToolRegistry— immutable after bootstrapSandboxedToolExecutor—/tmpisolation + backup/restore + approval gatingShellTool— argv-based, allowlist-gatedFilesystemTool— path-allowlisted, traversal-safeInfrastructureModule— 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
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
@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"orrole = "user"depending onsourceType - if
ContextPackis empty → singleusermessage with empty string (do not crash)
LlamaCppInferenceProvider
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.CODINGscore 0.9 - contains "r1" or "deepseek" →
ModelCapability.REASONINGscore 0.9 - default →
ModelCapability.GENERALscore 0.7
constraints
stream = falsealways- do NOT implement retry logic —
RetryCoordinator's responsibility - do NOT catch
CancellationException— let it propagate - throw
InferenceProviderExceptionon non-2xx HTTP responses latencyMs= wall clock time of the HTTP call
task 2 — LlamaCppTokenizer
module: infrastructure:inference:llama_cpp
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
enum class ResidencyMode {
PERSISTENT, // never unload
DYNAMIC, // unload after idle timeout
EPHEMERAL, // unload immediately after infer completes
}
ModelDescriptor
data class ModelDescriptor(
val modelId: String,
val modelPath: String,
val residencyMode: ResidencyMode,
val idleTimeoutMs: Long = 60_000L,
val contextSize: Int = 8192,
)
ModelManager interface
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.
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:
- acquire
Mutex - if
currentDescriptor?.modelId == descriptor.modelId→ no-op, return existing provider - if process running:
process.destroyForcibly(), wait for exit, clear state, appendModelUnloadedEvent - spawn:
ProcessBuilder(llamaServerBin, "--model", descriptor.modelPath, "--ctx-size", descriptor.contextSize.toString(), "--host", host, "--port", port.toString()) - redirect stdout/stderr to
~/.local/share/correx/logs/llama-server.log— create dir if absent - poll GET
http://$host:$port/healthuntil 200 — ifhealthTimeoutMsexceeded: throwModelLoadException(no event emitted on failure) - set
currentProcess,currentDescriptor - append
ModelLoadedEvent← only after process is healthy and state committed - release
Mutex, returnManagedInferenceProvider(delegate, this, descriptor)
unload() flow — event appended AFTER committed state:
- acquire
Mutex process.destroyForcibly(), wait for exit- clear
currentProcess,currentDescriptor - append
ModelUnloadedEvent← only after process is dead and state cleared - release
Mutex
ManagedInferenceProvider
wraps LlamaCppInferenceProvider, notifies manager for residency policy:
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 coroutineonInferenceCompleted(descriptor)— callsunload()immediately ifEPHEMERAL, 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
ModelLoadExceptionthrown 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:
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
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:
- resolve tool from
registrybyrequest.toolName— if not found: returnToolResult.Failure(recoverable = false) - check approval via
tool.tier:if not approved: emitwhen (tool.tier) { Tier.T0, Tier.T1 -> { /* proceed */ } Tier.T2, Tier.T3 -> { /* evaluate approval */ } }ToolExecutionRejectedEvent, returnToolResult.Failure(recoverable = false) - emit
ToolExecutionStartedEvent - create working dir:
/tmp/correx-sandbox/{sessionId}/{invocationId}/ - if tool implements
FileAffectingTool: calltool.affectedPaths(request), copy each to{workDir}/{filename}.bakviaFiles.copy - if backup fails: return
ToolResult.Failurebefore delegating — do NOT proceed - delegate to underlying
ToolExecutor - on
ToolResult.Success:Files.moveresult to original path, delete.bakfiles, emitToolExecutionCompletedEvent - on
ToolResult.Failure: restore.bakfiles viaFiles.move, delete working dir, emitToolExecutionFailedEvent
constraints
- working dir is fresh per invocation — no shared state
.bakfiles 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.
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>fromrequest.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, returnToolResult.Failure - exit code 0 →
ToolResult.Success(output = stdout) - non-zero →
ToolResult.Failure(reason = stderr, recoverable = false)
constraints
- do NOT invoke
bash -cor 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
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 asoutputlist— list directory contents atpathexists— return"true"or"false"
does NOT implement FileAffectingTool — read operations require no backup.
path validation before ANY operation:
- extract
pathfromrequest.parameters["path"] - resolve to absolute via
toRealPath() - check resolved path starts with at least one
allowedPathsroot - if not: return
ToolResult.Failure(recoverable = false)
constraints
allowedPathsempty = deny ALL — fail-secure defaulttoRealPath()resolves symlinks — re-validate after resolution- do NOT follow symlinks outside allowed paths
- T1 — goes through
SandboxedToolExecutorapproval check but auto-approved
task 7b — FileWriteTool
file: infrastructure/tools/filesystem/FileWriteTool.kt
module: infrastructure:tools:filesystem
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 atpathwithcontentparameterdelete— delete file atpath
affectedPaths() — extract path from request, resolve absolute, return as singleton set.
path validation — same as FileReadTool but applied to write targets.
constraints
allowedPathsempty = deny ALLtoRealPath()on parent dir for new files — target file may not exist yet, validate parent- T2 — requires approval via
SandboxedToolExecutor - backup/restore handled by
SandboxedToolExecutorviaFileAffectingTool— do NOT implement backup here
task 7c — FileEditTool
file: infrastructure/tools/filesystem/FileEditTool.kt
module: infrastructure:tools:filesystem
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— appendcontentto end of existing filereplace— findtargetstring in file, replace withreplacement— fails iftargetnot found or matches multiple timespatch— apply a unified diff frompatchparameter viaProcessBuilder("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
allowedPathsempty = deny ALLreplacemust match exactly once — if zero or multiple matches:ToolResult.Failure(recoverable = false)patchinvokes externalpatchbinary viaProcessBuilder— 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
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:
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
GenerationConfigfields correctly toChatCompletionRequest - usage mapped to
TokenUsagecorrectly - non-2xx response →
InferenceProviderExceptionthrown healthCheck()returnsHealthyon 200,Unhealthyotherwise
DefaultModelManagerTest
load()samemodelIdtwice → no-op, no process restartload()differentmodelId→ kills current process, starts new- health check timeout →
ModelLoadException, noModelLoadedEventemitted ModelLoadedEventemitted only after health check passesModelUnloadedEventemitted only after process is dead
ManagedInferenceProviderTest
EPHEMERAL:unload()called afterinfer()completesDYNAMIC: idle timer resets on eachinfer()callPERSISTENT:unload()never called automatically
ShellToolTest
- empty
allowedExecutables→ all commands denied - executable not in allowlist →
Failurebefore process spawn - timeout → process killed,
Failurereturned - non-zero exit →
Failurewith stderr as reason - environment is cleared — no parent env inheritance
FilesystemToolTest
- empty
allowedPaths→ all paths denied - path traversal
../→ blocked aftertoRealPath()resolution - symlink escape → blocked after re-validation
affectedPaths()returns empty set forread/list/exists
SandboxedToolExecutorTest
- approval rejected →
ToolExecutionRejectedEventemitted, delegate not called - delegate returns
Failure→.bakrestored, working dir deleted - backup creation fails →
Failurebefore delegate call .bakfiles 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)