# Epic 12 — Technical Debt & Gap Closure

**status:** planned
**scope:** fix all known bugs, ambiguities, and structural gaps before building interfaces
**goal:** codebase is correct, documented, and stable before Epic 13 (interfaces) begins

---

## task 1: ModelDescriptor.capabilities

**problem:** `LlamaCppInferenceProvider.capabilities()` derives capabilities from model name string matching. fragile, wrong on rename, invisible failure.

**fix:** add `capabilities: Set<CapabilityScore>` to `ModelDescriptor`. `LlamaCppInferenceProvider.capabilities()` returns `descriptor.capabilities` directly.

**files:**
- `infrastructure/inference/.../ModelDescriptor.kt` — add field
- `infrastructure/inference/.../LlamaCppInferenceProvider.kt` — remove name matching, return descriptor field

**acceptance criteria:**
- capabilities declared per-descriptor, not derived from name
- no string matching logic in `capabilities()`

---

## task 2: DefaultInferenceRouter

**problem:** `InferenceRouter` interface exists, no implementation. orchestrator calls `route(stageId, emptySet())` with hardcoded empty capabilities.

**fix:**
```kotlin
class DefaultInferenceRouter(
    private val registry: ProviderRegistry,
    private val strategy: RoutingStrategy,
) : InferenceRouter {
    override fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider {
        val candidates = requiredCapabilities
            .flatMap { registry.resolve(it) }
            .distinctBy { it.id }
            .ifEmpty { registry.listAll() }
        return strategy.select(candidates, requiredCapabilities)
    }
}
```

**files:**
- `core/inference/.../DefaultInferenceRouter.kt` — new
- `infrastructure/.../InfrastructureModule.kt` — wire `createInferenceRouter()`

**acceptance criteria:**
- routes by capability, not hardcoded
- `NoEligibleProviderException` on no candidates
- contract tests: match found, no match throws, empty capabilities returns any available

---

## task 3: StageConfig in WorkflowGraph

**problem:** `WorkflowGraph` holds `stages: Set<StageId>` only. orchestrator hardcodes context budget, generation config, capabilities.

**fix:**
```kotlin
data class StageConfig(
    val requiredCapabilities: Set<ModelCapability> = emptySet(),
    val tokenBudget: Int = 4096,
    val generationConfig: GenerationConfig = GenerationConfig(),
    val allowedTools: Set<String> = emptySet(),
    val maxRetries: Int = 3,
)

data class WorkflowGraph(
    val stages: Map<StageId, StageConfig>,
    val transitions: Set<TransitionEdge>,
    val start: StageId,
) {
    val stageIds: Set<StageId> get() = stages.keys
    init { require(start in stages) { "start stage must exist in stages" } }
}
```

**before dispatching:** run `grep -r "WorkflowGraph" --include="*.kt" -l` to know blast radius.

**files:**
- `core/transitions/.../StageConfig.kt` — new
- `core/transitions/.../WorkflowGraph.kt` — replace `Set<StageId>` with `Map<StageId, StageConfig>`
- `core/kernel/.../DefaultSessionOrchestrator.kt` — use stage config values
- all tests constructing `WorkflowGraph` — update constructor

**acceptance criteria:**
- orchestrator derives capabilities, budget, generationConfig from stage config
- existing transition resolution and cycle analysis tests pass
- no hardcoded values remain in orchestrator for stage-specific config

---

## task 4: core:risk module

**problem:** approval tier hardcoded to `T2`. no risk signal aggregation exists.

**new module:** `:core:risk`
**depends on:** `:core:events`, `:core:approvals`, `:core:validation`, `:core:transitions`

**types:**
```kotlin
enum class RiskLevel { LOW, MEDIUM, HIGH, CRITICAL }
enum class RiskAction { PROCEED, PROMPT_USER, BLOCK }

sealed class RiskSignal {
    data class DestructiveOperation(val tier: Tier) : RiskSignal()
    data class CycleWithoutExit(val cycleId: String) : RiskSignal()
    data class RepeatedFailure(val reason: String, val count: Int) : RiskSignal()
    data class ValidationErrors(val errorCount: Int) : RiskSignal()
    data class InferenceTimeout(val elapsedMs: Long) : RiskSignal()
}

data class RiskSummary(
    val level: RiskLevel,
    val signals: List<RiskSignal>,
    val recommendedAction: RiskAction,
)

fun RiskLevel.toApprovalTier(): Tier = when (this) {
    RiskLevel.LOW      -> Tier.T1
    RiskLevel.MEDIUM   -> Tier.T2
    RiskLevel.HIGH     -> Tier.T3
    RiskLevel.CRITICAL -> Tier.T4
}

interface RiskAssessor {
    fun assess(
        report: ValidationReport,
        outcome: StageOutcome,
        state: OrchestrationState,
    ): RiskSummary
}
```

