17 KiB
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 fieldinfrastructure/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:
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— newinfrastructure/.../InfrastructureModule.kt— wirecreateInferenceRouter()
acceptance criteria:
- routes by capability, not hardcoded
NoEligibleProviderExceptionon 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:
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— newcore/transitions/.../WorkflowGraph.kt— replaceSet<StageId>withMap<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:
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 implcore/events/.../RiskAssessedEvent.kt— newcore/events/.../serialization/Serialization.kt— registercore/kernel/.../DefaultSessionOrchestrator.kt— replace hardcodedTier.T2
acceptance criteria:
DefaultRiskAssessoris pure (no IO, no coroutines)- each signal type has a dedicated test
RiskAssessedEventserializable 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 InferenceTimeoutEventemitted- timeout is per-stage
task 6: session type safety and FSM gaps
problems:
DefaultSessionRepository.getSession(sessionId: String)takes rawString— loses type safetySessionStatus.REPLAYEDhas no documented FSM transitions — can sessions leave REPLAYED state?SessionEventMappermay be dead code after Epic 3 replaced it withSessionReducer
fixes:
- change signature to
getSession(sessionId: SessionId) - define explicit FSM edges for REPLAYED — either terminal (no outgoing) or document valid transitions
- grep codebase for
SessionEventMapperusages — delete if unused, document if kept
files:
core/sessions/.../DefaultSessionRepository.ktcore/sessions/.../SessionFsm.kt(or equivalent)
acceptance criteria:
getSessiontakesSessionId- REPLAYED state transitions are explicit and tested
SessionEventMappereither removed or documented with justification
task 7: transition engine undefined behaviors
problems:
- no-match behavior undefined — what happens when no transition matches current stage?
StageIdorigin unspecified — who generates it, orchestrator or transition engine?
fixes:
- define explicit behavior: emit
StageFailedEventwith reason"no matching transition", return typed failure from resolver - document and enforce:
StageIdis always caller-generated (orchestrator). transition engine never createsStageIdvalues.
files:
core/transitions/.../DefaultTransitionResolver.kt— add no-match handlingcore/transitions/.../TransitionDecision.kt— addNoMatchcase 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
NoMatchdecision StageIdownership 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:
data class CycleSignature(
val nodes: SortedSet<StageId>,
val edges: SortedSet<Pair<StageId, StageId>>,
)
files:
core/transitions/.../CycleSignature.ktcore/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:
ApprovalEngineinterface contract- tier evaluation logic per mode (PROMPT, AUTO, DENY, YOLO)
- grant extraction semantics from event stream
ApprovalMode.PROMPTcurrent 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:
ArtifactLifecyclePhasetransitions are undocumented and unenforced — no reducer or FSMArtifactRelationshipAddedEvent.relationshipType: String— typo produces silent invalid relationshipvalidationResultIds: List<String>type unpinned — unclear if these areValidationReportIdor something else
fixes:
- introduce
ArtifactReducerenforcing valid phase transitions. invalid transitions →IllegalStateExceptionor typed error - validate
relationshipTypeagainstArtifactRelationshipType.entriesat event creation - pin type: rename to
validationReportIds: List<ValidationReportId>— breaking change, fix all usages
files:
core/artifacts/.../ArtifactReducer.kt— newcore/artifacts/.../ArtifactProjector.kt— wire reducercore/events/.../ArtifactRelationshipAddedEvent.kt— add validationcore/artifacts/.../ArtifactLineage.kt— rename + retype field
acceptance criteria:
- invalid phase transition throws at reducer level
- all valid transitions tested
relationshipTypevalidated at event creation, test: invalid type throwsvalidationReportIdstyped asList<ValidationReportId>
task 11: context engine edge cases
problems:
buildingInProgressstuck permanently if process crashes betweenContextBuildingStartedEventand completion- L0+L1 exceeding
TokenBudget.limithas no defined overflow behavior - Conversation compression strategy "last N" — N is undocumented and presumably hardcoded
fixes:
- add recovery: on replay, if
buildingInProgress = trueand no completion/failure event follows, treat as failed. addContextBuildingInterruptedEventor handle via timeout during rebuild. - define overflow behavior explicitly: if L0+L1 > limit, truncate L1 from oldest first. L0 is never truncated. document this as a hard rule.
- make N configurable in
CompressionStrategy.Conversation(keepLast: Int = 10). document default.
files:
core/context/.../DefaultContextReducer.kt— stuck state recoverycore/context/.../DefaultContextCompressor.kt— L1 overflow handlingcore/context/.../CompressionStrategy.kt— addkeepLastparam
acceptance criteria:
- replaying a session with interrupted context build produces valid (failed) state
- L0+L1 overflow test: L1 truncated, L0 retained
keepLastdocumented and tested
task 12: inference contract gaps
problems:
InferenceTimeoutandInferenceCancellationTokeninteraction undefined — potential double-cancellation race- stale provider snapshot: provider becomes unavailable between routing and
infer()call CancellationExceptionswallow not enforced by contract test — any provider can silently break cooperative cancellation
fixes:
- document interaction rule: timeout fires → cancels token → provider sees cancellation. token cancel does NOT fire timeout. add KDoc on both types.
- add health check in
DefaultInferenceRouter.route()— filter outUnavailableproviders before selection. oninfer()failure withUnavailablehealth, throwProviderUnavailableException(not generic inference failure). - add contract test: cancel coroutine mid-inference, assert coroutine is actually cancelled within 500ms.
files:
core/inference/.../InferenceCancellationToken.kt— KDoccore/inference/.../InferenceTimeout.kt— KDoccore/inference/.../DefaultInferenceRouter.kt— health check before selectiontesting/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:
ReplayStrategy.SkipValidationleaves artifacts stuck inVALIDATINGphase permanentlyValidationFailure.retryableownership unclear — validator sets it or orchestrator?- approval tier for validation-triggered approvals unspecified
fixes:
- when
SkipValidationactive, emit syntheticArtifactValidatedEventfor any artifact inVALIDATINGphase. document this as replay-only behavior. - rule: validator sets
retryablebased on failure type. orchestrator only reads it, never overrides. document this ownership in KDoc onValidationFailure. - validation-triggered approvals use
Tier.T2explicitly. document in approval trigger logic. revisit when risk model is wired (task 4 feeds into this).
files:
core/kernel/.../ReplayOrchestrator.kt— synthetic validation eventscore/kernel/.../StageOutcome.kt— KDoc onValidationFailure.retryablecore/validation/.../ApprovalTrigger.kt— explicit tier for validation approvals
acceptance criteria:
- replay with SkipValidation: no artifacts left in VALIDATING phase
retryableownership documented and tested- validation approval tier explicit and documented
task 14: tool execution gaps
problems:
ToolResult.Successhas noexitCodefield — shell tool exit codes lost in receiptaffectedEntitieshardcoded toemptyList()inSandboxedToolExecutor— tool lineage permanently lost
fixes:
- add
exitCode: Int = 0toToolResult.Success.SandboxedToolExecutor.emitCompleted()uses it.ShellToolsets actual exit code. SandboxedToolExecutor.emitCompleted(): if tool implementsFileAffectingTool, populateaffectedEntitiesfromtool.affectedPaths(request).
files:
core/tools/.../ToolResult.kt— addexitCodeinfrastructure/tools/.../SandboxedToolExecutor.kt— use exitCode + affectedPathsinfrastructure/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.Successsignature
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.mdandspec-v0.1.mdto reflect current architecture - write module specs for: infrastructure tools, inference, sessions (impl-level), kernel
- then Epic 13: interfaces (TUI + CLI + Ktor server)