Files
correx/docs/epics/epic-11.md
T

26 KiB

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

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, 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

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" or role = "user" depending on sourceType
  • if ContextPack is empty → single user message 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.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

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:

  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:

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:

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:

  1. resolve tool from registry by request.toolName — if not found: return ToolResult.Failure(recoverable = false)
  2. check approval via tool.tier:
    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.

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 timeoutMsprocess.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

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

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

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

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 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)