**DefaultRiskAssessor rules:**
- T3/T4 tool in outcome → `DestructiveOperation` → at least HIGH
- validation report has errors → `ValidationErrors` → at least MEDIUM
- `retryCount >= maxAttempts - 1` → `RepeatedFailure` → HIGH
- cycle without policy binding → `CycleWithoutExit` → MEDIUM
- recent `InferenceTimeoutEvent` → `InferenceTimeout` → MEDIUM
- no signals → LOW, PROCEED

**new event:** `RiskAssessedEvent(sessionId, stageId, riskSummaryId, level, action)` — register in `Serialization.kt`.

**files:**
- `core/risk/src/main/kotlin/.../` — all risk types + interface + default impl
- `core/events/.../RiskAssessedEvent.kt` — new
- `core/events/.../serialization/Serialization.kt` — register
- `core/kernel/.../DefaultSessionOrchestrator.kt` — replace hardcoded `Tier.T2`

**acceptance criteria:**
- `DefaultRiskAssessor` is pure (no IO, no coroutines)
- each signal type has a dedicated test
- `RiskAssessedEvent` serializable and registered
- orchestrator derives tier from risk summary

---

## task 5: stage timeout enforcement

**problem:** `OrchestrationConfig.stageTimeoutMs` configured but never enforced.

**fix:** wrap stage execution in `withTimeout(config.stageTimeoutMs)` in `DefaultSessionOrchestrator`. on `TimeoutCancellationException` emit `InferenceTimeoutEvent`, return `StageOutcome.InferenceFailure(retryable = true)`.

**files:**
- `core/kernel/.../DefaultSessionOrchestrator.kt`

**acceptance criteria:**
- stage exceeding timeout produces `InferenceFailure`
- `InferenceTimeoutEvent` emitted
- timeout is per-stage

---

## task 6: session type safety and FSM gaps

**problems:**
1. `DefaultSessionRepository.getSession(sessionId: String)` takes raw `String` — loses type safety
2. `SessionStatus.REPLAYED` has no documented FSM transitions — can sessions leave REPLAYED state?
3. `SessionEventMapper` may be dead code after Epic 3 replaced it with `SessionReducer`

**fixes:**
1. change signature to `getSession(sessionId: SessionId)`
2. define explicit FSM edges for REPLAYED — either terminal (no outgoing) or document valid transitions
3. grep codebase for `SessionEventMapper` usages — delete if unused, document if kept

**files:**
- `core/sessions/.../DefaultSessionRepository.kt`
- `core/sessions/.../SessionFsm.kt` (or equivalent)

**acceptance criteria:**
- `getSession` takes `SessionId`
- REPLAYED state transitions are explicit and tested
- `SessionEventMapper` either removed or documented with justification

---

## task 7: transition engine undefined behaviors

**problems:**
1. no-match behavior undefined — what happens when no transition matches current stage?
2. `StageId` origin unspecified — who generates it, orchestrator or transition engine?

**fixes:**
1. define explicit behavior: emit `StageFailedEvent` with reason `"no matching transition"`, return typed failure from resolver
2. document and enforce: `StageId` is always caller-generated (orchestrator). transition engine never creates `StageId` values.

**files:**
- `core/transitions/.../DefaultTransitionResolver.kt` — add no-match handling
- `core/transitions/.../TransitionDecision.kt` — add `NoMatch` case if not present
- CLAUDE.md — add StageId ownership rule

**acceptance criteria:**
- no-match produces deterministic typed failure, not silent null/hang
- test: resolver with no matching transition returns `NoMatch` decision
- `StageId` ownership documented

---

## task 8: CycleSignature edge inclusion

**problem:** `CycleSignature` is derived from node set only. two cycles with same nodes but different edge order are treated as identical. policy binding can silently apply to the wrong cycle.

**fix:** include normalized edge set in `CycleSignature`:
```kotlin
data class CycleSignature(
    val nodes: SortedSet<StageId>,
    val edges: SortedSet<Pair<StageId, StageId>>,
)
```

**files:**
- `core/transitions/.../CycleSignature.kt`
- `core/validation/.../SemanticValidator.kt` — update signature derivation
- affected tests

**acceptance criteria:**
- two cycles with same nodes but different edges produce different signatures
- existing single-cycle tests still pass

---

## task 9: Epic 5 resolution document

**problem:** Epic 5 (Approvals) has no resolution document. `ApprovalEngine` contract, tier evaluation logic, grant semantics, and `ApprovalMode` behavior are undocumented. Epic 13 (interfaces) builds approval interaction on top of this.

**fix:** write `docs/epics/epic-5-resolution.md` covering:
- `ApprovalEngine` interface contract
- tier evaluation logic per mode (PROMPT, AUTO, DENY, YOLO)
- grant extraction semantics from event stream
- `ApprovalMode.PROMPT` current behavior (evaluates existing grants, no interactive prompt yet)
- what's deferred to Epic 13

**files:**
- `docs/epics/epic-5-resolution.md` — new

**acceptance criteria:**
- document exists and covers all four approval modes
- current PROMPT behavior limitation explicitly stated
- Epic 13 approval interaction scope is clear

---

## task 10: artifact lifecycle enforcement

**problems:**
1. `ArtifactLifecyclePhase` transitions are undocumented and unenforced — no reducer or FSM
2. `ArtifactRelationshipAddedEvent.relationshipType: String` — typo produces silent invalid relationship
3. `validationResultIds: List<String>` type unpinned — unclear if these are `ValidationReportId` or something else

**fixes:**
1. introduce `ArtifactReducer` enforcing valid phase transitions. invalid transitions → `IllegalStateException` or typed error
2. validate `relationshipType` against `ArtifactRelationshipType.entries` at event creation
3. pin type: rename to `validationReportIds: List<ValidationReportId>` — breaking change, fix all usages

**files:**
- `core/artifacts/.../ArtifactReducer.kt` — new
- `core/artifacts/.../ArtifactProjector.kt` — wire reducer
- `core/events/.../ArtifactRelationshipAddedEvent.kt` — add validation
- `core/artifacts/.../ArtifactLineage.kt` — rename + retype field

**acceptance criteria:**
- invalid phase transition throws at reducer level
- all valid transitions tested
- `relationshipType` validated at event creation, test: invalid type throws
- `validationReportIds` typed as `List<ValidationReportId>`

---

## task 11: context engine edge cases

**problems:**
1. `buildingInProgress` stuck permanently if process crashes between `ContextBuildingStartedEvent` and completion
2. L0+L1 exceeding `TokenBudget.limit` has no defined overflow behavior
3. Conversation compression strategy "last N" — N is undocumented and presumably hardcoded

**fixes:**
1. add recovery: on replay, if `buildingInProgress = true` and no completion/failure event follows, treat as failed. add `ContextBuildingInterruptedEvent` or handle via timeout during rebuild.
2. define overflow behavior explicitly: if L0+L1 > limit, truncate L1 from oldest first. L0 is never truncated. document this as a hard rule.
3. make N configurable in `CompressionStrategy.Conversation(keepLast: Int = 10)`. document default.

**files:**
- `core/context/.../DefaultContextReducer.kt` — stuck state recovery
- `core/context/.../DefaultContextCompressor.kt` — L1 overflow handling
- `core/context/.../CompressionStrategy.kt` — add `keepLast` param

**acceptance criteria:**
- replaying a session with interrupted context build produces valid (failed) state
- L0+L1 overflow test: L1 truncated, L0 retained
- `keepLast` documented and tested

---

## task 12: inference contract gaps

**problems:**
1. `InferenceTimeout` and `InferenceCancellationToken` interaction undefined — potential double-cancellation race
2. stale provider snapshot: provider becomes unavailable between routing and `infer()` call
3. `CancellationException` swallow not enforced by contract test — any provider can silently break cooperative cancellation

**fixes:**
1. document interaction rule: timeout fires → cancels token → provider sees cancellation. token cancel does NOT fire timeout. add KDoc on both types.
2. add health check in `DefaultInferenceRouter.route()` — filter out `Unavailable` providers before selection. on `infer()` failure with `Unavailable` health, throw `ProviderUnavailableException` (not generic inference failure).
3. add contract test: cancel coroutine mid-inference, assert coroutine is actually cancelled within 500ms.

**files:**
- `core/inference/.../InferenceCancellationToken.kt` — KDoc
- `core/inference/.../InferenceTimeout.kt` — KDoc
- `core/inference/.../DefaultInferenceRouter.kt` — health check before selection
- `testing/contracts/.../InferenceProviderContractTest.kt` — cancellation enforcement test

**acceptance criteria:**
- timeout/token interaction documented
- unavailable provider filtered at routing, not discovered at inference time
- cancellation contract test fails if provider swallows `CancellationException`

---

## task 13: orchestrator ambiguities

**problems:**
1. `ReplayStrategy.SkipValidation` leaves artifacts stuck in `VALIDATING` phase permanently
2. `ValidationFailure.retryable` ownership unclear — validator sets it or orchestrator?
3. approval tier for validation-triggered approvals unspecified

**fixes:**
1. when `SkipValidation` active, emit synthetic `ArtifactValidatedEvent` for any artifact in `VALIDATING` phase. document this as replay-only behavior.
2. rule: validator sets `retryable` based on failure type. orchestrator only reads it, never overrides. document this ownership in KDoc on `ValidationFailure`.
3. validation-triggered approvals use `Tier.T2` explicitly. document in approval trigger logic. revisit when risk model is wired (task 4 feeds into this).

**files:**
- `core/kernel/.../ReplayOrchestrator.kt` — synthetic validation events
- `core/kernel/.../StageOutcome.kt` — KDoc on `ValidationFailure.retryable`
- `core/validation/.../ApprovalTrigger.kt` — explicit tier for validation approvals

**acceptance criteria:**
- replay with SkipValidation: no artifacts left in VALIDATING phase
- `retryable` ownership documented and tested
- validation approval tier explicit and documented

---

## task 14: tool execution gaps

**problems:**
1. `ToolResult.Success` has no `exitCode` field — shell tool exit codes lost in receipt
2. `affectedEntities` hardcoded to `emptyList()` in `SandboxedToolExecutor` — tool lineage permanently lost

**fixes:**
1. add `exitCode: Int = 0` to `ToolResult.Success`. `SandboxedToolExecutor.emitCompleted()` uses it. `ShellTool` sets actual exit code.
2. `SandboxedToolExecutor.emitCompleted()`: if tool implements `FileAffectingTool`, populate `affectedEntities` from `tool.affectedPaths(request)`.

**files:**
- `core/tools/.../ToolResult.kt` — add `exitCode`
- `infrastructure/tools/.../SandboxedToolExecutor.kt` — use exitCode + affectedPaths
- `infrastructure/tools/shell/.../ShellTool.kt` — set exitCode in result
- affected tests

**acceptance criteria:**
- shell tool non-zero exit captured in receipt
- file-affecting tools produce non-empty `affectedEntities`
- existing tool tests updated for new `ToolResult.Success` signature

---

## sequencing

```
task 1  (ModelDescriptor.capabilities)     ← quick, unblocks task 2
task 2  (DefaultInferenceRouter)           ← depends on task 1
task 3  (StageConfig)                      ← independent, high blast radius
task 4  (core:risk)                        ← depends on task 3
task 5  (stage timeout)                    ← depends on task 4
task 6  (session type safety + FSM)        ← independent
task 7  (transition undefined behaviors)   ← independent
task 8  (CycleSignature edges)             ← independent
task 9  (Epic 5 resolution doc)            ← independent, no code
task 10 (artifact lifecycle)               ← independent
task 11 (context engine edge cases)        ← independent
task 12 (inference contract gaps)          ← depends on task 2
task 13 (orchestrator ambiguities)         ← depends on task 4
task 14 (tool execution gaps)              ← independent
```

tasks 6–11 and 14 can be parallelized if using subagents.
tasks 1→2→12 and 3→4→5→13 are the two critical chains.

---

## after this epic

- update `des-doc-v0.1.md` and `spec-v0.1.md` to reflect current architecture
- write module specs for: infrastructure tools, inference, sessions (impl-level), kernel
- then Epic 13: interfaces (TUI + CLI + Ktor server)