commit c77277af0bc61068f0738805080a5ab12c3597a3 Author: kami Date: Sat May 16 11:42:00 2026 +0400 epic-12: after epic audit and init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ba06cb97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +.idea/ +auto-import. +*.iml +*.ipr +cmake-build-*/ +*.iws +out/ +.idea_modules/ +atlassian-ide-plugin.xml +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +http-client.private.env.json +.DS_Store +/confluence/target +/dependencies/repo +/android.tests.dependencies +/dependencies/android.tests.dependencies +/dist +/local +/gh-pages +/ideaSDK +/clionSDK +/android-studio/sdk +/tmp +/intellij +workspace.xml +*.versionsBackup +/idea/testData/debugger/tinyApp/classes* +/jps-plugin/testData/kannotator +/js/js.translator/testData/out/ +/js/js.translator/testData/out-min/ +/js/js.translator/testData/out-pir/ +.gradle/ +build/ +!**/src/**/build +!**/test/**/build +!**/tests-gen/**/build +!**/testData/**/*.iml +node_modules/ +.rpt2_cache/ +local.properties +buildSrcTmp/ +distTmp/ +outTmp/ +/test.output +/kotlin-native/dist +kotlin-ide/ +.kotlin/ +.teamcity/ +.npmrc +**/.junie/memory/ +.claude/ +.claudeignore +.ctx_cache.pkl +.opencode/ +CLAUDE.md +scripts/ +test_output.log +**/xcuserdata +.test-federation.* +bin/ +/docs/future/ +/docs/plans/ +/docs/refactor.md diff --git a/apps/cli/build.gradle b/apps/cli/build.gradle new file mode 100644 index 00000000..8ac8456d --- /dev/null +++ b/apps/cli/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'application' +} + +application { + mainClass = 'com.correx.apps.cli.MainKt' +} + +dependencies { + implementation "com.github.ajalt.clikt:clikt:5.0.1" +} diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/Main.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/Main.kt new file mode 100644 index 00000000..df654d8c --- /dev/null +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/Main.kt @@ -0,0 +1,5 @@ +package com.correx.apps.cli + +fun main() { + println("correx :: apps/cli") +} diff --git a/apps/desktop/build.gradle b/apps/desktop/build.gradle new file mode 100644 index 00000000..289d6d13 --- /dev/null +++ b/apps/desktop/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'application' +} + +application { + mainClass = 'com.correx.apps.desktop.MainKt' +} + +dependencies { + implementation "com.github.ajalt.clikt:clikt:5.0.1" +} diff --git a/apps/desktop/src/main/kotlin/com/correx/apps/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/correx/apps/desktop/Main.kt new file mode 100644 index 00000000..fe456df0 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/correx/apps/desktop/Main.kt @@ -0,0 +1,5 @@ +package com.correx.apps.desktop + +fun main() { + println("correx :: apps/desktop") +} diff --git a/apps/server/build.gradle b/apps/server/build.gradle new file mode 100644 index 00000000..a14ee350 --- /dev/null +++ b/apps/server/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'application' +} + +application { + mainClass = 'com.correx.apps.server.MainKt' +} + +dependencies { + implementation "com.github.ajalt.clikt:clikt:5.0.1" +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt new file mode 100644 index 00000000..80f574a4 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -0,0 +1,5 @@ +package com.correx.apps.server + +fun main() { + println("correx :: apps/server") +} diff --git a/apps/worker/build.gradle b/apps/worker/build.gradle new file mode 100644 index 00000000..3eb6a105 --- /dev/null +++ b/apps/worker/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'application' +} + +application { + mainClass = 'com.correx.apps.worker.MainKt' +} + +dependencies { + implementation "com.github.ajalt.clikt:clikt:5.0.1" +} diff --git a/apps/worker/src/main/kotlin/com/correx/apps/worker/Main.kt b/apps/worker/src/main/kotlin/com/correx/apps/worker/Main.kt new file mode 100644 index 00000000..55fa81ad --- /dev/null +++ b/apps/worker/src/main/kotlin/com/correx/apps/worker/Main.kt @@ -0,0 +1,5 @@ +package com.correx.apps.worker + +fun main() { + println("correx :: apps/worker") +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..56d60b24 --- /dev/null +++ b/build.gradle @@ -0,0 +1,126 @@ +import io.gitlab.arturbosch.detekt.Detekt + +plugins { + id 'org.jetbrains.kotlin.jvm' version '2.0.21' apply false + id 'org.jetbrains.kotlin.plugin.serialization' version '2.0.21' apply false + id "io.gitlab.arturbosch.detekt" version "1.23.7" + id "org.jetbrains.kotlinx.kover" version "0.8.3" +} + +ext { + kotlin_version = '2.0.21' + kotlinx_coroutines_version = '1.9.0' + kotlinx_serialization_version = '1.7.3' + kotlinx_datetime_version = '0.6.2' + junit_version = '5.11.0' + sqlite_jdbc_version = '3.47.1.0' + + exposed_version = '0.57.0' + ktor_version = '3.0.3' + hoplite_version = '2.8.2' + sqlite_jdbc_version = '3.47.1.0' +} + +allprojects { + group = 'com.correx' + version = '0.1.0' + + repositories { + mavenCentral() + } +} + +subprojects { + pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { + + apply plugin: "io.gitlab.arturbosch.detekt" + apply plugin: "org.jetbrains.kotlinx.kover" + + detekt { + toolVersion = "1.23.7" + config.setFrom("$rootDir/detekt.yml") + buildUponDefaultConfig = true + ignoreFailures = false + } + + kover { + reports { +// verify { +// rule { +// minBound(80) +// } +// } + filters { + excludes { + classes("*Generated*") + } + } + } + } + + tasks.named("check") { + dependsOn("test") + dependsOn("detekt") + dependsOn("koverVerify") + } + } + + plugins.withId('org.jetbrains.kotlin.jvm') { + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } + } + + dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version" + implementation "org.xerial:sqlite-jdbc:$sqlite_jdbc_version" + implementation "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinx_datetime_version" + testImplementation "org.junit.jupiter:junit-jupiter:$junit_version" + } + + test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + showStandardStreams = true + exceptionFormat = 'full' + } + afterSuite { desc, result -> + if (!desc.parent) { // Only log for the final root suite + def duration = (result.endTime - result.startTime) / 1000 + + println """ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + tests: ${result.testCount} + passed: ${result.successfulTestCount} + failed: ${result.failedTestCount} + skipped: ${result.skippedTestCount} + + result: ${result.resultType} + time: ${duration}s + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + """ + } + } + } + + tasks.withType(Detekt).configureEach { + reports { + html.required.set(true) + xml.required.set(true) + txt.required.set(false) + } + } + + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 + freeCompilerArgs.add("-Xcontext-receivers") + } + } + } +} \ No newline at end of file diff --git a/core/agents/build.gradle b/core/agents/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/core/agents/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/core/agents/src/main/kotlin/com/correx/core/agents/Module.kt b/core/agents/src/main/kotlin/com/correx/core/agents/Module.kt new file mode 100644 index 00000000..77592b23 --- /dev/null +++ b/core/agents/src/main/kotlin/com/correx/core/agents/Module.kt @@ -0,0 +1,3 @@ +package com.correx.core.agents + +object Module diff --git a/core/approvals/build.gradle b/core/approvals/build.gradle new file mode 100644 index 00000000..cc0c5f1f --- /dev/null +++ b/core/approvals/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + implementation(project(":core:sessions")) + testImplementation(project(":infrastructure:persistence")) +} \ No newline at end of file diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/ApprovalProjector.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/ApprovalProjector.kt new file mode 100644 index 00000000..528588c2 --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/ApprovalProjector.kt @@ -0,0 +1,10 @@ +package com.correx.core.approvals + +import com.correx.core.approvals.model.ApprovalState +import com.correx.core.events.events.StoredEvent +import com.correx.core.sessions.projections.Projection + +class ApprovalProjector(private val reducer: ApprovalReducer) : Projection { + override fun initial(): ApprovalState = ApprovalState() + override fun apply(state: ApprovalState, event: StoredEvent): ApprovalState = reducer.reduce(state, event) +} diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/ApprovalReducer.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/ApprovalReducer.kt new file mode 100644 index 00000000..831e0bd8 --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/ApprovalReducer.kt @@ -0,0 +1,8 @@ +package com.correx.core.approvals + +import com.correx.core.approvals.model.ApprovalState +import com.correx.core.events.events.StoredEvent + +interface ApprovalReducer { + fun reduce(state: ApprovalState, event: StoredEvent): ApprovalState +} diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/DefaultApprovalReducer.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/DefaultApprovalReducer.kt new file mode 100644 index 00000000..f3148d49 --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/DefaultApprovalReducer.kt @@ -0,0 +1,81 @@ +package com.correx.core.approvals + +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalDecision +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.DomainApprovalRequest +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.approvals.model.ApprovalState +import com.correx.core.events.events.ApprovalDecisionResolvedEvent +import com.correx.core.events.events.ApprovalGrantCreatedEvent +import com.correx.core.events.events.ApprovalGrantExpiredEvent +import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.sessions.ApprovalMode + +class DefaultApprovalReducer : ApprovalReducer { + + override fun reduce(state: ApprovalState, event: StoredEvent): ApprovalState { + return when (val payload = event.payload) { + is ApprovalRequestedEvent -> { + val request = DomainApprovalRequest( + id = payload.requestId, + tier = payload.tier, + validationReportId = payload.validationReportId, + riskSummaryId = payload.riskSummaryId, + timestamp = event.metadata.timestamp, + causationId = event.metadata.causationId, + correlationId = event.metadata.correlationId, + userSteering = payload.userSteering + ) + state.copy(requests = state.requests + (request.id to request)) + } + + is ApprovalDecisionResolvedEvent -> { + val decisionId = payload.decisionId + // The event does not carry context info, so we reconstruct a partial identity + // with a default mode; stageId and projectId are unavailable at this point. + val contextSnapshot = ApprovalContext( + identity = ApprovalScopeIdentity( + sessionId = event.metadata.sessionId, + stageId = null, + projectId = null + ), + mode = ApprovalMode.PROMPT, + causationId = event.metadata.causationId, + correlationId = event.metadata.correlationId + ) + val decision = ApprovalDecision( + id = decisionId, + requestId = payload.requestId, + outcome = payload.outcome, + state = payload.status, + tier = payload.tier, + contextSnapshot = contextSnapshot, + resolutionTimestamp = payload.resolutionTimestamp, + reason = payload.reason, + userSteering = payload.userSteering + ) + state.copy(decisions = state.decisions + (decisionId to decision)) + } + + is ApprovalGrantCreatedEvent -> { + val grant = ApprovalGrant( + id = payload.grantId, + scope = payload.scope, + permittedTiers = payload.permittedTiers, + reason = payload.reason, + timestamp = event.metadata.timestamp, + expiresAt = payload.expiresAt + ) + state.copy(grants = state.grants + (grant.id to grant)) + } + + is ApprovalGrantExpiredEvent -> { + state.copy(grants = state.grants - payload.grantId) + } + + else -> state + } + } +} diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/DefaultApprovalRepository.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/DefaultApprovalRepository.kt new file mode 100644 index 00000000..ecdc8406 --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/DefaultApprovalRepository.kt @@ -0,0 +1,12 @@ +package com.correx.core.approvals + +import com.correx.core.approvals.model.ApprovalState +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.replay.EventReplayer + +class DefaultApprovalRepository( + private val replayer: EventReplayer +) { + fun getApprovalState(sessionId: SessionId): ApprovalState = + replayer.rebuild(sessionId) +} diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/ApprovalEngine.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/ApprovalEngine.kt new file mode 100644 index 00000000..55ca957a --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/ApprovalEngine.kt @@ -0,0 +1,16 @@ +package com.correx.core.approvals.domain + +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalDecision +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.DomainApprovalRequest +import kotlinx.datetime.Instant + +interface ApprovalEngine { + fun evaluate( + request: DomainApprovalRequest, + context: ApprovalContext, + grants: List, + now: Instant + ): ApprovalDecision +} diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt new file mode 100644 index 00000000..de785227 --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt @@ -0,0 +1,91 @@ +package com.correx.core.approvals.domain + +import com.correx.core.approvals.Tier +import com.correx.core.approvals.isAtMost +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalDecision +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.model.DomainApprovalRequest +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.GrantScope +import com.correx.core.events.types.ApprovalDecisionId +import com.correx.core.sessions.ApprovalMode +import kotlinx.datetime.Instant + +@Suppress("ReturnCount") +class DefaultApprovalEngine : ApprovalEngine { + + override fun evaluate( + request: DomainApprovalRequest, + context: ApprovalContext, + grants: List, + now: Instant + ): ApprovalDecision { + val decisionId = ApprovalDecisionId("decision:${request.id.value}") + + val matchingGrant = grants.firstOrNull { grant -> + !isExpired(grant, now) + && scopeMatches(grant.scope, context) + && request.tier in grant.permittedTiers + } + + if (matchingGrant != null) { + return approved(decisionId, request, context, now, reason = "grant:${matchingGrant.id.value}") + } + + if (context.mode == ApprovalMode.YOLO) { + return approved(decisionId, request, context, now, reason = null) + } + + val threshold = when (context.mode) { + ApprovalMode.DENY -> Tier.T0 + ApprovalMode.PROMPT -> Tier.T1 + ApprovalMode.AUTO -> Tier.T2 + ApprovalMode.YOLO -> error("unreachable") + } + + return if (request.tier.isAtMost(threshold)) { + approved(decisionId, request, context, now, reason = null) + } else { + ApprovalDecision( + id = decisionId, + requestId = request.id, + outcome = null, + state = ApprovalStatus.PENDING, + tier = request.tier, + contextSnapshot = context, + resolutionTimestamp = null, + reason = null, + userSteering = request.userSteering + ) + } + } + + private fun approved( + id: ApprovalDecisionId, + request: DomainApprovalRequest, + context: ApprovalContext, + now: Instant, + reason: String? + ) = ApprovalDecision( + id = id, + requestId = request.id, + outcome = ApprovalOutcome.AUTO_APPROVED, + state = ApprovalStatus.COMPLETED, + tier = request.tier, + contextSnapshot = context, + resolutionTimestamp = now, + reason = reason, + userSteering = request.userSteering + ) + + private fun isExpired(grant: ApprovalGrant, now: Instant): Boolean = + grant.expiresAt != null && grant.expiresAt <= now + + private fun scopeMatches(scope: GrantScope, context: ApprovalContext): Boolean = when (scope) { + is GrantScope.SESSION -> true + is GrantScope.STAGE -> context.identity.stageId == scope.stageId + is GrantScope.PROJECT -> context.identity.projectId == scope.projectId + } +} diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/NoOpApprovalEngine.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/NoOpApprovalEngine.kt new file mode 100644 index 00000000..2f2b0c5e --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/NoOpApprovalEngine.kt @@ -0,0 +1,33 @@ +package com.correx.core.approvals.domain + +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.Tier +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalDecision +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.DomainApprovalRequest +import kotlinx.datetime.Instant + +/** + * No-op implementation of [ApprovalEngine] used during deterministic replay. + * Replay reconstructs approval decisions from the event log — live approval + * evaluation must never run on the replay path. + */ +class NoOpApprovalEngine : ApprovalEngine { + override fun evaluate( + request: DomainApprovalRequest, + context: ApprovalContext, + grants: List, + now: Instant, + ): ApprovalDecision = ApprovalDecision( + id = null, + requestId = request.id, + outcome = ApprovalOutcome.AUTO_APPROVED, + state = ApprovalStatus.COMPLETED, + tier = Tier.T0, + contextSnapshot = context, + resolutionTimestamp = now, + reason = "replay — reconstructed from event log", + ) +} diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalContext.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalContext.kt new file mode 100644 index 00000000..4a6df3c5 --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalContext.kt @@ -0,0 +1,14 @@ +package com.correx.core.approvals.model + +import com.correx.core.events.types.CausationId +import com.correx.core.events.types.CorrelationId +import com.correx.core.sessions.ApprovalMode +import kotlinx.serialization.Serializable + +@Serializable +data class ApprovalContext( + val identity: ApprovalScopeIdentity, + val mode: ApprovalMode, + val causationId: CausationId? = null, + val correlationId: CorrelationId? = null +) diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalDecision.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalDecision.kt new file mode 100644 index 00000000..f9267a2f --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalDecision.kt @@ -0,0 +1,26 @@ +package com.correx.core.approvals.model + +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.Tier +import com.correx.core.approvals.UserSteering +import com.correx.core.events.types.ApprovalDecisionId +import com.correx.core.events.types.ApprovalRequestId +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class ApprovalDecision( + val id: ApprovalDecisionId?, + val requestId: ApprovalRequestId, + val outcome: ApprovalOutcome?, + val state: ApprovalStatus, + val tier: Tier, + val contextSnapshot: ApprovalContext, + val resolutionTimestamp: Instant?, + val reason: String?, + val userSteering: UserSteering? = null +) { + val isFinal: Boolean get() = state != ApprovalStatus.PENDING + val isApproved: Boolean get() = outcome == ApprovalOutcome.APPROVED || outcome == ApprovalOutcome.AUTO_APPROVED +} diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalGrant.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalGrant.kt new file mode 100644 index 00000000..2419bd5b --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalGrant.kt @@ -0,0 +1,17 @@ +package com.correx.core.approvals.model + +import com.correx.core.approvals.GrantScope +import com.correx.core.approvals.Tier +import com.correx.core.events.types.GrantId +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class ApprovalGrant( + val id: GrantId, + val scope: GrantScope, + val permittedTiers: Set, + val reason: String, + val timestamp: Instant, + val expiresAt: Instant? = null +) diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalScopeIdentity.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalScopeIdentity.kt new file mode 100644 index 00000000..65d41bed --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalScopeIdentity.kt @@ -0,0 +1,13 @@ +package com.correx.core.approvals.model + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.SessionId +import kotlinx.serialization.Serializable + +@Serializable +data class ApprovalScopeIdentity( + val sessionId: SessionId, + val stageId: StageId?, + val projectId: ProjectId? +) diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalState.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalState.kt new file mode 100644 index 00000000..c3695b5b --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalState.kt @@ -0,0 +1,13 @@ +package com.correx.core.approvals.model + +import com.correx.core.events.types.ApprovalDecisionId +import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.events.types.GrantId +import kotlinx.serialization.Serializable + +@Serializable +data class ApprovalState( + val requests: Map = emptyMap(), + val decisions: Map = emptyMap(), + val grants: Map = emptyMap() +) diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/model/DomainApprovalRequest.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/DomainApprovalRequest.kt new file mode 100644 index 00000000..922095f0 --- /dev/null +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/model/DomainApprovalRequest.kt @@ -0,0 +1,23 @@ +package com.correx.core.approvals.model + +import com.correx.core.approvals.Tier +import com.correx.core.approvals.UserSteering +import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.events.types.CausationId +import com.correx.core.events.types.CorrelationId +import com.correx.core.events.types.RiskSummaryId +import com.correx.core.events.types.ValidationReportId +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class DomainApprovalRequest( + val id: ApprovalRequestId, + val tier: Tier, + val validationReportId: ValidationReportId, + val riskSummaryId: RiskSummaryId?, + val timestamp: Instant, + val causationId: CausationId? = null, + val correlationId: CorrelationId? = null, + val userSteering: UserSteering? = null +) diff --git a/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalReducerTest.kt b/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalReducerTest.kt new file mode 100644 index 00000000..6fafd371 --- /dev/null +++ b/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalReducerTest.kt @@ -0,0 +1,138 @@ +package com.correx.core.approvals + +import com.correx.core.approvals.model.ApprovalState +import com.correx.core.events.events.ApprovalDecisionResolvedEvent +import com.correx.core.events.events.ApprovalGrantCreatedEvent +import com.correx.core.events.events.ApprovalGrantExpiredEvent +import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolInvokedEvent +import com.correx.core.events.types.ApprovalDecisionId +import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.events.types.EventId +import com.correx.core.events.types.GrantId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.ValidationReportId +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DefaultApprovalReducerTest { + + private val reducer = DefaultApprovalReducer() + + private val timestamp = Instant.parse("2026-01-01T00:00:00Z") + private val sessionId = SessionId("session-1") + + private fun storedEvent(payload: com.correx.core.events.events.EventPayload, sequence: Long = 1L): StoredEvent { + return StoredEvent( + metadata = EventMetadata( + eventId = EventId("event-$sequence"), + sessionId = sessionId, + timestamp = timestamp, + schemaVersion = 1, + causationId = null, + correlationId = null + ), + sequence = sequence, + payload = payload + ) + } + + @Test + fun `ApprovalRequestedEvent adds request to state`() { + val requestId = ApprovalRequestId("req-1") + val payload = ApprovalRequestedEvent( + requestId = requestId, + tier = Tier.T2, + validationReportId = ValidationReportId("report-1"), + riskSummaryId = null, + sessionId = sessionId, + stageId = null, + projectId = null + ) + + val state = reducer.reduce(ApprovalState(), storedEvent(payload)) + + assertTrue(state.requests.containsKey(requestId)) + assertEquals(requestId, state.requests[requestId]?.id) + assertEquals(Tier.T2, state.requests[requestId]?.tier) + } + + @Test + fun `ApprovalGrantCreatedEvent adds grant to state`() { + val grantId = GrantId("grant-1") + val payload = ApprovalGrantCreatedEvent( + grantId = grantId, + scope = GrantScope.SESSION, + permittedTiers = setOf(Tier.T1, Tier.T2), + reason = "user approved", + expiresAt = null, + sessionId = sessionId, + stageId = null, + projectId = null + ) + + val state = reducer.reduce(ApprovalState(), storedEvent(payload)) + + assertTrue(state.grants.containsKey(grantId)) + assertEquals(grantId, state.grants[grantId]?.id) + assertEquals(GrantScope.SESSION, state.grants[grantId]?.scope) + } + + @Test + fun `ApprovalGrantExpiredEvent removes grant from state`() { + val grantId = GrantId("grant-2") + + val addPayload = ApprovalGrantCreatedEvent( + grantId = grantId, + scope = GrantScope.SESSION, + permittedTiers = setOf(Tier.T1), + reason = "granted", + expiresAt = null, + sessionId = sessionId, + stageId = null, + projectId = null + ) + val removePayload = ApprovalGrantExpiredEvent(grantId = grantId) + + val stateAfterAdd = reducer.reduce(ApprovalState(), storedEvent(addPayload, sequence = 1L)) + assertTrue(stateAfterAdd.grants.containsKey(grantId)) + + val stateAfterRemove = reducer.reduce(stateAfterAdd, storedEvent(removePayload, sequence = 2L)) + assertFalse(stateAfterRemove.grants.containsKey(grantId)) + } + + @Test + fun `ApprovalDecisionResolvedEvent adds decision to state`() { + val decisionId = ApprovalDecisionId("decision-1") + val requestId = ApprovalRequestId("req-1") + val payload = ApprovalDecisionResolvedEvent( + decisionId = decisionId, + requestId = requestId, + outcome = ApprovalOutcome.APPROVED, + status = ApprovalStatus.COMPLETED, + tier = Tier.T2, + resolutionTimestamp = timestamp, + reason = null + ) + + val state = reducer.reduce(ApprovalState(), storedEvent(payload)) + + assertTrue(state.decisions.containsKey(decisionId)) + assertEquals(requestId, state.decisions[decisionId]?.requestId) + assertEquals(ApprovalOutcome.APPROVED, state.decisions[decisionId]?.outcome) + } + + @Test + fun `unknown events pass state through unchanged`() { + val initial = ApprovalState() + val state = reducer.reduce(initial, storedEvent(ToolInvokedEvent("tool"))) + assertEquals(initial, state) + } +} diff --git a/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalRepositoryTest.kt b/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalRepositoryTest.kt new file mode 100644 index 00000000..ab833c81 --- /dev/null +++ b/core/approvals/src/test/kotlin/com/correx/core/approvals/DefaultApprovalRepositoryTest.kt @@ -0,0 +1,99 @@ +package com.correx.core.approvals + +import com.correx.core.events.events.ApprovalGrantCreatedEvent +import com.correx.core.events.events.ApprovalGrantExpiredEvent +import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.NewEvent +import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.GrantId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.ValidationReportId +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.infrastructure.persistence.InMemoryEventStore +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DefaultApprovalRepositoryTest { + + private val store = InMemoryEventStore() + private val replayer = DefaultEventReplayer(store, ApprovalProjector(DefaultApprovalReducer())) + private val repository = DefaultApprovalRepository(replayer) + + private val timestamp = Instant.parse("2026-01-01T00:00:00Z") + + private fun metadata(sessionId: SessionId, eventId: String): EventMetadata = EventMetadata( + eventId = EventId(eventId), + sessionId = sessionId, + timestamp = timestamp, + schemaVersion = 1, + causationId = null, + correlationId = null + ) + + @Test + fun `unknown session returns empty ApprovalState`() { + val state = repository.getApprovalState(SessionId("unknown-session")) + assertEquals(com.correx.core.approvals.model.ApprovalState(), state) + } + + @Test + fun `after ApprovalRequestedEvent state contains the request`() { + val sessionId = SessionId("session-requested") + val requestId = ApprovalRequestId("req-1") + + store.append( + NewEvent( + metadata = metadata(sessionId, "event-1"), + payload = ApprovalRequestedEvent( + requestId = requestId, + tier = Tier.T2, + validationReportId = ValidationReportId("report-1"), + riskSummaryId = null, + sessionId = sessionId, + stageId = null, + projectId = null + ) + ) + ) + + val state = repository.getApprovalState(sessionId) + assertEquals(1, state.requests.size) + assertTrue(state.requests.containsKey(requestId)) + } + + @Test + fun `after ApprovalGrantCreatedEvent then ApprovalGrantExpiredEvent grants list is empty`() { + val sessionId = SessionId("session-grant-expired") + val grantId = GrantId("grant-1") + + store.append( + NewEvent( + metadata = metadata(sessionId, "event-1"), + payload = ApprovalGrantCreatedEvent( + grantId = grantId, + scope = GrantScope.SESSION, + permittedTiers = setOf(Tier.T1, Tier.T2), + reason = "approved", + expiresAt = null, + sessionId = sessionId, + stageId = null, + projectId = null + ) + ) + ) + + store.append( + NewEvent( + metadata = metadata(sessionId, "event-2"), + payload = ApprovalGrantExpiredEvent(grantId = grantId) + ) + ) + + val state = repository.getApprovalState(sessionId) + assertTrue(state.grants.isEmpty()) + } +} diff --git a/core/artifacts/build.gradle b/core/artifacts/build.gradle new file mode 100644 index 00000000..29c06aaa --- /dev/null +++ b/core/artifacts/build.gradle @@ -0,0 +1,9 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) +} diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/ArtifactProjector.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/ArtifactProjector.kt new file mode 100644 index 00000000..6b8fdeff --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/ArtifactProjector.kt @@ -0,0 +1,24 @@ +package com.correx.core.artifacts + +import com.correx.core.events.events.StoredEvent +import com.correx.core.sessions.projections.Projection +import java.util.logging.Logger + +class ArtifactProjector(private val reducer: ArtifactReducer) : Projection { + + private val logger: Logger = Logger.getLogger(ArtifactProjector::class.java.name) + + override fun initial(): ArtifactState = ArtifactState() + + // Invalid lifecycle transitions must not crash the replayer. Log and leave state unchanged + // so replay continues; the event log remains the source of truth. + override fun apply(state: ArtifactState, event: StoredEvent): ArtifactState = + reducer.reduce(state, event).getOrElse { exception -> + if (exception !is IllegalStateException) throw exception + logger.warning( + "Invalid artifact lifecycle transition (event payload=${event.payload::class.simpleName}): " + + "${exception.message}. State unchanged." + ) + state + } +} diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/ArtifactReducer.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/ArtifactReducer.kt new file mode 100644 index 00000000..7cd4d75e --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/ArtifactReducer.kt @@ -0,0 +1,77 @@ +package com.correx.core.artifacts + +import com.correx.core.artifacts.model.ArtifactRelationship +import com.correx.core.events.events.ArtifactArchivedEvent +import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.events.ArtifactRejectedEvent +import com.correx.core.events.events.ArtifactRelationshipAddedEvent +import com.correx.core.events.events.ArtifactSupersededEvent +import com.correx.core.events.events.ArtifactValidatedEvent +import com.correx.core.events.events.ArtifactValidatingEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.types.ArtifactLifecyclePhase + +interface ArtifactReducer { + fun reduce(state: ArtifactState, event: StoredEvent): Result +} + +class DefaultArtifactReducer : ArtifactReducer { + + override fun reduce(state: ArtifactState, event: StoredEvent): Result = + when (val p = event.payload) { + is ArtifactCreatedEvent -> + Result.success(state.copy(phase = ArtifactLifecyclePhase.CREATED)) + is ArtifactValidatingEvent -> + transition(state, ArtifactLifecyclePhase.VALIDATING, ArtifactLifecyclePhase.CREATED) + is ArtifactValidatedEvent -> + transition(state, ArtifactLifecyclePhase.VALIDATED, ArtifactLifecyclePhase.VALIDATING) + is ArtifactRejectedEvent -> + transition(state, ArtifactLifecyclePhase.REJECTED, ArtifactLifecyclePhase.VALIDATING) + is ArtifactSupersededEvent -> + transition(state, ArtifactLifecyclePhase.SUPERSEDED, ArtifactLifecyclePhase.VALIDATED) + is ArtifactArchivedEvent -> + transitionFromAny( + state, + ArtifactLifecyclePhase.ARCHIVED, + ArtifactLifecyclePhase.VALIDATED, + ArtifactLifecyclePhase.REJECTED, + ) + is ArtifactRelationshipAddedEvent -> { + val rel = ArtifactRelationship(p.sourceId, p.targetId, p.relationshipType) + Result.success( + state.copy(lineage = state.lineage.copy(relationships = state.lineage.relationships + rel)) + ) + } + else -> Result.success(state) + } + + private fun transition( + state: ArtifactState, + to: ArtifactLifecyclePhase, + from: ArtifactLifecyclePhase, + ): Result = + if (state.phase == from) { + Result.success(state.copy(phase = to)) + } else { + Result.failure( + IllegalStateException( + "Invalid artifact transition: ${state.phase} → $to (expected $from)" + ) + ) + } + + private fun transitionFromAny( + state: ArtifactState, + to: ArtifactLifecyclePhase, + vararg validFrom: ArtifactLifecyclePhase, + ): Result = + if (state.phase in validFrom) { + Result.success(state.copy(phase = to)) + } else { + Result.failure( + IllegalStateException( + "Invalid artifact transition: ${state.phase} → $to (expected one of ${validFrom.toList()})" + ) + ) + } +} diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/ArtifactState.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/ArtifactState.kt new file mode 100644 index 00000000..4d845e38 --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/ArtifactState.kt @@ -0,0 +1,11 @@ +package com.correx.core.artifacts + +import com.correx.core.artifacts.model.ArtifactLineage +import com.correx.core.events.types.ArtifactLifecyclePhase +import kotlinx.serialization.Serializable + +@Serializable +data class ArtifactState( + val phase: ArtifactLifecyclePhase = ArtifactLifecyclePhase.CREATED, + val lineage: ArtifactLineage = ArtifactLineage() +) diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/Module.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/Module.kt new file mode 100644 index 00000000..dc7696da --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/Module.kt @@ -0,0 +1,3 @@ +package com.correx.core.artifacts + +object Module diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/model/Artifact.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/model/Artifact.kt new file mode 100644 index 00000000..d9bc052e --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/model/Artifact.kt @@ -0,0 +1,20 @@ +package com.correx.core.artifacts.model + +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.ArtifactLifecyclePhase +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +sealed interface Artifact { + val id: ArtifactId + val sessionId: SessionId + val stageId: StageId + val schemaVersion: Int + val createdAt: Instant + val lifecyclePhase: ArtifactLifecyclePhase + val lineage: ArtifactLineage + val metadata: Map +} diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/model/ArtifactLineage.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/model/ArtifactLineage.kt new file mode 100644 index 00000000..690bcf8b --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/model/ArtifactLineage.kt @@ -0,0 +1,12 @@ +package com.correx.core.artifacts.model + +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.ValidationReportId +import kotlinx.serialization.Serializable + +@Serializable +data class ArtifactLineage( + val parentIds: List = emptyList(), + val relationships: List = emptyList(), + val validationReportIds: List = emptyList() +) diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/model/ArtifactRelationship.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/model/ArtifactRelationship.kt new file mode 100644 index 00000000..a57662e3 --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/model/ArtifactRelationship.kt @@ -0,0 +1,12 @@ +package com.correx.core.artifacts.model + +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.ArtifactRelationshipType +import kotlinx.serialization.Serializable + +@Serializable +data class ArtifactRelationship( + val sourceId: ArtifactId, + val targetId: ArtifactId, + val type: ArtifactRelationshipType +) diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/serialization/ArtifactSerializationModule.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/serialization/ArtifactSerializationModule.kt new file mode 100644 index 00000000..d892383f --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/serialization/ArtifactSerializationModule.kt @@ -0,0 +1,11 @@ +package com.correx.core.artifacts.serialization + +import com.correx.core.artifacts.model.Artifact +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic + +val artifactModule: SerializersModule = SerializersModule { + polymorphic(Artifact::class) { + // concrete artifact subclasses registered here in future epics + } +} diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 00000000..eb784cf9 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,3 @@ +subprojects { + group = "com.correx.core" +} \ No newline at end of file diff --git a/core/config/build.gradle b/core/config/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/core/config/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/core/config/src/main/kotlin/com/correx/core/config/Module.kt b/core/config/src/main/kotlin/com/correx/core/config/Module.kt new file mode 100644 index 00000000..b624861d --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/Module.kt @@ -0,0 +1,3 @@ +package com.correx.core.config + +object Module diff --git a/core/context/build.gradle b/core/context/build.gradle new file mode 100644 index 00000000..4dcdba22 --- /dev/null +++ b/core/context/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + implementation(project(":core:artifacts")) + implementation(project(":core:sessions")) +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/ContextProjector.kt b/core/context/src/main/kotlin/com/correx/core/context/ContextProjector.kt new file mode 100644 index 00000000..573267ab --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/ContextProjector.kt @@ -0,0 +1,13 @@ +package com.correx.core.context + +import com.correx.core.context.state.ContextState +import com.correx.core.events.events.StoredEvent +import com.correx.core.sessions.projections.Projection + +class ContextProjector( + private val reducer: ContextReducer +) : Projection { + override fun initial(): ContextState = ContextState() + override fun apply(state: ContextState, event: StoredEvent): ContextState = + reducer.reduce(state, event) +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/ContextReducer.kt b/core/context/src/main/kotlin/com/correx/core/context/ContextReducer.kt new file mode 100644 index 00000000..91728229 --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/ContextReducer.kt @@ -0,0 +1,8 @@ +package com.correx.core.context + +import com.correx.core.context.state.ContextState +import com.correx.core.events.events.StoredEvent + +interface ContextReducer { + fun reduce(state: ContextState, event: StoredEvent): ContextState +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/DefaultContextReducer.kt b/core/context/src/main/kotlin/com/correx/core/context/DefaultContextReducer.kt new file mode 100644 index 00000000..130f2bbb --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/DefaultContextReducer.kt @@ -0,0 +1,23 @@ +package com.correx.core.context + +import com.correx.core.context.state.ContextState +import com.correx.core.events.events.ContextBuildingFailedEvent +import com.correx.core.events.events.ContextBuildingInterruptedEvent +import com.correx.core.events.events.ContextBuildingStartedEvent +import com.correx.core.events.events.ContextPackBuiltEvent +import com.correx.core.events.events.StoredEvent + +class DefaultContextReducer : ContextReducer { + override fun reduce(state: ContextState, event: StoredEvent): ContextState = + when (val p = event.payload) { + is ContextBuildingStartedEvent -> state.copy(buildingInProgress = true, interrupted = false) + is ContextPackBuiltEvent -> state.copy( + buildingInProgress = false, + interrupted = false, + builtPackIds = state.builtPackIds + p.contextPackId, + ) + is ContextBuildingFailedEvent -> state.copy(buildingInProgress = false, interrupted = false) + is ContextBuildingInterruptedEvent -> state.copy(buildingInProgress = false, interrupted = true) + else -> state + } +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/DefaultContextRepository.kt b/core/context/src/main/kotlin/com/correx/core/context/DefaultContextRepository.kt new file mode 100644 index 00000000..69ea979b --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/DefaultContextRepository.kt @@ -0,0 +1,12 @@ +package com.correx.core.context + +import com.correx.core.context.state.ContextState +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.replay.EventReplayer + +class DefaultContextRepository( + private val replayer: EventReplayer +) { + fun getContextState(sessionId: SessionId): ContextState = + replayer.rebuild(sessionId) +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/Module.kt b/core/context/src/main/kotlin/com/correx/core/context/Module.kt new file mode 100644 index 00000000..eb314a1c --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/Module.kt @@ -0,0 +1,3 @@ +package com.correx.core.context + +object Module diff --git a/core/context/src/main/kotlin/com/correx/core/context/builder/ContextPackBuilder.kt b/core/context/src/main/kotlin/com/correx/core/context/builder/ContextPackBuilder.kt new file mode 100644 index 00000000..ea4a06b7 --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/builder/ContextPackBuilder.kt @@ -0,0 +1,18 @@ +package com.correx.core.context.builder + +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.ContextPack +import com.correx.core.context.model.TokenBudget +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId + +interface ContextPackBuilder { + fun build( + id: ContextPackId, + sessionId: SessionId, + stageId: StageId, + entries: List, + budget: TokenBudget + ): ContextPack +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/builder/DecisionPointBuilder.kt b/core/context/src/main/kotlin/com/correx/core/context/builder/DecisionPointBuilder.kt new file mode 100644 index 00000000..aac8ff8e --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/builder/DecisionPointBuilder.kt @@ -0,0 +1,8 @@ +package com.correx.core.context.builder + +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.ContextPack + +interface DecisionPointBuilder { + fun build(pack: ContextPack): List +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt b/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt new file mode 100644 index 00000000..ad5091ad --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt @@ -0,0 +1,76 @@ +package com.correx.core.context.builder + +import com.correx.core.context.compression.CompressionStrategy +import com.correx.core.context.compression.ContextCompressor +import com.correx.core.context.model.CompressionMetadata +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.ContextPack +import com.correx.core.context.model.TokenBudget +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId + +class DefaultContextPackBuilder( + private val compressor: ContextCompressor +) : ContextPackBuilder { + + private val neverDropSourceTypes = setOf("steeringNote", "eventHistory") + + override fun build( + id: ContextPackId, + sessionId: SessionId, + stageId: StageId, + entries: List, + budget: TokenBudget + ): ContextPack { + val (pinned, compressible) = entries.partition { it.sourceType in neverDropSourceTypes } + val pinnedTokens = pinned.sumOf { it.tokenEstimate } + var remainingTokens = (budget.limit - pinnedTokens).coerceAtLeast(0) + + // Dispatch compression strategy by entry sourceType — applying Conversation + // strategy uniformly mangles tool logs and artifacts (their shape isn't conversational). + val strategiesUsed = linkedSetOf() + val compressed = compressible + .groupBy { it.sourceType } + .flatMap { (sourceType, group) -> + val strategy = strategyFor(sourceType) + strategiesUsed += strategy::class.simpleName ?: "Unknown" + val groupBudget = TokenBudget(limit = remainingTokens.coerceAtLeast(0)) + val result = compressor.compress(group, groupBudget, strategy) + remainingTokens -= result.sumOf { it.tokenEstimate } + result + } + + val retained = pinned + compressed + val layers = retained.groupBy { it.layer } + val budgetUsed = retained.sumOf { it.tokenEstimate } + val droppedCount = entries.size - retained.size + val truncatedLayers = if (droppedCount > 0) { + entries.map { it.layer }.distinct().filter { layer -> + entries.count { it.layer == layer } > retained.count { it.layer == layer } + } + } else emptyList() + + return ContextPack( + id = id, + sessionId = sessionId, + stageId = stageId, + layers = layers, + budgetUsed = budgetUsed, + budgetLimit = budget.limit, + compressionMetadata = CompressionMetadata( + appliedStrategies = strategiesUsed.toList().ifEmpty { listOf("Conversation") }, + truncatedLayers = truncatedLayers, + entriesDropped = droppedCount + ) + ) + } + + private fun strategyFor(sourceType: String): CompressionStrategy = when (sourceType) { + "toolLog" -> CompressionStrategy.ToolLog + "artifact" -> CompressionStrategy.Artifact + "steeringNote" -> CompressionStrategy.SteeringNote + "eventHistory" -> CompressionStrategy.EventHistory + else -> CompressionStrategy.Conversation() + } +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultDecisionPointBuilder.kt b/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultDecisionPointBuilder.kt new file mode 100644 index 00000000..51eac82c --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultDecisionPointBuilder.kt @@ -0,0 +1,14 @@ +package com.correx.core.context.builder + +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.ContextLayer +import com.correx.core.context.model.ContextPack + +class DefaultDecisionPointBuilder : DecisionPointBuilder { + // L0 (live execution) and L1 (stage-local) are the only layers sent to the model for inference. + // L2+ are too large or already compressed into DecisionPoints. + private val inferenceLayers = setOf(ContextLayer.L0, ContextLayer.L1) + + override fun build(pack: ContextPack): List = + inferenceLayers.flatMap { layer -> pack.layers[layer] ?: emptyList() } +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/compression/CompressionStrategy.kt b/core/context/src/main/kotlin/com/correx/core/context/compression/CompressionStrategy.kt new file mode 100644 index 00000000..891e0ddb --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/compression/CompressionStrategy.kt @@ -0,0 +1,14 @@ +package com.correx.core.context.compression + +sealed interface CompressionStrategy { + // Tool log entries: deduplicate identical content, drop oldest when over budget + object ToolLog : CompressionStrategy + // Conversation turns: retain the last `keepLast` entries verbatim, drop oldest first when over budget. Default: 10. + data class Conversation(val keepLast: Int = 10) : CompressionStrategy + // Artifact entries: keep most recent entry per sourceId + object Artifact : CompressionStrategy + // Steering notes: always retained verbatim, never dropped + object SteeringNote : CompressionStrategy + // Event history DecisionPoints: always retained, already compressed facts + object EventHistory : CompressionStrategy +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/compression/ContextCompressor.kt b/core/context/src/main/kotlin/com/correx/core/context/compression/ContextCompressor.kt new file mode 100644 index 00000000..79c2c81f --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/compression/ContextCompressor.kt @@ -0,0 +1,12 @@ +package com.correx.core.context.compression + +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.TokenBudget + +interface ContextCompressor { + fun compress( + entries: List, + budget: TokenBudget, + strategy: CompressionStrategy + ): List +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/compression/DefaultContextCompressor.kt b/core/context/src/main/kotlin/com/correx/core/context/compression/DefaultContextCompressor.kt new file mode 100644 index 00000000..8c1e57b8 --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/compression/DefaultContextCompressor.kt @@ -0,0 +1,63 @@ +package com.correx.core.context.compression + +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.ContextLayer +import com.correx.core.context.model.TokenBudget + +class DefaultContextCompressor : ContextCompressor { + + // L0 (live execution), L3 (durable project memory), and L4 (archival) are never dropped — only L2 then L1. + private val dropOrder = listOf(ContextLayer.L2, ContextLayer.L1) + + override fun compress( + entries: List, + budget: TokenBudget, + strategy: CompressionStrategy + ): List = when (strategy) { + CompressionStrategy.SteeringNote, + CompressionStrategy.EventHistory -> entries + CompressionStrategy.Artifact -> compressArtifacts(entries, budget) + CompressionStrategy.ToolLog -> compressToolLogs(entries, budget) + is CompressionStrategy.Conversation -> compressConversation(entries, budget, strategy.keepLast) + } + + // Keeps the last `keepLast` entries (oldest-first order preserved), then enforces budget. + // L0 is never truncated — overflow removes L1 from oldest first. + private fun compressConversation( + entries: List, + budget: TokenBudget, + keepLast: Int + ): List { + val kept = if (entries.size > keepLast) entries.takeLast(keepLast) else entries + return trimToFit(kept, budget) + } + + private fun compressArtifacts(entries: List, budget: TokenBudget): List { + val deduplicated = entries + .groupBy { it.sourceId } + .map { (_, group) -> group.last() } + return trimToFit(deduplicated, budget) + } + + private fun compressToolLogs(entries: List, budget: TokenBudget): List { + val deduplicated = entries.distinctBy { it.content } + return trimToFit(deduplicated, budget) + } + + private fun trimToFit(entries: List, budget: TokenBudget): List { + var total = entries.sumOf { it.tokenEstimate } + if (total <= budget.limit) return entries + + val mutable = entries.toMutableList() + for (layer in dropOrder) { + if (total <= budget.limit) break + val layerEntries = mutable.filter { it.layer == layer } + for (entry in layerEntries) { + if (total <= budget.limit) break + mutable.remove(entry) + total -= entry.tokenEstimate + } + } + return mutable + } +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/model/CompressionMetadata.kt b/core/context/src/main/kotlin/com/correx/core/context/model/CompressionMetadata.kt new file mode 100644 index 00000000..3d2a5f46 --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/model/CompressionMetadata.kt @@ -0,0 +1,10 @@ +package com.correx.core.context.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CompressionMetadata( + val appliedStrategies: List = emptyList(), + val truncatedLayers: List = emptyList(), + val entriesDropped: Int = 0 +) diff --git a/core/context/src/main/kotlin/com/correx/core/context/model/ContextEntry.kt b/core/context/src/main/kotlin/com/correx/core/context/model/ContextEntry.kt new file mode 100644 index 00000000..27d76dbf --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/model/ContextEntry.kt @@ -0,0 +1,14 @@ +package com.correx.core.context.model + +import com.correx.core.events.types.ContextEntryId +import kotlinx.serialization.Serializable + +@Serializable +data class ContextEntry( + val id: ContextEntryId, + val layer: ContextLayer, + val content: String, + val sourceType: String, + val sourceId: String, + val tokenEstimate: Int +) diff --git a/core/context/src/main/kotlin/com/correx/core/context/model/ContextLayer.kt b/core/context/src/main/kotlin/com/correx/core/context/model/ContextLayer.kt new file mode 100644 index 00000000..c8a5aeac --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/model/ContextLayer.kt @@ -0,0 +1,12 @@ +package com.correx.core.context.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class ContextLayer { + L0, // live execution: current stage inputs/outputs + L1, // stage-local: recent artifacts and events within this stage + L2, // compressed session memory: summarised prior stages + L3, // durable project memory: cross-session retained context + L4 // archival history: stored externally, referenced by ID only +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/model/ContextPack.kt b/core/context/src/main/kotlin/com/correx/core/context/model/ContextPack.kt new file mode 100644 index 00000000..9872edfb --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/model/ContextPack.kt @@ -0,0 +1,17 @@ +package com.correx.core.context.model + +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.Serializable + +@Serializable +data class ContextPack( + val id: ContextPackId, + val sessionId: SessionId, + val stageId: StageId, + val layers: Map> = emptyMap(), + val budgetUsed: Int, + val budgetLimit: Int, + val compressionMetadata: CompressionMetadata = CompressionMetadata() +) diff --git a/core/context/src/main/kotlin/com/correx/core/context/model/TokenBudget.kt b/core/context/src/main/kotlin/com/correx/core/context/model/TokenBudget.kt new file mode 100644 index 00000000..e4f4c863 --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/model/TokenBudget.kt @@ -0,0 +1,12 @@ +package com.correx.core.context.model + +data class TokenBudget( + val limit: Int, + val used: Int = 0 +) { + val remaining: Int get() = limit - used + + fun consume(tokens: Int): TokenBudget = copy(used = used + tokens) + + fun canFit(tokens: Int): Boolean = tokens <= remaining +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/serialization/ContextSerializationModule.kt b/core/context/src/main/kotlin/com/correx/core/context/serialization/ContextSerializationModule.kt new file mode 100644 index 00000000..d3b90b97 --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/serialization/ContextSerializationModule.kt @@ -0,0 +1,11 @@ +package com.correx.core.context.serialization + +import com.correx.core.context.model.ContextPack +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic + +val contextModule: SerializersModule = SerializersModule { + polymorphic(ContextPack::class) { + // no subclasses — ContextPack is a concrete data class + } +} diff --git a/core/context/src/main/kotlin/com/correx/core/context/state/ContextState.kt b/core/context/src/main/kotlin/com/correx/core/context/state/ContextState.kt new file mode 100644 index 00000000..356e23b6 --- /dev/null +++ b/core/context/src/main/kotlin/com/correx/core/context/state/ContextState.kt @@ -0,0 +1,11 @@ +package com.correx.core.context.state + +import com.correx.core.events.types.ContextPackId +import kotlinx.serialization.Serializable + +@Serializable +data class ContextState( + val builtPackIds: List = emptyList(), + val buildingInProgress: Boolean = false, + val interrupted: Boolean = false, +) diff --git a/core/events/build.gradle b/core/events/build.gradle new file mode 100644 index 00000000..228057df --- /dev/null +++ b/core/events/build.gradle @@ -0,0 +1,9 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation "org.jetbrains.kotlinx:kotlinx-datetime" +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/ApprovalOutcome.kt b/core/events/src/main/kotlin/com/correx/core/approvals/ApprovalOutcome.kt new file mode 100644 index 00000000..fd9b3cf0 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/approvals/ApprovalOutcome.kt @@ -0,0 +1,10 @@ +package com.correx.core.approvals + +import kotlinx.serialization.Serializable + +@Serializable +enum class ApprovalOutcome { + APPROVED, + REJECTED, + AUTO_APPROVED, +} diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/ApprovalStatus.kt b/core/events/src/main/kotlin/com/correx/core/approvals/ApprovalStatus.kt new file mode 100644 index 00000000..4b3249de --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/approvals/ApprovalStatus.kt @@ -0,0 +1,10 @@ +package com.correx.core.approvals + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface ApprovalStatus { + @Serializable data object PENDING : ApprovalStatus + @Serializable data object COMPLETED : ApprovalStatus + @Serializable data object TIMED_OUT : ApprovalStatus +} diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt b/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt new file mode 100644 index 00000000..840e5e8b --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt @@ -0,0 +1,12 @@ +package com.correx.core.approvals + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.StageId +import kotlinx.serialization.Serializable + +@Serializable +sealed interface GrantScope { + @Serializable data object SESSION : GrantScope + @Serializable data class STAGE(val stageId: StageId) : GrantScope + @Serializable data class PROJECT(val projectId: ProjectId) : GrantScope +} diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt b/core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt new file mode 100644 index 00000000..7af1ce2e --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt @@ -0,0 +1,16 @@ +package com.correx.core.approvals + +import kotlinx.serialization.Serializable + +@Suppress("MagicNumber") +@Serializable +enum class Tier(val level: Int) { + T0(0), + T1(1), + T2(2), + T3(3), + T4(4) +} + +fun Tier.isAtLeast(other: Tier): Boolean = this.level >= other.level +fun Tier.isAtMost(other: Tier): Boolean = this.level <= other.level diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/UserSteering.kt b/core/events/src/main/kotlin/com/correx/core/approvals/UserSteering.kt new file mode 100644 index 00000000..8eddb93e --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/approvals/UserSteering.kt @@ -0,0 +1,12 @@ +package com.correx.core.approvals + +import com.correx.core.events.types.SessionId +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class UserSteering( + val text: String, + val sessionId: SessionId, + val timestamp: Instant +) diff --git a/core/events/src/main/kotlin/com/correx/core/events/EventDispatcher.kt b/core/events/src/main/kotlin/com/correx/core/events/EventDispatcher.kt new file mode 100644 index 00000000..0a7c5420 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/EventDispatcher.kt @@ -0,0 +1,34 @@ +package com.correx.core.events + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.NewEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.CausationId +import com.correx.core.events.types.CorrelationId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import kotlinx.datetime.Clock +import java.util.UUID + +class EventDispatcher(private val eventStore: EventStore) { + suspend fun emit( + payload: EventPayload, + sessionId: SessionId, + causationId: CausationId? = null, + correlationId: CorrelationId? = null, + ) { + val event = NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = causationId, + correlationId = correlationId, + ), + payload = payload + ) + eventStore.append(event) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ApprovalEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ApprovalEvents.kt new file mode 100644 index 00000000..8223d9d6 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ApprovalEvents.kt @@ -0,0 +1,58 @@ +package com.correx.core.events.events + +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.GrantScope +import com.correx.core.approvals.Tier +import com.correx.core.approvals.UserSteering +import com.correx.core.events.types.ApprovalDecisionId +import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.events.types.GrantId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.RiskSummaryId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ValidationReportId +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class ApprovalRequestedEvent( + val requestId: ApprovalRequestId, + val tier: Tier, + val validationReportId: ValidationReportId, + val riskSummaryId: RiskSummaryId?, + val sessionId: SessionId, + val stageId: StageId?, + val projectId: ProjectId?, + val userSteering: UserSteering? = null +) : EventPayload + +@Serializable +data class ApprovalDecisionResolvedEvent( + val decisionId: ApprovalDecisionId, + val requestId: ApprovalRequestId, + val outcome: ApprovalOutcome, + val status: ApprovalStatus, + val tier: Tier, + val resolutionTimestamp: Instant, + val reason: String?, + val userSteering: UserSteering? = null +) : EventPayload + +@Serializable +data class ApprovalGrantCreatedEvent( + val grantId: GrantId, + val scope: GrantScope, + val permittedTiers: Set, + val reason: String, + val expiresAt: Instant?, + val sessionId: SessionId, + val stageId: StageId?, + val projectId: ProjectId? +) : EventPayload + +@Serializable +data class ApprovalGrantExpiredEvent( + val grantId: GrantId +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ArtifactEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ArtifactEvents.kt new file mode 100644 index 00000000..cc152e76 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ArtifactEvents.kt @@ -0,0 +1,60 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.ArtifactRelationshipType +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.Serializable + +@Serializable +data class ArtifactValidatingEvent( + val artifactId: ArtifactId, + val sessionId: SessionId, + val stageId: StageId, +) : EventPayload + +@Serializable +data class ArtifactValidatedEvent( + val artifactId: ArtifactId, + val sessionId: SessionId, + val stageId: StageId, +) : EventPayload + +@Serializable +data class ArtifactSupersededEvent( + val artifactId: ArtifactId, + val supersededById: ArtifactId, + val sessionId: SessionId, + val stageId: StageId, +) : EventPayload + +@Serializable +data class ArtifactRelationshipAddedEvent( + val sourceId: ArtifactId, + val targetId: ArtifactId, + val relationshipType: ArtifactRelationshipType, + val sessionId: SessionId, +) : EventPayload + +@Serializable +data class ArtifactRejectedEvent( + val artifactId: ArtifactId, + val sessionId: SessionId, + val stageId: StageId, + val reason: String, +) : EventPayload + +@Serializable +data class ArtifactCreatedEvent( + val artifactId: ArtifactId, + val sessionId: SessionId, + val stageId: StageId, + val schemaVersion: Int, +) : EventPayload + +@Serializable +data class ArtifactArchivedEvent( + val artifactId: ArtifactId, + val sessionId: SessionId, + val stageId: StageId, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ContextEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ContextEvents.kt new file mode 100644 index 00000000..c1ef855e --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ContextEvents.kt @@ -0,0 +1,51 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.Serializable + +@Serializable +data class LayerTruncatedEvent( + val contextPackId: ContextPackId, + val layer: String, + val entriesDropped: Int, + val reason: String, +) : EventPayload + +@Serializable +data class ContextBuildingStartedEvent( + val sessionId: SessionId, + val stageId: StageId, +) : EventPayload + +@Serializable +data class ContextBuildingFailedEvent( + val sessionId: SessionId, + val stageId: StageId, + val reason: String, +) : EventPayload + +// Emitted during replay when a session ended with buildingInProgress=true and no completion/failure event followed. +@Serializable +data class ContextBuildingInterruptedEvent( + val sessionId: SessionId, + val stageId: StageId, +) : EventPayload + +@Serializable +data class CompressionAppliedEvent( + val contextPackId: ContextPackId, + val layer: String, + val entriesRemoved: Int, + val strategyApplied: String, +) : EventPayload + +@Serializable +data class ContextPackBuiltEvent( + val contextPackId: ContextPackId, + val sessionId: SessionId, + val stageId: StageId, + val budgetUsed: Int, + val budgetLimit: Int, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/EventEnvelope.kt b/core/events/src/main/kotlin/com/correx/core/events/events/EventEnvelope.kt new file mode 100644 index 00000000..a59da6fb --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/EventEnvelope.kt @@ -0,0 +1,16 @@ +package com.correx.core.events.events + +import kotlinx.serialization.Serializable + +@Serializable +data class NewEvent( + val metadata: EventMetadata, + val payload: EventPayload +) + +@Serializable +data class StoredEvent( + val metadata: EventMetadata, + val sequence: Long, + val payload: EventPayload +) \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/EventMetadata.kt b/core/events/src/main/kotlin/com/correx/core/events/events/EventMetadata.kt new file mode 100644 index 00000000..846f3b0c --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/EventMetadata.kt @@ -0,0 +1,18 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.CausationId +import com.correx.core.events.types.CorrelationId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class EventMetadata( + val eventId: EventId, + val sessionId: SessionId, + val timestamp: Instant, + val schemaVersion: Int, + val causationId: CausationId?, + val correlationId: CorrelationId? +) \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/EventPayload.kt b/core/events/src/main/kotlin/com/correx/core/events/events/EventPayload.kt new file mode 100644 index 00000000..d5679042 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/EventPayload.kt @@ -0,0 +1,6 @@ +package com.correx.core.events.events + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface EventPayload \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/InferenceEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/InferenceEvents.kt new file mode 100644 index 00000000..39299cff --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/InferenceEvents.kt @@ -0,0 +1,60 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.InferenceRequestId +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.TokenUsage +import kotlinx.serialization.Serializable + + +@Serializable +data class InferenceStartedEvent( + val requestId: InferenceRequestId, + val sessionId: SessionId, + val stageId: StageId, + val providerId: ProviderId, +) : EventPayload + +@Serializable +data class InferenceCompletedEvent( + val requestId: InferenceRequestId, + val sessionId: SessionId, + val stageId: StageId, + val providerId: ProviderId, + val tokensUsed: TokenUsage, + val latencyMs: Long, +) : EventPayload + +@Serializable +data class InferenceFailedEvent( + val requestId: InferenceRequestId, + val sessionId: SessionId, + val stageId: StageId, + val providerId: ProviderId, + val reason: String, // human-readable; structured cause lives in CancellationReason +) : EventPayload + +@Serializable +data class InferenceTimeoutEvent( + val requestId: InferenceRequestId, + val sessionId: SessionId, + val stageId: StageId, + val providerId: ProviderId, + val timeoutMs: Long, +) : EventPayload + +@Serializable +data class ModelLoadedEvent( + val modelId: String, + val providerId: ProviderId, + val sessionId: SessionId, +) : EventPayload + +@Serializable +data class ModelUnloadedEvent( + val modelId: String, + val providerId: ProviderId, + val sessionId: SessionId, + val cancellationReason: String? = null, // serialized label, not the sealed class +) : EventPayload \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt new file mode 100644 index 00000000..f26d9366 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt @@ -0,0 +1,50 @@ +package com.correx.core.events.events + +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.Serializable + +@Serializable +data class WorkflowStartedEvent( + val sessionId: SessionId, + val startStageId: StageId, + val retryPolicy: RetryPolicy? = null, +) : EventPayload + +@Serializable +data class WorkflowCompletedEvent( + val sessionId: SessionId, + val terminalStageId: StageId, + val totalStages: Int, +) : EventPayload + +@Serializable +data class WorkflowFailedEvent( + val sessionId: SessionId, + val stageId: StageId, + val reason: String, + val retryExhausted: Boolean, +) : EventPayload + +@Serializable +data class OrchestrationPausedEvent( + val sessionId: SessionId, + val stageId: StageId, + val reason: String, // e.g. "APPROVAL_PENDING", "USER_REQUESTED" +) : EventPayload + +@Serializable +data class OrchestrationResumedEvent( + val sessionId: SessionId, + val stageId: StageId, +) : EventPayload + +@Serializable +data class RetryAttemptedEvent( + val sessionId: SessionId, + val stageId: StageId, + val attemptNumber: Int, + val maxAttempts: Int, + val failureReason: String, +) : EventPayload \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/RiskAssessedEvent.kt b/core/events/src/main/kotlin/com/correx/core/events/events/RiskAssessedEvent.kt new file mode 100644 index 00000000..485a9f3c --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/RiskAssessedEvent.kt @@ -0,0 +1,17 @@ +package com.correx.core.events.events + +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.risk.RiskLevel +import com.correx.core.events.types.RiskSummaryId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.Serializable + +@Serializable +data class RiskAssessedEvent( + val sessionId: SessionId, + val stageId: StageId, + val riskSummaryId: RiskSummaryId, + val level: RiskLevel, + val action: RiskAction, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt new file mode 100644 index 00000000..eabec9e6 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt @@ -0,0 +1,34 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import kotlinx.serialization.Serializable + +@Serializable +data class SessionStartedEvent( + val sessionId: SessionId, + val initialContextId: String? = null +) : EventPayload + +@Serializable +data class SessionPausedEvent( + val sessionId: SessionId, + val reason: String? = null +) : EventPayload + +@Serializable +data class SessionResumedEvent( + val sessionId: SessionId, +) : EventPayload + +@Serializable +data class SessionCompletedEvent( + val sessionId: SessionId, + val summary: String? = null +) : EventPayload + +@Serializable +data class SessionFailedEvent( + val sessionId: SessionId, + val errorCode: String? = null, + val errorMessage: String? = null +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/StageEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/StageEvents.kt new file mode 100644 index 00000000..3d49bfab --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/StageEvents.kt @@ -0,0 +1,36 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import kotlinx.serialization.Serializable + +@Serializable +data class StageStartedEvent( + val sessionId: SessionId, + val stageId: StageId, + val transitionId: TransitionId +) : EventPayload + +@Serializable +data class StageCompletedEvent( + val sessionId: SessionId, + val stageId: StageId, + val transitionId: TransitionId +) : EventPayload + +@Serializable +data class StageFailedEvent( + val sessionId: SessionId, + val stageId: StageId, + val transitionId: TransitionId, + val reason: String +) : EventPayload + +@Serializable +data class TransitionExecutedEvent( + val sessionId: SessionId, + val from: StageId, + val to: StageId, + val transitionId: TransitionId +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt new file mode 100644 index 00000000..910b4cbf --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt @@ -0,0 +1,54 @@ +package com.correx.core.events.events + +import com.correx.core.approvals.Tier +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import kotlinx.serialization.Serializable + +@Serializable +data class ToolExecutionCompletedEvent( + val invocationId: ToolInvocationId, + val sessionId: SessionId, + val toolName: String, + val receipt: ToolReceipt, +) : EventPayload + +@Serializable +data class ToolExecutionFailedEvent( + val invocationId: ToolInvocationId, + val sessionId: SessionId, + val toolName: String, + val reason: String, +) : EventPayload + +@Serializable +data class ToolExecutionRejectedEvent( + val invocationId: ToolInvocationId, + val sessionId: SessionId, + val toolName: String, + val tier: Tier, + val reason: String, +) : EventPayload + +@Serializable +data class ToolExecutionStartedEvent( + val invocationId: ToolInvocationId, + val sessionId: SessionId, + val toolName: String, +) : EventPayload + +@Serializable +data class ToolInvocationRequestedEvent( + val invocationId: ToolInvocationId, + val sessionId: SessionId, + val stageId: StageId, + val toolName: String, + val tier: Tier, + val request: ToolRequest, +) : EventPayload + +@Serializable +data class ToolInvokedEvent( + val toolId: String, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ToolReceipt.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ToolReceipt.kt new file mode 100644 index 00000000..49a8f3b8 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ToolReceipt.kt @@ -0,0 +1,21 @@ +package com.correx.core.events.events + +import com.correx.core.approvals.Tier +import com.correx.core.events.serialization.AnyMapSerializer +import com.correx.core.events.types.ToolInvocationId +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class ToolReceipt( + val invocationId: ToolInvocationId, + val toolName: String, + val exitCode: Int, + val outputSummary: String, + @Serializable(with = AnyMapSerializer::class) + val structuredOutput: Map = emptyMap(), + val affectedEntities: List = emptyList(), + val durationMs: Long, + val tier: Tier, + val timestamp: Instant +) diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ToolRequest.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ToolRequest.kt new file mode 100644 index 00000000..0f5ec66b --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ToolRequest.kt @@ -0,0 +1,17 @@ +package com.correx.core.events.events + +import com.correx.core.events.serialization.AnyMapSerializer +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import kotlinx.serialization.Serializable + +@Serializable +data class ToolRequest( + val invocationId: ToolInvocationId, + val sessionId: SessionId, + val stageId: StageId, + val toolName: String, + @Serializable(with = AnyMapSerializer::class) + val parameters: Map = emptyMap() +) diff --git a/core/events/src/main/kotlin/com/correx/core/events/execution/RetryPolicy.kt b/core/events/src/main/kotlin/com/correx/core/events/execution/RetryPolicy.kt new file mode 100644 index 00000000..ef5fa730 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/execution/RetryPolicy.kt @@ -0,0 +1,13 @@ +package com.correx.core.events.execution + +import kotlinx.serialization.Serializable + +@Serializable +data class RetryPolicy( + val maxAttempts: Int, + val backoffMs: Long = 0L, +) { + init { + require(maxAttempts >= 1) { "maxAttempts must be >= 1" } + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/orchestration/OrchestrationState.kt b/core/events/src/main/kotlin/com/correx/core/events/orchestration/OrchestrationState.kt new file mode 100644 index 00000000..32b17b18 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/orchestration/OrchestrationState.kt @@ -0,0 +1,16 @@ +package com.correx.core.events.orchestration + +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.events.types.StageId +import kotlinx.serialization.Serializable + +@Serializable +data class OrchestrationState( + val currentStageId: StageId? = null, + val status: OrchestrationStatus = OrchestrationStatus.IDLE, + val retryCount: Int = 0, + val pauseReason: String? = null, + val pendingApproval: Boolean = false, + val failureReason: String? = null, + val retryPolicy: RetryPolicy? = null, +) diff --git a/core/events/src/main/kotlin/com/correx/core/events/orchestration/OrchestrationStatus.kt b/core/events/src/main/kotlin/com/correx/core/events/orchestration/OrchestrationStatus.kt new file mode 100644 index 00000000..395b1f32 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/orchestration/OrchestrationStatus.kt @@ -0,0 +1,11 @@ +package com.correx.core.events.orchestration + +enum class OrchestrationStatus { + IDLE, + RUNNING, + PAUSED, + COMPLETED, + FAILED, + CANCELED, + ; +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskAction.kt b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskAction.kt new file mode 100644 index 00000000..32365889 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskAction.kt @@ -0,0 +1,3 @@ +package com.correx.core.events.risk + +enum class RiskAction { PROCEED, PROMPT_USER, BLOCK } diff --git a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskLevel.kt b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskLevel.kt new file mode 100644 index 00000000..bcbccb54 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskLevel.kt @@ -0,0 +1,3 @@ +package com.correx.core.events.risk + +enum class RiskLevel { LOW, MEDIUM, HIGH, CRITICAL } diff --git a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSignal.kt b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSignal.kt new file mode 100644 index 00000000..d8d6b2e4 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSignal.kt @@ -0,0 +1,8 @@ +package com.correx.core.events.risk + +sealed class 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() +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt new file mode 100644 index 00000000..038e45ad --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt @@ -0,0 +1,7 @@ +package com.correx.core.events.risk + +data class RiskSummary( + val level: RiskLevel, + val signals: List, + val recommendedAction: RiskAction, +) diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/AnyMapSerializer.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/AnyMapSerializer.kt new file mode 100644 index 00000000..d3a56e0c --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/AnyMapSerializer.kt @@ -0,0 +1,71 @@ +package com.correx.core.events.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +private object AnySerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("AnyValue") + + override fun serialize(encoder: Encoder, value: Any) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("AnySerializer requires a JSON encoder") + jsonEncoder.encodeJsonElement(value.toJsonElement()) + } + + override fun deserialize(decoder: Decoder): Any { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("AnySerializer requires a JSON decoder") + return jsonDecoder.decodeJsonElement().toAny() + } +} + +private fun Any?.toJsonElement(): JsonElement = when (this) { + null -> JsonNull + is String -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + is Boolean -> JsonPrimitive(this) + is Map<*, *> -> JsonObject(entries.associate { (k, v) -> k.toString() to v.toJsonElement() }) + is List<*> -> JsonArray(map { it.toJsonElement() }) + else -> throw SerializationException("Unsupported type: ${this::class.simpleName}") +} + +private fun JsonElement.toAny(): Any = when (this) { + is JsonNull -> throw SerializationException("null values are not supported in Map") + is JsonPrimitive -> { + val content = this.content + when { + content == "true" || content == "false" -> content == "true" + content == "null" -> throw SerializationException("null values are not supported in Map") + content.toDoubleOrNull() != null && content.contains('.') -> content.toDouble() + content.toLongOrNull() != null -> content.toLong() + else -> content // fallback to string + } + } + is JsonObject -> entries.associate { (k, v) -> k to v.toAny() } + is JsonArray -> map { it.toAny() } +} + +object AnyMapSerializer : KSerializer> { + private val delegate = MapSerializer(String.serializer(), AnySerializer) + override val descriptor: SerialDescriptor = delegate.descriptor + + @Suppress("UNCHECKED_CAST") + override fun deserialize(decoder: Decoder): Map = + delegate.deserialize(decoder) as Map + + override fun serialize(encoder: Encoder, value: Map) = + delegate.serialize(encoder, value) +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/EventSerializer.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/EventSerializer.kt new file mode 100644 index 00000000..9178b0ba --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/EventSerializer.kt @@ -0,0 +1,8 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload + +interface EventSerializer { + fun serialize(payload: EventPayload): String + fun deserialize(raw: String): EventPayload +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/JsonEventSerializer.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/JsonEventSerializer.kt new file mode 100644 index 00000000..a66e6058 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/JsonEventSerializer.kt @@ -0,0 +1,15 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import kotlinx.serialization.json.Json + +class JsonEventSerializer( + private val json: Json = eventJson +) : EventSerializer { + + override fun serialize(payload: EventPayload): String = + json.encodeToString(EventPayload.serializer(), payload) + + override fun deserialize(raw: String): EventPayload = + json.decodeFromString(EventPayload.serializer(), raw) +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt new file mode 100644 index 00000000..3489bb64 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -0,0 +1,107 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.ApprovalDecisionResolvedEvent +import com.correx.core.events.events.ApprovalGrantCreatedEvent +import com.correx.core.events.events.ApprovalGrantExpiredEvent +import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.events.ArtifactArchivedEvent +import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.events.ArtifactRejectedEvent +import com.correx.core.events.events.ArtifactRelationshipAddedEvent +import com.correx.core.events.events.ArtifactSupersededEvent +import com.correx.core.events.events.ArtifactValidatedEvent +import com.correx.core.events.events.ArtifactValidatingEvent +import com.correx.core.events.events.CompressionAppliedEvent +import com.correx.core.events.events.ContextBuildingFailedEvent +import com.correx.core.events.events.ContextBuildingInterruptedEvent +import com.correx.core.events.events.ContextBuildingStartedEvent +import com.correx.core.events.events.ContextPackBuiltEvent +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.InferenceCompletedEvent +import com.correx.core.events.events.InferenceFailedEvent +import com.correx.core.events.events.InferenceStartedEvent +import com.correx.core.events.events.InferenceTimeoutEvent +import com.correx.core.events.events.ModelLoadedEvent +import com.correx.core.events.events.ModelUnloadedEvent +import com.correx.core.events.events.LayerTruncatedEvent +import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.OrchestrationResumedEvent +import com.correx.core.events.events.RetryAttemptedEvent +import com.correx.core.events.events.RiskAssessedEvent +import com.correx.core.events.events.SessionCompletedEvent +import com.correx.core.events.events.SessionFailedEvent +import com.correx.core.events.events.SessionPausedEvent +import com.correx.core.events.events.SessionResumedEvent +import com.correx.core.events.events.SessionStartedEvent +import com.correx.core.events.events.StageCompletedEvent +import com.correx.core.events.events.StageFailedEvent +import com.correx.core.events.events.StageStartedEvent +import com.correx.core.events.events.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolExecutionFailedEvent +import com.correx.core.events.events.ToolExecutionRejectedEvent +import com.correx.core.events.events.ToolExecutionStartedEvent +import com.correx.core.events.events.ToolInvokedEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.events.TransitionExecutedEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.events.WorkflowFailedEvent +import com.correx.core.events.events.WorkflowStartedEvent +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +val eventModule = SerializersModule { + polymorphic(EventPayload::class) { + // Legacy stub event — predates the full tool lifecycle model (ToolInvocationRequestedEvent et al.) + subclass(ToolInvokedEvent::class) + subclass(ToolInvocationRequestedEvent::class) + subclass(ToolExecutionStartedEvent::class) + subclass(ToolExecutionCompletedEvent::class) + subclass(ToolExecutionFailedEvent::class) + subclass(ToolExecutionRejectedEvent::class) + subclass(SessionStartedEvent::class) + subclass(SessionPausedEvent::class) + subclass(SessionResumedEvent::class) + subclass(SessionCompletedEvent::class) + subclass(SessionFailedEvent::class) + subclass(StageStartedEvent::class) + subclass(StageFailedEvent::class) + subclass(StageCompletedEvent::class) + subclass(TransitionExecutedEvent::class) + subclass(ApprovalRequestedEvent::class) + subclass(ApprovalDecisionResolvedEvent::class) + subclass(ApprovalGrantCreatedEvent::class) + subclass(ApprovalGrantExpiredEvent::class) + subclass(ContextBuildingStartedEvent::class) + subclass(ContextPackBuiltEvent::class) + subclass(CompressionAppliedEvent::class) + subclass(LayerTruncatedEvent::class) + subclass(ContextBuildingFailedEvent::class) + subclass(ContextBuildingInterruptedEvent::class) + subclass(ArtifactCreatedEvent::class) + subclass(ArtifactValidatingEvent::class) + subclass(ArtifactValidatedEvent::class) + subclass(ArtifactRejectedEvent::class) + subclass(ArtifactSupersededEvent::class) + subclass(ArtifactArchivedEvent::class) + subclass(ArtifactRelationshipAddedEvent::class) + subclass(InferenceFailedEvent::class) + subclass(InferenceCompletedEvent::class) + subclass(InferenceStartedEvent::class) + subclass(InferenceTimeoutEvent::class) + subclass(ModelLoadedEvent::class) + subclass(ModelUnloadedEvent::class) + subclass(OrchestrationResumedEvent::class) + subclass(OrchestrationPausedEvent::class) + subclass(WorkflowStartedEvent::class) + subclass(WorkflowFailedEvent::class) + subclass(WorkflowCompletedEvent::class) + subclass(RetryAttemptedEvent::class) + subclass(RiskAssessedEvent::class) + } +} + +val eventJson = Json { + serializersModule = eventModule +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/events/stores/EventStore.kt b/core/events/src/main/kotlin/com/correx/core/events/stores/EventStore.kt new file mode 100644 index 00000000..e6c15ff5 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/stores/EventStore.kt @@ -0,0 +1,37 @@ +package com.correx.core.events.stores + +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.types.SessionId + + +interface EventStore { + /** + * Append a single event to the store. + * Must enforce: + * - monotonic per-session sequence + * - idempotency by eventId + * - causal consistency rules (if enforced here, otherwise in kernel) + */ + fun append(event: NewEvent): StoredEvent + + /** + * Append multiple events atomically (same session preferred). + */ + fun appendAll(events: List): List + + /** + * Read events for a session in strict sequence order. + */ + fun read(sessionId: SessionId): List + + /** + * Read events from a specific sequence offset (for replay/resume). + */ + fun readFrom(sessionId: SessionId, fromSequence: Long): List + + /** + * Get last sequence number for session. + */ + fun lastSequence(sessionId: SessionId): Long? +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/ArtifactLifecyclePhase.kt b/core/events/src/main/kotlin/com/correx/core/events/types/ArtifactLifecyclePhase.kt new file mode 100644 index 00000000..deedcb7b --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/ArtifactLifecyclePhase.kt @@ -0,0 +1,13 @@ +package com.correx.core.events.types + +import kotlinx.serialization.Serializable + +@Serializable +enum class ArtifactLifecyclePhase { + CREATED, + VALIDATING, + VALIDATED, + REJECTED, + SUPERSEDED, + ARCHIVED +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/ArtifactRelationshipType.kt b/core/events/src/main/kotlin/com/correx/core/events/types/ArtifactRelationshipType.kt new file mode 100644 index 00000000..43b9de55 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/ArtifactRelationshipType.kt @@ -0,0 +1,14 @@ +package com.correx.core.events.types + +import kotlinx.serialization.Serializable + +@Serializable +enum class ArtifactRelationshipType { + PARENT, + CHILD, + SUPERSEDES, + DERIVED_FROM, + VALIDATED_BY, + APPROVED_BY, + GENERATED_FROM +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/EventTypes.kt b/core/events/src/main/kotlin/com/correx/core/events/types/EventTypes.kt new file mode 100644 index 00000000..4aebddde --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/EventTypes.kt @@ -0,0 +1,17 @@ +package com.correx.core.events.types + +object EventTypes { + const val SESSION_STARTED = "session.started" + const val SESSION_PAUSED = "session.paused" + const val SESSION_COMPLETED = "session.completed" + + const val TOOL_INVOKED = "tool.invoked" + const val TOOL_COMPLETED = "tool.completed" + + const val APPROVAL_REQUESTED = "approval.requested" + const val APPROVAL_DECISION_RESOLVED = "approval.decision_resolved" + const val APPROVAL_GRANT_CREATED = "approval.grant_created" + const val APPROVAL_GRANT_EXPIRED = "approval.grant_expired" + + const val ARTIFACT_CREATED = "artifact.created" +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt b/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt new file mode 100644 index 00000000..585fc0a8 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt @@ -0,0 +1,42 @@ +package com.correx.core.events.types + +import com.correx.core.utils.TypeId + + +// Events types +typealias EventId = TypeId +typealias SessionId = TypeId +typealias CorrelationId = TypeId +typealias CausationId = TypeId + + +// Transitions types +typealias StageId = TypeId +typealias TransitionId = TypeId + + +// Artifacts types +typealias ArtifactId = TypeId + + +// Context types +typealias ContextPackId = TypeId +typealias ContextEntryId = TypeId + + +// Approvals types +typealias ValidationReportId = TypeId +typealias RiskSummaryId = TypeId +typealias GrantId = TypeId +typealias ApprovalRequestId = TypeId +typealias ProjectId = TypeId +typealias ApprovalDecisionId = TypeId + + +// Tools types +typealias ToolInvocationId = TypeId + + +// Inference +typealias InferenceRequestId = TypeId +typealias ProviderId = TypeId \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/RiskSummaryId.kt b/core/events/src/main/kotlin/com/correx/core/events/types/RiskSummaryId.kt new file mode 100644 index 00000000..e821d076 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/RiskSummaryId.kt @@ -0,0 +1 @@ +package com.correx.core.events.types diff --git a/core/events/src/main/kotlin/com/correx/core/inference/TokenUsage.kt b/core/events/src/main/kotlin/com/correx/core/inference/TokenUsage.kt new file mode 100644 index 00000000..89a23f54 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/inference/TokenUsage.kt @@ -0,0 +1,11 @@ +package com.correx.core.inference + +import kotlinx.serialization.Serializable + +@Serializable +data class TokenUsage( + val promptTokens: Int, + val completionTokens: Int, +) { + val totalTokens: Int get() = promptTokens + completionTokens +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/sessions/projections/DefaultStateBuilder.kt b/core/events/src/main/kotlin/com/correx/core/sessions/projections/DefaultStateBuilder.kt new file mode 100644 index 00000000..18e1a2ad --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/sessions/projections/DefaultStateBuilder.kt @@ -0,0 +1,14 @@ +package com.correx.core.sessions.projections + +import com.correx.core.events.events.StoredEvent + +class DefaultStateBuilder( + private val projection: Projection +) : StateBuilder { + + override fun build(events: List): S { + return events.fold(projection.initial()) { state, event -> + projection.apply(state, event) + } + } +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/sessions/projections/Projection.kt b/core/events/src/main/kotlin/com/correx/core/sessions/projections/Projection.kt new file mode 100644 index 00000000..d7a13610 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/sessions/projections/Projection.kt @@ -0,0 +1,10 @@ +package com.correx.core.sessions.projections + +import com.correx.core.events.events.StoredEvent + +interface Projection { + + fun initial(): S + + fun apply(state: S, event: StoredEvent): S +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/sessions/projections/StateBuilder.kt b/core/events/src/main/kotlin/com/correx/core/sessions/projections/StateBuilder.kt new file mode 100644 index 00000000..3e006a93 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/sessions/projections/StateBuilder.kt @@ -0,0 +1,7 @@ +package com.correx.core.sessions.projections + +import com.correx.core.events.events.StoredEvent + +interface StateBuilder { + fun build(events: List): S +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/sessions/projections/replay/DefaultEventReplayer.kt b/core/events/src/main/kotlin/com/correx/core/sessions/projections/replay/DefaultEventReplayer.kt new file mode 100644 index 00000000..5b8cbd70 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/sessions/projections/replay/DefaultEventReplayer.kt @@ -0,0 +1,19 @@ +package com.correx.core.sessions.projections.replay + +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.DefaultStateBuilder +import com.correx.core.sessions.projections.Projection + +class DefaultEventReplayer( + private val store: EventStore, + private val projection: Projection +) : EventReplayer { + + override fun rebuild(sessionId: SessionId): S { + val events = store.read(sessionId) + + return DefaultStateBuilder(projection) + .build(events) + } +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/sessions/projections/replay/EventReplayer.kt b/core/events/src/main/kotlin/com/correx/core/sessions/projections/replay/EventReplayer.kt new file mode 100644 index 00000000..579b407a --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/sessions/projections/replay/EventReplayer.kt @@ -0,0 +1,7 @@ +package com.correx.core.sessions.projections.replay + +import com.correx.core.events.types.SessionId + +interface EventReplayer { + fun rebuild(sessionId: SessionId): S +} \ No newline at end of file diff --git a/core/events/src/main/kotlin/com/correx/core/utils/TypeId.kt b/core/events/src/main/kotlin/com/correx/core/utils/TypeId.kt new file mode 100644 index 00000000..64f1a6d9 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/utils/TypeId.kt @@ -0,0 +1,14 @@ +package com.correx.core.utils + +import kotlinx.serialization.Serializable + +@JvmInline +@Serializable +value class TypeId(val value: String) { + init { + require(value.isNotBlank()) { "id must not be blank" } + require(value.trim() == value) { "id must not contain leading/trailing whitespace" } + } + + override fun toString(): String = value +} \ No newline at end of file diff --git a/core/events/src/test/kotlin/com/correx/core/events/ToolEventsSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/ToolEventsSerializationTest.kt new file mode 100644 index 00000000..5d825ffe --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/ToolEventsSerializationTest.kt @@ -0,0 +1,99 @@ +package com.correx.core.events + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolExecutionFailedEvent +import com.correx.core.events.events.ToolExecutionRejectedEvent +import com.correx.core.events.events.ToolExecutionStartedEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.events.ToolReceipt +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.serialization.eventJson +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ToolEventsSerializationTest { + private val invocationId = ToolInvocationId("inv-1") + private val sessionId = SessionId("s-1") + private val stageId = StageId("st-1") + + private val request = ToolRequest( + invocationId = invocationId, + sessionId = sessionId, + stageId = stageId, + toolName = "echo", + parameters = mapOf("key" to "value", "count" to 42L) + ) + + private val receipt = ToolReceipt( + invocationId = invocationId, + toolName = "echo", + exitCode = 0, + outputSummary = "done", + structuredOutput = mapOf("result" to "ok"), + affectedEntities = listOf("file.txt"), + durationMs = 100L, + tier = Tier.T0, + timestamp = Instant.parse("2026-01-01T00:00:00Z") + ) + + @Test + fun `ToolInvocationRequestedEvent round-trips`() { + val event = ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request) + val json = eventJson.encodeToString(ToolInvocationRequestedEvent.serializer(), event) + assertEquals(event, eventJson.decodeFromString(ToolInvocationRequestedEvent.serializer(), json)) + } + + @Test + fun `ToolExecutionStartedEvent round-trips`() { + val event = ToolExecutionStartedEvent(invocationId, sessionId, "echo") + val json = eventJson.encodeToString(ToolExecutionStartedEvent.serializer(), event) + assertEquals(event, eventJson.decodeFromString(ToolExecutionStartedEvent.serializer(), json)) + } + + @Test + fun `ToolExecutionCompletedEvent round-trips`() { + val event = ToolExecutionCompletedEvent(invocationId, sessionId, "echo", receipt) + val json = eventJson.encodeToString(ToolExecutionCompletedEvent.serializer(), event) + assertEquals(event, eventJson.decodeFromString(ToolExecutionCompletedEvent.serializer(), json)) + } + + @Test + fun `ToolExecutionFailedEvent round-trips`() { + val event = ToolExecutionFailedEvent(invocationId, sessionId, "echo", "something broke") + val json = eventJson.encodeToString(ToolExecutionFailedEvent.serializer(), event) + assertEquals(event, eventJson.decodeFromString(ToolExecutionFailedEvent.serializer(), json)) + } + + @Test + fun `ToolExecutionRejectedEvent round-trips`() { + val event = ToolExecutionRejectedEvent(invocationId, sessionId, "echo", Tier.T4, "tier too high") + val json = eventJson.encodeToString(ToolExecutionRejectedEvent.serializer(), event) + assertEquals(event, eventJson.decodeFromString(ToolExecutionRejectedEvent.serializer(), json)) + } + + @Test + fun `ToolRequest with parameters round-trips`() { + val json = eventJson.encodeToString(ToolRequest.serializer(), request) + assertEquals(request, eventJson.decodeFromString(ToolRequest.serializer(), json)) + } + + @Test + fun `ToolReceipt with structuredOutput round-trips`() { + val json = eventJson.encodeToString(ToolReceipt.serializer(), receipt) + assertEquals(receipt, eventJson.decodeFromString(ToolReceipt.serializer(), json)) + } + + @Test + fun `ToolInvocationRequestedEvent round-trips through polymorphic EventPayload`() { + val event = ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request) + val json = eventJson.encodeToString(EventPayload.serializer(), event) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + assertEquals(event, decoded) + } +} diff --git a/core/inference/build.gradle b/core/inference/build.gradle new file mode 100644 index 00000000..7f436a45 --- /dev/null +++ b/core/inference/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + implementation(project(":core:context")) +} \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/CancellationReason.kt b/core/inference/src/main/kotlin/com/correx/core/inference/CancellationReason.kt new file mode 100644 index 00000000..e2232777 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/CancellationReason.kt @@ -0,0 +1,20 @@ +package com.correx.core.inference + +import kotlinx.serialization.Serializable +import kotlin.time.Duration + +sealed class CancellationReason { + object UserRequested : CancellationReason() + object StageTimeout : CancellationReason() + object SessionCancelled : CancellationReason() + object ProviderEvicted : CancellationReason() +} + +/** + * Records a deadline that has fired. Used by the harness to carry timeout context when + * cancelling an [InferenceCancellationToken] with [CancellationReason.StageTimeout]. + * Produced by the harness only — providers must not construct this. + */ +@JvmInline +@Serializable +value class InferenceTimeout(val duration: Duration) diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/DefaultInferenceReducer.kt b/core/inference/src/main/kotlin/com/correx/core/inference/DefaultInferenceReducer.kt new file mode 100644 index 00000000..c236fec2 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/DefaultInferenceReducer.kt @@ -0,0 +1,47 @@ +package com.correx.core.inference + +import com.correx.core.events.events.InferenceCompletedEvent +import com.correx.core.events.events.InferenceFailedEvent +import com.correx.core.events.events.InferenceStartedEvent +import com.correx.core.events.events.InferenceTimeoutEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.types.InferenceRequestId + +class DefaultInferenceReducer : InferenceReducer { + override fun reduce(state: InferenceState, event: StoredEvent): InferenceState = when (val p = event.payload) { + is InferenceStartedEvent -> { + val newRecord = InferenceRecord( + requestId = p.requestId, + sessionId = p.sessionId, + stageId = p.stageId, + providerId = p.providerId, + status = InferenceStatus.STARTED, + ) + state.copy(records = state.records + newRecord) + } + + is InferenceCompletedEvent -> updateRecord(state, p.requestId) { + it.copy(status = InferenceStatus.COMPLETED, tokensUsed = p.tokensUsed, latencyMs = p.latencyMs) + } + + is InferenceFailedEvent -> updateRecord(state, p.requestId) { + it.copy(status = InferenceStatus.FAILED, failureReason = p.reason) + } + + is InferenceTimeoutEvent -> updateRecord(state, p.requestId) { + it.copy(status = InferenceStatus.TIMED_OUT, latencyMs = p.timeoutMs) + } + + else -> state + } + + private fun updateRecord( + state: InferenceState, + requestId: InferenceRequestId, + transform: (InferenceRecord) -> InferenceRecord, + ): InferenceState { + val updated = state.records.map { if (it.requestId == requestId) transform(it) else it } + return state.copy(records = updated) + } + +} \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/DefaultInferenceRouter.kt b/core/inference/src/main/kotlin/com/correx/core/inference/DefaultInferenceRouter.kt new file mode 100644 index 00000000..559d452b --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/DefaultInferenceRouter.kt @@ -0,0 +1,60 @@ +package com.correx.core.inference + +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.StageId +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +private data class HealthEntry( + val health: ProviderHealth, + val mark: TimeMark, +) + +class DefaultInferenceRouter( + private val registry: ProviderRegistry, + private val strategy: RoutingStrategy, + private val cacheTtl: Duration = 5.seconds, + private val timeSource: TimeSource = TimeSource.Monotonic, +) : InferenceRouter { + + private val cache = mutableMapOf() + private val locks = mutableMapOf() + private val mapMutex = Mutex() + + private suspend fun lockFor(id: ProviderId): Mutex = + mapMutex.withLock { locks.getOrPut(id) { Mutex() } } + + private suspend fun cachedHealth(provider: InferenceProvider): ProviderHealth { + val existing = mapMutex.withLock { cache[provider.id] } + if (existing != null && existing.mark.elapsedNow() < cacheTtl) return existing.health + + return lockFor(provider.id).withLock { + // re-check after acquiring lock — another coroutine may have refreshed + val recheck = mapMutex.withLock { cache[provider.id] } + if (recheck != null && recheck.mark.elapsedNow() < cacheTtl) return@withLock recheck.health + + val fresh = provider.healthCheck() + mapMutex.withLock { cache[provider.id] = HealthEntry(fresh, timeSource.markNow()) } + fresh + } + } + + override suspend fun route(stageId: StageId, requiredCapabilities: Set): InferenceProvider { + val candidates = requiredCapabilities + .flatMap { registry.resolve(it) } + .distinctBy { it.id } + .ifEmpty { registry.listAll() } + val healthy = candidates.filter { cachedHealth(it) !is ProviderHealth.Unavailable } + val selected = strategy.select(healthy, requiredCapabilities) + // Post-selection re-check closes the TOCTOU window between initial filter and dispatch. + when (val postHealth = selected.healthCheck()) { + is ProviderHealth.Unavailable -> throw ProviderUnavailableException(selected.id, postHealth.reason) + else -> Unit + } + return selected + } +} diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/FinishReason.kt b/core/inference/src/main/kotlin/com/correx/core/inference/FinishReason.kt new file mode 100644 index 00000000..c02ac7c1 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/FinishReason.kt @@ -0,0 +1,17 @@ +package com.correx.core.inference + +import kotlinx.serialization.Serializable + +@Serializable +sealed class FinishReason { + @Serializable + object Stop : FinishReason() + @Serializable + object Length : FinishReason() + @Serializable + object Timeout : FinishReason() + @Serializable + object Cancelled : FinishReason() + @Serializable + data class Error(val message: String) : FinishReason() +} \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/GenerationConfig.kt b/core/inference/src/main/kotlin/com/correx/core/inference/GenerationConfig.kt new file mode 100644 index 00000000..670b6f40 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/GenerationConfig.kt @@ -0,0 +1,16 @@ +package com.correx.core.inference + +import kotlinx.serialization.Serializable + +/** + * All fields required for deterministic replay. + * Callers must fully specify config — no implicit defaults at runtime. + */ +@Serializable +data class GenerationConfig( + val temperature: Double, + val topP: Double, + val maxTokens: Int, + val stopSequences: List = emptyList(), + val seed: Long? = null, // null = non-deterministic; set for replay +) \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/InferenceCancellationToken.kt b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceCancellationToken.kt new file mode 100644 index 00000000..a8faaf2d --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceCancellationToken.kt @@ -0,0 +1,31 @@ +package com.correx.core.inference + +/** + * Cancellation contract for inference calls. + * + * ## Coroutine contract + * Implementations of [InferenceProvider.infer] MUST honour cancellation cooperatively. + * Checkpoints are mandatory at: + * - before the HTTP/socket write + * - after each streamed chunk (if streaming) + * - before parsing the final response + * + * Use `kotlinx.coroutines.ensureActive()` at each checkpoint. + * Blocking calls must be wrapped with `withContext(Dispatchers.IO)` and must + * not suppress `CancellationException`. + * + * ## Timeout / token interaction + * Direction is one-way: when a deadline fires, the harness cancels this token (calling + * [cancel] with [CancellationReason.StageTimeout]). The reverse is NOT true — calling + * [cancel] on this token for any other reason MUST NOT retroactively record or signal a + * timeout. Only a fired [InferenceTimeout] deadline triggers [CancellationReason.StageTimeout]. + * + * ## Event contract + * On cancellation, the provider MUST emit [InferenceTimeoutEvent] (for deadline + * exceeded) or allow the harness to emit [InferenceFailedEvent] with + * [CancellationReason] attached. The provider MUST NOT swallow the cancellation. + */ +interface InferenceCancellationToken { + val isCancelled: Boolean + fun cancel(reason: CancellationReason) +} \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/InferenceProjector.kt b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceProjector.kt new file mode 100644 index 00000000..81713e2d --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceProjector.kt @@ -0,0 +1,15 @@ +package com.correx.core.inference + +import com.correx.core.events.events.StoredEvent +import com.correx.core.sessions.projections.Projection + +class InferenceProjector( + private val reducer: InferenceReducer = DefaultInferenceReducer() +) : Projection { + override fun initial(): InferenceState = InferenceState(emptyList()) + + override fun apply( + state: InferenceState, + event: StoredEvent + ): InferenceState = reducer.reduce(state, event) +} \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/InferenceProvider.kt b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceProvider.kt new file mode 100644 index 00000000..a99ab234 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceProvider.kt @@ -0,0 +1,20 @@ +package com.correx.core.inference + +import com.correx.core.events.types.ProviderId + +interface InferenceProvider { + val id: ProviderId + val name: String + val tokenizer: Tokenizer + + suspend fun infer(request: InferenceRequest): InferenceResponse + suspend fun healthCheck(): ProviderHealth + fun capabilities(): Set +} + +interface ProviderRegistry { + fun register(provider: InferenceProvider) + fun resolve(capability: ModelCapability): List // ordered by score desc + fun listAll(): List + suspend fun healthCheckAll(): Map +} \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRecord.kt b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRecord.kt new file mode 100644 index 00000000..c9268a08 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRecord.kt @@ -0,0 +1,19 @@ +package com.correx.core.inference + +import com.correx.core.events.types.InferenceRequestId +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.Serializable + +@Serializable +data class InferenceRecord( + val requestId: InferenceRequestId, + val sessionId: SessionId, + val stageId: StageId, + val providerId: ProviderId, + val status: InferenceStatus, + val tokensUsed: TokenUsage? = null, + val latencyMs: Long? = null, + val failureReason: String? = null, +) \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/InferenceReducer.kt b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceReducer.kt new file mode 100644 index 00000000..aa7a68dc --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceReducer.kt @@ -0,0 +1,8 @@ +package com.correx.core.inference + +import com.correx.core.events.events.StoredEvent +import com.correx.core.inference.InferenceState + +interface InferenceReducer { + fun reduce(state: InferenceState, event: StoredEvent): InferenceState +} \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRepository.kt b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRepository.kt new file mode 100644 index 00000000..65068c18 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRepository.kt @@ -0,0 +1,12 @@ +package com.correx.core.inference + +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.replay.EventReplayer + +class InferenceRepository( + private val replayer: EventReplayer +) { + fun getInferenceState(sessionId: SessionId): InferenceState { + return replayer.rebuild(sessionId) + } +} \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRequest.kt b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRequest.kt new file mode 100644 index 00000000..ddbeef87 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRequest.kt @@ -0,0 +1,17 @@ +package com.correx.core.inference + +import com.correx.core.context.model.ContextPack +import com.correx.core.events.types.InferenceRequestId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.Serializable + +@Serializable +data class InferenceRequest( + val requestId: InferenceRequestId, + val sessionId: SessionId, + val stageId: StageId, + val contextPack: ContextPack, + val generationConfig: GenerationConfig, + val timeout: InferenceTimeout? = null, +) \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/InferenceResponse.kt b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceResponse.kt new file mode 100644 index 00000000..1356fb69 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceResponse.kt @@ -0,0 +1,13 @@ +package com.correx.core.inference + +import com.correx.core.events.types.InferenceRequestId +import kotlinx.serialization.Serializable + +@Serializable +data class InferenceResponse( + val requestId: InferenceRequestId, + val text: String, + val finishReason: FinishReason, + val tokensUsed: TokenUsage, + val latencyMs: Long, +) \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRouter.kt b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRouter.kt new file mode 100644 index 00000000..f7e96ea8 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceRouter.kt @@ -0,0 +1,44 @@ +package com.correx.core.inference + +import com.correx.core.events.types.StageId + +/** + * Pure selection function — no I/O, no side effects. + * Operates on a snapshot of available providers. + */ +fun interface RoutingStrategy { + /** + * @throws NoEligibleProviderException if no candidate satisfies all required capabilities + */ + fun select( + candidates: List, + requiredCapabilities: Set, + ): InferenceProvider +} + +interface InferenceRouter { + /** + * Resolves and selects a provider for the given stage. + * Performs a live health check and filters [ProviderHealth.Unavailable] providers + * before selection, so callers never receive a provider that is known-down at routing time. + * @throws NoEligibleProviderException if no healthy provider satisfies requirements + */ + suspend fun route( + stageId: StageId, + requiredCapabilities: Set, + ): InferenceProvider +} + +class NoEligibleProviderException( + stageId: StageId, + required: Set, +) : Exception( + "No provider satisfies capabilities $required for stage '${stageId.value}'", +) + +class ProviderUnavailableException( + providerId: com.correx.core.events.types.ProviderId, + reason: String, +) : Exception( + "Provider '${providerId.value}' is unavailable: $reason", +) \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/InferenceState.kt b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceState.kt new file mode 100644 index 00000000..171c040a --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceState.kt @@ -0,0 +1,8 @@ +package com.correx.core.inference + +import kotlinx.serialization.Serializable + +@Serializable +data class InferenceState( + val records: List = emptyList() +) \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/InferenceStatus.kt b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceStatus.kt new file mode 100644 index 00000000..d7b233cc --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/InferenceStatus.kt @@ -0,0 +1,5 @@ +package com.correx.core.inference + +enum class InferenceStatus { + STARTED, COMPLETED, FAILED, TIMED_OUT +} \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/ModelCapability.kt b/core/inference/src/main/kotlin/com/correx/core/inference/ModelCapability.kt new file mode 100644 index 00000000..44854233 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/ModelCapability.kt @@ -0,0 +1,27 @@ +package com.correx.core.inference + +import kotlinx.serialization.Serializable + +@Serializable +sealed class ModelCapability { + @Serializable + object Coding : ModelCapability() + + @Serializable + object ToolCalling : ModelCapability() + + @Serializable + object Reasoning : ModelCapability() + + @Serializable + object Summarization : ModelCapability() + + @Serializable + object General : ModelCapability() +} + +@Serializable +data class CapabilityScore( + val capability: ModelCapability, + val score: Double, // 0.0 – 1.0; higher = better +) diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/ModelLoadException.kt b/core/inference/src/main/kotlin/com/correx/core/inference/ModelLoadException.kt new file mode 100644 index 00000000..45dac516 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/ModelLoadException.kt @@ -0,0 +1,11 @@ +package com.correx.core.inference + +/** + * Thrown when model loading fails during DefaultModelManager.load(). + * No event is emitted on failure per event sourcing principles. + */ +class ModelLoadException( + override val message: String, + val modelId: String, + override val cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/ProviderHealth.kt b/core/inference/src/main/kotlin/com/correx/core/inference/ProviderHealth.kt new file mode 100644 index 00000000..e06dc569 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/ProviderHealth.kt @@ -0,0 +1,7 @@ +package com.correx.core.inference + +sealed class ProviderHealth { + object Healthy : ProviderHealth() + data class Degraded(val reason: String) : ProviderHealth() + data class Unavailable(val reason: String) : ProviderHealth() +} \ No newline at end of file diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/Tokenizer.kt b/core/inference/src/main/kotlin/com/correx/core/inference/Tokenizer.kt new file mode 100644 index 00000000..ed3f8290 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/Tokenizer.kt @@ -0,0 +1,13 @@ +package com.correx.core.inference + +@JvmInline +value class Token(val id: Int) + +/** + * Provider-owned. Tokenization is model-family-specific; + * two providers for the same capability may not share a tokenizer. + */ +interface Tokenizer { + suspend fun tokenize(text: String): List + suspend fun countTokens(text: String): Int +} \ No newline at end of file diff --git a/core/kernel/build.gradle b/core/kernel/build.gradle new file mode 100644 index 00000000..47879f3e --- /dev/null +++ b/core/kernel/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation project(':core:events') + implementation project(':core:sessions') + implementation project(':core:transitions') + implementation project(':core:validation') + implementation project(':core:approvals') + implementation project(':core:context') + implementation project(':core:inference') + implementation project(':core:tools') + implementation project(':core:artifacts') + implementation project(':core:risk') +} \ No newline at end of file diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/ReplayStrategy.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/ReplayStrategy.kt new file mode 100644 index 00000000..3ac77441 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/ReplayStrategy.kt @@ -0,0 +1,7 @@ +package com.correx.core.kernel.execution + +sealed interface ReplayStrategy { + data object Full : ReplayStrategy + data object SkipInference : ReplayStrategy // use recorded InferenceCompletedEvent artifacts + data object SkipValidation : ReplayStrategy // trust recorded outcomes +} \ No newline at end of file diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/RetryPolicy.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/RetryPolicy.kt new file mode 100644 index 00000000..a48135bf --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/RetryPolicy.kt @@ -0,0 +1 @@ +package com.correx.core.kernel.execution diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/StageOutcome.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/StageOutcome.kt new file mode 100644 index 00000000..6205bd6e --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/StageOutcome.kt @@ -0,0 +1,18 @@ +package com.correx.core.kernel.execution + +import com.correx.core.approvals.model.DomainApprovalRequest +import com.correx.core.artifacts.model.Artifact +import com.correx.core.validation.model.ValidationReport + +sealed interface StageOutcome { + data class Success(val artifact: Artifact) : StageOutcome + /** + * @param retryable set by the validator based on failure type; the orchestrator must read this + * and never override it. + * Structural errors (e.g. invalid graph topology) are not retryable. Transient errors may be. + */ + data class ValidationFailure(val report: ValidationReport, val retryable: Boolean) : StageOutcome + data class InferenceFailure(val reason: String, val retryable: Boolean) : StageOutcome + data class ApprovalRequired(val request: DomainApprovalRequest) : StageOutcome + data object Cancelled : StageOutcome +} \ No newline at end of file diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/WorkflowResult.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/WorkflowResult.kt new file mode 100644 index 00000000..ce001534 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/execution/WorkflowResult.kt @@ -0,0 +1,10 @@ +package com.correx.core.kernel.execution + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId + +sealed interface WorkflowResult { + data class Completed(val sessionId: SessionId, val terminalStageId: StageId) : WorkflowResult + data class Failed(val sessionId: SessionId, val reason: String, val retryExhausted: Boolean) : WorkflowResult + data class Cancelled(val sessionId: SessionId) : WorkflowResult +} \ No newline at end of file diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultOrchestrationReducer.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultOrchestrationReducer.kt new file mode 100644 index 00000000..11e32f5e --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultOrchestrationReducer.kt @@ -0,0 +1,49 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.OrchestrationResumedEvent +import com.correx.core.events.events.RetryAttemptedEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.events.WorkflowFailedEvent +import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.orchestration.OrchestrationState +import com.correx.core.events.orchestration.OrchestrationStatus + +class DefaultOrchestrationReducer : OrchestrationReducer { + override fun reduce( + state: OrchestrationState, + event: StoredEvent, + ): OrchestrationState = when (val p = event.payload) { + is WorkflowStartedEvent -> state.copy( + status = OrchestrationStatus.RUNNING, + currentStageId = p.startStageId, + retryPolicy = p.retryPolicy, + ) + is WorkflowCompletedEvent -> state.copy( + status = OrchestrationStatus.COMPLETED, + currentStageId = p.terminalStageId, + ) + + is WorkflowFailedEvent -> state.copy(status = OrchestrationStatus.FAILED, failureReason = p.reason) + is OrchestrationPausedEvent -> state.copy( + status = OrchestrationStatus.PAUSED, + pendingApproval = true, + pauseReason = p.reason, + ) + + is OrchestrationResumedEvent -> state.copy( + status = OrchestrationStatus.RUNNING, + pendingApproval = false, + pauseReason = null, + ) + + is RetryAttemptedEvent -> state.copy( + status = OrchestrationStatus.RUNNING, + retryCount = p.attemptNumber, + failureReason = null, + ) + + else -> state + } +} \ No newline at end of file diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt new file mode 100644 index 00000000..97cb37da --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -0,0 +1,157 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.orchestration.OrchestrationState +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.kernel.execution.WorkflowResult +import com.correx.core.kernel.retry.RetryCoordinator +import com.correx.core.sessions.Session +import com.correx.core.transitions.execution.StageExecutionResult +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.TransitionDecision +import java.util.concurrent.* +import java.util.concurrent.atomic.* + +@SuppressWarnings( + "ReturnCount", + "NestedBlockDepth", +) +class DefaultSessionOrchestrator( + private val repositories: OrchestratorRepositories, + engines: OrchestratorEngines, + private val retryCoordinator: RetryCoordinator, +) : SessionOrchestrator(repositories, engines) { + override val cancellations: ConcurrentHashMap = + ConcurrentHashMap() + + override suspend fun run( + sessionId: SessionId, + graph: WorkflowGraph, + config: OrchestrationConfig, + ): WorkflowResult { + emitWorkflowStarted(sessionId, graph, config) + val base = ExecutionContext(graph, sessionId, 0, graph.start, config, null, null) + return step(base.enrich()) + } + + private tailrec suspend fun step(ctx: EnrichedExecutionContext): WorkflowResult { + if (isCancelled(ctx.sessionId)) return handleCancellation(ctx.sessionId, ctx.currentStageId) + + val enriched = EnrichedExecutionContext( + graph = ctx.graph, + sessionId = ctx.sessionId, + stageCount = ctx.stageCount, + currentStageId = ctx.currentStageId, + config = ctx.config, + state = orchestrationRepository.getState(ctx.sessionId), + session = repositories.sessionRepository.getSession(ctx.sessionId), + ) + + return when (val decision = resolveTransition(enriched.graph, enriched.sessionId, enriched.currentStageId)) { + is TransitionDecision.Move -> when (val r = executeMove(enriched, decision)) { + is StepResult.Continue -> step(r.ctx) + is StepResult.Terminal -> r.result + } + + is TransitionDecision.Stay -> if (isTerminal(enriched.graph, enriched.currentStageId)) { + completeWorkflow(enriched.sessionId, enriched.currentStageId, enriched.stageCount) + } else { + step(enriched) + } + + is TransitionDecision.Blocked -> failWorkflow( + sessionId = enriched.sessionId, + stageId = enriched.currentStageId, + reason = decision.reason, + retryExhausted = false, + ) + + is TransitionDecision.NoMatch -> failWorkflow( + sessionId = enriched.sessionId, + stageId = enriched.currentStageId, + reason = "no matching transition from stage ${enriched.currentStageId.value}", + retryExhausted = false, + ) + } + } + + + override suspend fun cancel(sessionId: SessionId) { + cancellations.getOrPut(sessionId) { AtomicBoolean(false) }.set(true) + } + + private suspend fun executeMove( + ctx: EnrichedExecutionContext, + decision: TransitionDecision.Move, + ): StepResult { + val nextStageId = decision.to + + if (isTerminal(ctx.graph, nextStageId)) { + return StepResult.Terminal( + completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount + 1), + ) + } + + return when (val result = executeStage(ctx.sessionId, nextStageId, ctx.graph, ctx.session, ctx.config)) { + is StageExecutionResult.Success -> StepResult.Continue( + ctx.copy( + currentStageId = advanceStage(ctx.sessionId, ctx.currentStageId, decision), + stageCount = ctx.stageCount + 1, + ), + ) + + is StageExecutionResult.Failure -> { + val shouldRetry = retryCoordinator.shouldRetry( + sessionId = ctx.sessionId, + stageId = nextStageId, + currentAttempt = ctx.state.retryCount, + policy = ctx.config.retryPolicy, + failureReason = result.reason, + ) + if (shouldRetry) { + StepResult.Continue(ctx) // retryCount updated via projection on next iteration + } else { + StepResult.Terminal( + failWorkflow( + ctx.sessionId, + ctx.currentStageId, + result.reason, + retryExhausted = result.retryable, + ), + ) + } + } + } + } + + private fun ExecutionContext.enrich() = EnrichedExecutionContext( + graph, sessionId, stageCount, currentStageId, config, + session = repositories.sessionRepository.getSession(sessionId), + state = orchestrationRepository.getState(sessionId), + ) +} + +private sealed class StepResult { + data class Continue(val ctx: EnrichedExecutionContext) : StepResult() + data class Terminal(val result: WorkflowResult) : StepResult() +} + +private data class ExecutionContext( + val graph: WorkflowGraph, + val sessionId: SessionId, + val stageCount: Int, + val currentStageId: StageId, + val config: OrchestrationConfig, + val session: Session?, + val state: OrchestrationState?, +) + +private data class EnrichedExecutionContext( + val graph: WorkflowGraph, + val sessionId: SessionId, + val stageCount: Int, + val currentStageId: StageId, + val config: OrchestrationConfig, + val session: Session, + val state: OrchestrationState, +) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationConfig.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationConfig.kt new file mode 100644 index 00000000..1c12dcd1 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationConfig.kt @@ -0,0 +1,10 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.kernel.execution.ReplayStrategy + +data class OrchestrationConfig( + val retryPolicy: RetryPolicy = RetryPolicy(maxAttempts = 3), + val replayStrategy: ReplayStrategy = ReplayStrategy.Full, + val stageTimeoutMs: Long = 60_000L, +) \ No newline at end of file diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationProjector.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationProjector.kt new file mode 100644 index 00000000..f4ae1fc8 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationProjector.kt @@ -0,0 +1,16 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.orchestration.OrchestrationState +import com.correx.core.sessions.projections.Projection + +class OrchestrationProjector( + private val orchestrationReducer: OrchestrationReducer, +) : Projection { + override fun initial(): OrchestrationState = OrchestrationState() + + override fun apply( + state: OrchestrationState, + event: StoredEvent, + ): OrchestrationState = orchestrationReducer.reduce(state, event) +} \ No newline at end of file diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationReducer.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationReducer.kt new file mode 100644 index 00000000..a732434a --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationReducer.kt @@ -0,0 +1,8 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.orchestration.OrchestrationState + +interface OrchestrationReducer { + fun reduce(state: OrchestrationState, event: StoredEvent): OrchestrationState +} \ No newline at end of file diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationRepository.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationRepository.kt new file mode 100644 index 00000000..ca168ab3 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationRepository.kt @@ -0,0 +1,11 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.orchestration.OrchestrationState +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.replay.EventReplayer + +class OrchestrationRepository( + private val replayer: EventReplayer, +) { + fun getState(sessionId: SessionId): OrchestrationState = replayer.rebuild(sessionId) +} \ No newline at end of file diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationState.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationState.kt new file mode 100644 index 00000000..5ffbbee2 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationState.kt @@ -0,0 +1 @@ +package com.correx.core.kernel.orchestration diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationStatus.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationStatus.kt new file mode 100644 index 00000000..5ffbbee2 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationStatus.kt @@ -0,0 +1 @@ +package com.correx.core.kernel.orchestration diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt new file mode 100644 index 00000000..a36bd2ee --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt @@ -0,0 +1,17 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.approvals.domain.ApprovalEngine +import com.correx.core.context.builder.ContextPackBuilder +import com.correx.core.inference.InferenceRouter +import com.correx.core.risk.RiskAssessor +import com.correx.core.transitions.resolution.TransitionResolver +import com.correx.core.validation.pipeline.ValidationPipeline + +data class OrchestratorEngines( + val transitionResolver: TransitionResolver, + val contextPackBuilder: ContextPackBuilder, + val inferenceRouter: InferenceRouter, + val validationPipeline: ValidationPipeline, + val approvalEngine: ApprovalEngine, + val riskAssessor: RiskAssessor, +) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorRepositories.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorRepositories.kt new file mode 100644 index 00000000..45fba516 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorRepositories.kt @@ -0,0 +1,12 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.stores.EventStore +import com.correx.core.inference.InferenceRepository +import com.correx.core.sessions.DefaultSessionRepository + +data class OrchestratorRepositories( + val eventStore: EventStore, + val inferenceRepository: InferenceRepository, + val orchestrationRepository: OrchestrationRepository, + val sessionRepository: DefaultSessionRepository, +) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt new file mode 100644 index 00000000..62d7287d --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt @@ -0,0 +1,170 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.approvals.domain.NoOpApprovalEngine +import com.correx.core.context.model.ContextPack +import com.correx.core.events.events.ArtifactValidatedEvent +import com.correx.core.events.events.ArtifactValidatingEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.InferenceRequestId +import com.correx.core.inference.GenerationConfig +import com.correx.core.inference.InferenceRequest +import com.correx.core.kernel.execution.ReplayStrategy +import com.correx.core.kernel.replay.ReplayInferenceProvider +import com.correx.core.kernel.retry.exception.ReplayArtifactMissingException +import com.correx.core.risk.NoOpRiskAssessor +import com.correx.core.transitions.execution.StageExecutionResult +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.TransitionDecision +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.kernel.execution.WorkflowResult +import com.correx.core.validation.model.ValidationContext +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +private data class ReplayContext( + val graph: WorkflowGraph, + val sessionId: SessionId, + val currentStageId: StageId, + val stageCount: Int, + val config: OrchestrationConfig, +) + +/** + * Replay is environment-independent. No live risk assessment or approval evaluation — + * both are reconstructed from the event log (ApprovalGrantedEvent / ApprovalDeniedEvent). + */ +@SuppressWarnings("ReturnCount") +class ReplayOrchestrator( + private val repositories: OrchestratorRepositories, + engines: OrchestratorEngines, + override val cancellations: ConcurrentHashMap, + private val strategy: ReplayStrategy, +) : SessionOrchestrator( + repositories, + engines.copy(approvalEngine = NoOpApprovalEngine(), riskAssessor = NoOpRiskAssessor()), +) { + private val replayProvider = ReplayInferenceProvider(repositories.eventStore) + + override suspend fun run( + sessionId: SessionId, + graph: WorkflowGraph, + config: OrchestrationConfig, + ): WorkflowResult { + emitWorkflowStarted(sessionId, graph, config) + return step(ReplayContext(graph, sessionId, graph.start, 0, config)) + } + + private tailrec suspend fun step(ctx: ReplayContext): WorkflowResult { + if (isCancelled(ctx.sessionId)) return handleCancellation(ctx.sessionId, ctx.currentStageId) + + val session = repositories.sessionRepository.getSession(ctx.sessionId) + + return when (val decision = resolveTransition(ctx.graph, ctx.sessionId, ctx.currentStageId)) { + is TransitionDecision.Move -> { + val nextStageId = decision.to + + if (isTerminal(ctx.graph, nextStageId)) { + return completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount + 1) + } + + when (val result = executeStage(ctx.sessionId, nextStageId, ctx.graph, session, ctx.config)) { + is StageExecutionResult.Success -> step( + ctx.copy( + currentStageId = advanceStage(ctx.sessionId, ctx.currentStageId, decision), + stageCount = ctx.stageCount + 1, + ), + ) + is StageExecutionResult.Failure -> + failWorkflow(ctx.sessionId, ctx.currentStageId, result.reason, retryExhausted = false) + } + } + + is TransitionDecision.Stay -> if (isTerminal(ctx.graph, ctx.currentStageId)) { + completeWorkflow(ctx.sessionId, ctx.currentStageId, ctx.stageCount) + } else { + step(ctx) + } + + is TransitionDecision.Blocked -> failWorkflow( + sessionId = ctx.sessionId, + stageId = ctx.currentStageId, + reason = decision.reason, + retryExhausted = false, + ) + + is TransitionDecision.NoMatch -> failWorkflow( + sessionId = ctx.sessionId, + stageId = ctx.currentStageId, + reason = "no matching transition from stage ${ctx.currentStageId.value}", + retryExhausted = false, + ) + } + } + + override suspend fun cancel(sessionId: SessionId) { + cancellations.getOrPut(sessionId) { AtomicBoolean(false) }.set(true) + } + + override suspend fun runInference( + sessionId: SessionId, + stageId: StageId, + contextPack: ContextPack, + stageConfig: StageConfig, + timeoutMs: Long, + ): InferenceResult = when (strategy) { + is ReplayStrategy.SkipInference -> { + // bypass router entirely — use recorded artifact + val request = InferenceRequest( + requestId = InferenceRequestId(UUID.randomUUID().toString()), + sessionId = sessionId, + stageId = stageId, + contextPack = contextPack, + generationConfig = GenerationConfig( + temperature = 0.0, + topP = 1.0, + maxTokens = 0, + seed = 0L, + ), + ) + runCatching { replayProvider.infer(request) } + .fold( + onSuccess = { InferenceResult.Success(it) }, + onFailure = { e -> + if (e is ReplayArtifactMissingException) { + InferenceResult.Failed(e.message ?: "replay artifact missing") + } else { + throw e + } + }, + ) + } + else -> super.runInference(sessionId, stageId, contextPack, stageConfig, timeoutMs) + } + + override fun mapValidationOutcome( + sessionId: SessionId, + stageId: StageId, + context: ValidationContext, + ): StageExecutionResult = when (strategy) { + is ReplayStrategy.SkipValidation -> { + // Replay-only: emit a synthetic ArtifactValidatedEvent for every artifact left in + // VALIDATING at this stage. Without this, artifacts are permanently stuck because + // the real validation path (which emits ArtifactValidatedEvent) is bypassed. + val events = repositories.eventStore.read(sessionId) + val alreadyValidated = events + .mapNotNull { (it.payload as? ArtifactValidatedEvent) } + .filter { it.stageId == stageId } + .map { it.artifactId } + .toSet() + events + .mapNotNull { (it.payload as? ArtifactValidatingEvent)?.takeIf { e -> e.stageId == stageId } } + .filter { it.artifactId !in alreadyValidated } + .forEach { emit(sessionId, ArtifactValidatedEvent(it.artifactId, sessionId, stageId)) } + StageExecutionResult.Success(emptyList()) + } + else -> super.mapValidationOutcome(sessionId, stageId, context) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt new file mode 100644 index 00000000..a5594c4a --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -0,0 +1,344 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.domain.ApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.approvals.model.DomainApprovalRequest +import com.correx.core.context.builder.ContextPackBuilder +import com.correx.core.context.model.ContextPack +import com.correx.core.context.model.TokenBudget +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.InferenceCompletedEvent +import com.correx.core.events.events.InferenceFailedEvent +import com.correx.core.events.events.InferenceStartedEvent +import com.correx.core.events.events.InferenceTimeoutEvent +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.OrchestrationResumedEvent +import com.correx.core.events.events.RiskAssessedEvent +import com.correx.core.events.events.TransitionExecutedEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.events.WorkflowFailedEvent +import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.InferenceRequestId +import com.correx.core.events.types.RiskSummaryId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ValidationReportId +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.InferenceRequest +import com.correx.core.inference.InferenceResponse +import com.correx.core.inference.InferenceRouter +import com.correx.core.kernel.execution.WorkflowResult +import com.correx.core.risk.RiskAssessor +import com.correx.core.risk.RiskContext +import com.correx.core.risk.toApprovalTier +import com.correx.core.sessions.ApprovalMode +import com.correx.core.sessions.Session +import com.correx.core.transitions.evaluation.EvaluationContext +import com.correx.core.transitions.execution.StageExecutionResult +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.TransitionDecision +import com.correx.core.transitions.resolution.TransitionResolver +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.pipeline.ValidationOutcome +import com.correx.core.validation.pipeline.ValidationPipeline +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import kotlinx.datetime.Clock +import java.util.* +import java.util.concurrent.* +import java.util.concurrent.atomic.* +import kotlin.coroutines.cancellation.CancellationException + +@SuppressWarnings( + "ForbiddenComment", + "UnusedParameter", + "ReturnCount", + "TooGenericExceptionCaught", + "TooManyFunctions", + "NestedBlockDepth", +) +abstract class SessionOrchestrator( + repositories: OrchestratorRepositories, + engines: OrchestratorEngines, +) { + private val eventStore: EventStore = repositories.eventStore + private val transitionResolver: TransitionResolver = engines.transitionResolver + private val contextPackBuilder: ContextPackBuilder = engines.contextPackBuilder + private val inferenceRouter: InferenceRouter = engines.inferenceRouter + private val validationPipeline: ValidationPipeline = engines.validationPipeline + private val approvalEngine: ApprovalEngine = engines.approvalEngine + private val riskAssessor: RiskAssessor = engines.riskAssessor + private val inferenceRepository: InferenceRepository = repositories.inferenceRepository + internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository + internal abstract val cancellations: ConcurrentHashMap + + abstract suspend fun run( + sessionId: SessionId, + graph: WorkflowGraph, + config: OrchestrationConfig, + ): WorkflowResult + + abstract suspend fun cancel(sessionId: SessionId) + + // --- stage execution --- + + internal suspend fun executeStage( + sessionId: SessionId, + stageId: StageId, + graph: WorkflowGraph, + session: Session, + config: OrchestrationConfig, + ): StageExecutionResult { + val stageConfig = requireNotNull(graph.stages[stageId]) { + "Stage '${stageId.value}' not declared in workflow graph" + } + val contextPack = contextPackBuilder.build( + id = ContextPackId(UUID.randomUUID().toString()), + sessionId = sessionId, + stageId = stageId, + entries = emptyList(), + budget = TokenBudget(limit = stageConfig.tokenBudget), + ) + + val inferenceResult = runInference(sessionId, stageId, contextPack, stageConfig, config.stageTimeoutMs) + return when (inferenceResult) { + is InferenceResult.Cancelled -> StageExecutionResult.Failure("CANCELLED", retryable = false) + is InferenceResult.Failed -> StageExecutionResult.Failure(inferenceResult.reason, retryable = true) + is InferenceResult.Success -> { + if (isCancelled(sessionId)) return StageExecutionResult.Failure("CANCELLED", retryable = false) + val validationCtx = ValidationContext(graph = graph, sessionState = session.state) + mapValidationOutcome(sessionId, stageId, validationCtx) + } + } + } + + internal open fun mapValidationOutcome( + sessionId: SessionId, + stageId: StageId, + context: ValidationContext, + ): StageExecutionResult = when (val outcome = validationPipeline.validate(context)) { + is ValidationOutcome.Passed -> StageExecutionResult.Success(emptyList()) + is ValidationOutcome.Rejected -> + StageExecutionResult.Failure("validation failed", retryable = outcome.retryable) + is ValidationOutcome.NeedsApproval -> handleApproval(sessionId, stageId, outcome) + } + + internal open suspend fun runInference( + sessionId: SessionId, + stageId: StageId, + contextPack: ContextPack, + stageConfig: StageConfig, + timeoutMs: Long, + ): InferenceResult { + val provider = inferenceRouter.route(stageId, stageConfig.requiredCapabilities) + val requestId = InferenceRequestId(UUID.randomUUID().toString()) + val request = InferenceRequest( + requestId = requestId, + sessionId = sessionId, + stageId = stageId, + contextPack = contextPack, + generationConfig = stageConfig.generationConfig, + ) + + emit(sessionId, InferenceStartedEvent(requestId, sessionId, stageId, provider.id)) + + if (isCancelled(sessionId)) return InferenceResult.Cancelled + + return try { + val response = withTimeout(timeoutMs) { provider.infer(request) } + + if (isCancelled(sessionId)) return InferenceResult.Cancelled + + emit( + sessionId, + InferenceCompletedEvent( + requestId = requestId, + sessionId = sessionId, + stageId = stageId, + providerId = provider.id, + tokensUsed = response.tokensUsed, + latencyMs = response.latencyMs, + ), + ) + + InferenceResult.Success(response) + } catch (e: TimeoutCancellationException) { + emit( + sessionId, + InferenceTimeoutEvent( + requestId = requestId, + sessionId = sessionId, + stageId = stageId, + providerId = provider.id, + timeoutMs = timeoutMs, + ), + ) + InferenceResult.Failed("TIMEOUT: ${e.message}") + } catch (e: CancellationException) { + throw e // never swallow + } catch (e: Exception) { + emit( + sessionId, + InferenceFailedEvent( + requestId = requestId, + sessionId = sessionId, + stageId = stageId, + providerId = provider.id, + reason = e.message ?: "unknown", + ), + ) + InferenceResult.Failed(e.message ?: "inference failed") + } + } + + // --- transition helpers --- + + internal fun resolveTransition( + graph: WorkflowGraph, + sessionId: SessionId, + currentStageId: StageId, + ): TransitionDecision { + val ctx = EvaluationContext(sessionId, currentStageId, emptyMap()) + return transitionResolver.resolve(graph, ctx) + } + + internal fun isTerminal(graph: WorkflowGraph, stageId: StageId): Boolean = + graph.transitions.none { it.from == stageId } + + internal fun advanceStage( + sessionId: SessionId, + fromStageId: StageId, + decision: TransitionDecision.Move, + ): StageId { + emit(sessionId, TransitionExecutedEvent(sessionId, fromStageId, decision.to, decision.transitionId)) + return decision.to + } + + // --- terminal state helpers --- + + internal fun emitWorkflowStarted(sessionId: SessionId, graph: WorkflowGraph, config: OrchestrationConfig) { + emit( + sessionId, + WorkflowStartedEvent( + sessionId = sessionId, + startStageId = graph.start, + retryPolicy = config.retryPolicy, + ), + ) + } + + internal fun completeWorkflow( + sessionId: SessionId, + terminalStageId: StageId, + stageCount: Int, + ): WorkflowResult.Completed { + emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount)) + cancellations.remove(sessionId) + return WorkflowResult.Completed(sessionId, terminalStageId) + } + + internal fun failWorkflow( + sessionId: SessionId, + stageId: StageId, + reason: String, + retryExhausted: Boolean, + ): WorkflowResult.Failed { + emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted)) + cancellations.remove(sessionId) + return WorkflowResult.Failed(sessionId, reason, retryExhausted) + } + + internal fun handleCancellation( + sessionId: SessionId, + stageId: StageId, + ): WorkflowResult.Cancelled { + emit(sessionId, WorkflowFailedEvent(sessionId, stageId, "CANCELLED", retryExhausted = false)) + cancellations.remove(sessionId) + return WorkflowResult.Cancelled(sessionId) + } + + // --- cancellation --- + + internal fun isCancelled(sessionId: SessionId): Boolean = + cancellations[sessionId]?.get() == true + + // --- event emission --- + + internal fun emit(sessionId: SessionId, payload: EventPayload) { + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = payload, + ), + ) + } + + // --- private functions --- + + private fun handleApproval( + sessionId: SessionId, + stageId: StageId, + outcome: ValidationOutcome.NeedsApproval, + ): StageExecutionResult { + emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING")) + + val state = orchestrationRepository.getState(sessionId) + val inferenceState = inferenceRepository.getInferenceState(sessionId) + val riskSummary = riskAssessor.assess( + RiskContext( + validationReport = outcome.request.validationReport, + orchestrationState = state, + inferenceState = inferenceState, + ), + ) + + val riskSummaryId = RiskSummaryId(UUID.randomUUID().toString()) + emit( + sessionId, + RiskAssessedEvent(sessionId, stageId, riskSummaryId, riskSummary.level, riskSummary.recommendedAction), + ) + + val domainRequest = DomainApprovalRequest( + id = ApprovalRequestId(UUID.randomUUID().toString()), + tier = riskSummary.level.toApprovalTier(), + validationReportId = ValidationReportId(UUID.randomUUID().toString()), + riskSummaryId = riskSummaryId, + timestamp = Clock.System.now(), + ) + val approvalCtx = ApprovalContext( + identity = ApprovalScopeIdentity(sessionId, stageId, null), + mode = ApprovalMode.PROMPT, + ) + + val decision = approvalEngine.evaluate(domainRequest, approvalCtx, emptyList(), Clock.System.now()) + + return if (decision.state == ApprovalStatus.COMPLETED) { + emit(sessionId, OrchestrationResumedEvent(sessionId, stageId)) + StageExecutionResult.Success(emptyList()) + } else { + StageExecutionResult.Failure("approval pending or rejected", retryable = false) + } + } +} + +internal sealed interface InferenceResult { + data class Success(val response: InferenceResponse) : InferenceResult + data class Failed(val reason: String) : InferenceResult + data object Cancelled : InferenceResult +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/replay/ReplayInferenceProvider.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/replay/ReplayInferenceProvider.kt new file mode 100644 index 00000000..b84a0188 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/replay/ReplayInferenceProvider.kt @@ -0,0 +1,52 @@ +package com.correx.core.kernel.replay + +import com.correx.core.events.events.InferenceCompletedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ProviderId +import com.correx.core.inference.CapabilityScore +import com.correx.core.inference.FinishReason +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRequest +import com.correx.core.inference.InferenceResponse +import com.correx.core.inference.ProviderHealth +import com.correx.core.inference.Token +import com.correx.core.inference.Tokenizer +import com.correx.core.kernel.retry.exception.ReplayArtifactMissingException + +class ReplayInferenceProvider( + private val eventStore: EventStore, + override val name: String = "Replay Provider", +) : InferenceProvider { + + override val id: ProviderId = ProviderId("replay-provider") + + @Suppress("MagicNumber") + override val tokenizer: Tokenizer = object : Tokenizer { + override suspend fun tokenize(text: String): List = + List(text.chunked(4).size) { i -> Token(i) } // 1 token ≈ 4 chars + + override suspend fun countTokens(text: String): Int = + (text.length + 3) / 4 + } + + override suspend fun infer(request: InferenceRequest): InferenceResponse { + val recorded = eventStore.read(request.sessionId) + .map { it.payload } + .filterIsInstance() + .firstOrNull { it.stageId == request.stageId } + + ?: throw ReplayArtifactMissingException(request.sessionId, request.stageId) + + return InferenceResponse( + requestId = request.requestId, + text = "", // inference text is not recorded — replay produces empty text by design + finishReason = FinishReason.Stop, + tokensUsed = recorded.tokensUsed, + latencyMs = recorded.latencyMs, + ) + } + + override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy + + override fun capabilities(): Set = emptySet() +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/retry/DefaultRetryCoordinator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/retry/DefaultRetryCoordinator.kt new file mode 100644 index 00000000..f50de8c8 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/retry/DefaultRetryCoordinator.kt @@ -0,0 +1,58 @@ +package com.correx.core.kernel.retry + +import com.correx.core.events.events.NewEvent +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.events.events.RetryAttemptedEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.events.EventMetadata +import kotlinx.datetime.Clock +import kotlinx.coroutines.delay +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlin.coroutines.cancellation.CancellationException + +class DefaultRetryCoordinator( + private val eventStore: EventStore, +) : RetryCoordinator { + + override suspend fun shouldRetry( + sessionId: SessionId, + stageId: StageId, + currentAttempt: Int, + policy: RetryPolicy, + failureReason: String, + ): Boolean { + if (currentAttempt >= policy.maxAttempts) return false + + val eventId = EventId(UUID.randomUUID().toString()) + val event = NewEvent( + metadata = EventMetadata( + eventId = eventId, + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = RetryAttemptedEvent( + sessionId = sessionId, + stageId = stageId, + attemptNumber = currentAttempt + 1, + maxAttempts = policy.maxAttempts, + failureReason = failureReason, + ), + ) + + eventStore.append(event) + + if (policy.backoffMs > 0L) { + delay(policy.backoffMs) + } + + return true + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/retry/RetryCoordinator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/retry/RetryCoordinator.kt new file mode 100644 index 00000000..0780e2c4 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/retry/RetryCoordinator.kt @@ -0,0 +1,15 @@ +package com.correx.core.kernel.retry + +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId + +interface RetryCoordinator { + suspend fun shouldRetry( + sessionId: SessionId, + stageId: StageId, + currentAttempt: Int, + policy: RetryPolicy, + failureReason: String, + ): Boolean +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/retry/exception/ReplayArtifactMissingException.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/retry/exception/ReplayArtifactMissingException.kt new file mode 100644 index 00000000..c58921db --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/retry/exception/ReplayArtifactMissingException.kt @@ -0,0 +1,9 @@ +package com.correx.core.kernel.retry.exception + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId + +class ReplayArtifactMissingException( + val sessionId: SessionId, + val stageId: StageId, +) : Exception("no recorded inference artifact for session=${sessionId.value} stage=${stageId.value}") diff --git a/core/observability/build.gradle b/core/observability/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/core/observability/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/core/observability/src/main/kotlin/com/correx/core/observability/Module.kt b/core/observability/src/main/kotlin/com/correx/core/observability/Module.kt new file mode 100644 index 00000000..e2599e12 --- /dev/null +++ b/core/observability/src/main/kotlin/com/correx/core/observability/Module.kt @@ -0,0 +1,3 @@ +package com.correx.core.observability + +object Module diff --git a/core/policies/build.gradle b/core/policies/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/core/policies/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/core/policies/src/main/kotlin/com/correx/core/policies/Module.kt b/core/policies/src/main/kotlin/com/correx/core/policies/Module.kt new file mode 100644 index 00000000..5d8d8389 --- /dev/null +++ b/core/policies/src/main/kotlin/com/correx/core/policies/Module.kt @@ -0,0 +1,3 @@ +package com.correx.core.policies + +object Module diff --git a/core/risk/build.gradle b/core/risk/build.gradle new file mode 100644 index 00000000..91a5069f --- /dev/null +++ b/core/risk/build.gradle @@ -0,0 +1,14 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' +} + +dependencies { + implementation project(':core:events') + implementation project(':core:approvals') + implementation project(':core:validation') + implementation project(':core:inference') + + testImplementation "org.junit.jupiter:junit-jupiter" + testImplementation "org.jetbrains.kotlin:kotlin-test" +} diff --git a/core/risk/src/main/kotlin/com/correx/core/risk/DefaultRiskAssessor.kt b/core/risk/src/main/kotlin/com/correx/core/risk/DefaultRiskAssessor.kt new file mode 100644 index 00000000..02aea6cc --- /dev/null +++ b/core/risk/src/main/kotlin/com/correx/core/risk/DefaultRiskAssessor.kt @@ -0,0 +1,48 @@ +package com.correx.core.risk + +import com.correx.core.events.risk.RiskLevel +import com.correx.core.events.risk.RiskSignal +import com.correx.core.events.risk.RiskSummary +import com.correx.core.inference.InferenceStatus +import com.correx.core.validation.model.ValidationSeverity + +class DefaultRiskAssessor : RiskAssessor { + override fun assess(context: RiskContext): RiskSummary { + val (report, state, inferenceState) = context + val signals = mutableListOf() + + val errorCount = report.sections.sumOf { s -> + s.issues.count { it.severity == ValidationSeverity.ERROR } + } + if (errorCount > 0) { + signals += RiskSignal.ValidationErrors(errorCount) + } + + report.sections.flatMap { it.issues } + .firstOrNull { it.code.contains("CYCLE", ignoreCase = true) } + ?.let { signals += RiskSignal.CycleWithoutExit(it.code) } + + state.retryPolicy?.let { policy -> + if (state.retryCount >= policy.maxAttempts - 1) { + signals += RiskSignal.RepeatedFailure( + reason = state.failureReason ?: "unknown", + count = state.retryCount, + ) + } + } + + state.currentStageId?.let { stageId -> + inferenceState.records + .firstOrNull { it.stageId == stageId && it.status == InferenceStatus.TIMED_OUT } + ?.let { signals += RiskSignal.InferenceTimeout(it.latencyMs ?: 0L) } + } + + val level = signals.fold(RiskLevel.LOW) { acc, signal -> maxOf(acc, signal.toRiskLevel()) } + + return RiskSummary( + level = level, + signals = signals, + recommendedAction = level.toRiskAction(), + ) + } +} diff --git a/core/risk/src/main/kotlin/com/correx/core/risk/NoOpRiskAssessor.kt b/core/risk/src/main/kotlin/com/correx/core/risk/NoOpRiskAssessor.kt new file mode 100644 index 00000000..9fb0f911 --- /dev/null +++ b/core/risk/src/main/kotlin/com/correx/core/risk/NoOpRiskAssessor.kt @@ -0,0 +1,18 @@ +package com.correx.core.risk + +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.risk.RiskLevel +import com.correx.core.events.risk.RiskSummary + +/** + * No-op implementation of [RiskAssessor] used during deterministic replay. + * Replay reconstructs approval decisions from the event log (ApprovalGrantedEvent / + * ApprovalDeniedEvent) — live risk assessment must never run on the replay path. + */ +class NoOpRiskAssessor : RiskAssessor { + override fun assess(context: RiskContext): RiskSummary = RiskSummary( + level = RiskLevel.LOW, + signals = emptyList(), + recommendedAction = RiskAction.PROCEED, + ) +} diff --git a/core/risk/src/main/kotlin/com/correx/core/risk/RiskAssessor.kt b/core/risk/src/main/kotlin/com/correx/core/risk/RiskAssessor.kt new file mode 100644 index 00000000..3b3cec55 --- /dev/null +++ b/core/risk/src/main/kotlin/com/correx/core/risk/RiskAssessor.kt @@ -0,0 +1,7 @@ +package com.correx.core.risk + +import com.correx.core.events.risk.RiskSummary + +interface RiskAssessor { + fun assess(context: RiskContext): RiskSummary +} diff --git a/core/risk/src/main/kotlin/com/correx/core/risk/RiskContext.kt b/core/risk/src/main/kotlin/com/correx/core/risk/RiskContext.kt new file mode 100644 index 00000000..33a60b1d --- /dev/null +++ b/core/risk/src/main/kotlin/com/correx/core/risk/RiskContext.kt @@ -0,0 +1,25 @@ +package com.correx.core.risk + +import com.correx.core.events.orchestration.OrchestrationState +import com.correx.core.inference.InferenceState +import com.correx.core.validation.model.ValidationReport + +/** + * Immutable snapshot of all signals available for risk assessment at a given decision point. + * + * [validationReport] — issues raised by the validation pipeline for the current stage. + * [orchestrationState] — current orchestration state, including retry count, failure reason, + * and retry policy. Carries execution-level signals that previously had no path into risk + * evaluation (e.g. [OrchestrationState.retryCount], [OrchestrationState.retryPolicy]). + * [inferenceState] — inference history, used to detect timeouts for the current stage. + * + * Note: [StageOutcome] lives in core:kernel which depends on core:risk, so it cannot be + * embedded here directly. The relevant execution signals it carries (retry count, failure + * reason) are proxied through [orchestrationState], which is populated from config before + * the first assessment call. + */ +data class RiskContext( + val validationReport: ValidationReport, + val orchestrationState: OrchestrationState, + val inferenceState: InferenceState, +) diff --git a/core/risk/src/main/kotlin/com/correx/core/risk/TierMapping.kt b/core/risk/src/main/kotlin/com/correx/core/risk/TierMapping.kt new file mode 100644 index 00000000..cb42fbdb --- /dev/null +++ b/core/risk/src/main/kotlin/com/correx/core/risk/TierMapping.kt @@ -0,0 +1,27 @@ +package com.correx.core.risk + +import com.correx.core.approvals.Tier +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.risk.RiskLevel +import com.correx.core.events.risk.RiskSignal + +fun RiskLevel.toApprovalTier(): Tier = when (this) { + RiskLevel.LOW -> Tier.T1 + RiskLevel.MEDIUM -> Tier.T2 + RiskLevel.HIGH -> Tier.T3 + RiskLevel.CRITICAL -> Tier.T4 +} + +internal fun RiskLevel.toRiskAction(): RiskAction = when (this) { + RiskLevel.LOW -> RiskAction.PROCEED + RiskLevel.MEDIUM -> RiskAction.PROMPT_USER + RiskLevel.HIGH -> RiskAction.PROMPT_USER + RiskLevel.CRITICAL -> RiskAction.BLOCK +} + +internal fun RiskSignal.toRiskLevel(): RiskLevel = when (this) { + is RiskSignal.ValidationErrors -> RiskLevel.MEDIUM + is RiskSignal.CycleWithoutExit -> RiskLevel.MEDIUM + is RiskSignal.InferenceTimeout -> RiskLevel.MEDIUM + is RiskSignal.RepeatedFailure -> RiskLevel.HIGH +} diff --git a/core/risk/src/test/kotlin/com/correx/core/risk/DefaultRiskAssessorTest.kt b/core/risk/src/test/kotlin/com/correx/core/risk/DefaultRiskAssessorTest.kt new file mode 100644 index 00000000..57d0c51f --- /dev/null +++ b/core/risk/src/test/kotlin/com/correx/core/risk/DefaultRiskAssessorTest.kt @@ -0,0 +1,201 @@ +package com.correx.core.risk + +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.events.orchestration.OrchestrationState +import com.correx.core.events.orchestration.OrchestrationStatus +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.risk.RiskLevel +import com.correx.core.events.risk.RiskSignal +import com.correx.core.events.types.InferenceRequestId +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.InferenceRecord +import com.correx.core.inference.InferenceState +import com.correx.core.inference.InferenceStatus +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationReport +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.model.ValidationSeverity +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DefaultRiskAssessorTest { + + private val assessor = DefaultRiskAssessor() + private val sessionId = SessionId("s1") + private val stageId = StageId("stage-1") + + private fun emptyReport() = ValidationReport(sections = emptyList()) + private fun emptyInferenceState() = InferenceState() + private fun baseState() = OrchestrationState(currentStageId = stageId, status = OrchestrationStatus.RUNNING) + + private fun ctx( + report: ValidationReport = emptyReport(), + state: OrchestrationState = baseState(), + inferenceState: InferenceState = emptyInferenceState(), + ) = RiskContext( + validationReport = report, + orchestrationState = state, + inferenceState = inferenceState, + ) + + @Test + fun `no signals yields LOW risk and PROCEED`() { + val summary = assessor.assess(ctx()) + + assertEquals(RiskLevel.LOW, summary.level) + assertEquals(RiskAction.PROCEED, summary.recommendedAction) + assertTrue(summary.signals.isEmpty()) + } + + @Test + fun `validation errors produce ValidationErrors signal at MEDIUM`() { + val report = ValidationReport( + sections = listOf( + ValidationSection( + name = "schema", + issues = listOf( + ValidationIssue(code = "MISSING_FIELD", message = "field required", + severity = ValidationSeverity.ERROR), + ValidationIssue(code = "MISSING_FIELD", message = "other field", + severity = ValidationSeverity.ERROR), + ), + ), + ), + ) + + val summary = assessor.assess(ctx(report = report)) + + assertEquals(RiskLevel.MEDIUM, summary.level) + assertEquals(RiskAction.PROMPT_USER, summary.recommendedAction) + val signal = summary.signals.filterIsInstance().single() + assertEquals(2, signal.errorCount) + } + + @Test + fun `cycle issue in validation report produces CycleWithoutExit signal at MEDIUM`() { + val report = ValidationReport( + sections = listOf( + ValidationSection( + name = "semantic", + issues = listOf( + ValidationIssue(code = "CYCLE_WITHOUT_POLICY", message = "cycle detected", + severity = ValidationSeverity.WARNING), + ), + ), + ), + ) + + val summary = assessor.assess(ctx(report = report)) + + assertEquals(RiskLevel.MEDIUM, summary.level) + val signal = summary.signals.filterIsInstance().single() + assertEquals("CYCLE_WITHOUT_POLICY", signal.cycleId) + } + + @Test + fun `retryCount at maxAttempts minus one produces RepeatedFailure signal at HIGH`() { + val state = OrchestrationState( + currentStageId = stageId, + status = OrchestrationStatus.RUNNING, + retryCount = 2, + failureReason = "inference failed", + retryPolicy = RetryPolicy(maxAttempts = 3), + ) + + val summary = assessor.assess(ctx(state = state)) + + assertEquals(RiskLevel.HIGH, summary.level) + assertEquals(RiskAction.PROMPT_USER, summary.recommendedAction) + val signal = summary.signals.filterIsInstance().single() + assertEquals(2, signal.count) + assertEquals("inference failed", signal.reason) + } + + @Test + fun `no retry policy skips RepeatedFailure signal even at high retryCount`() { + val state = OrchestrationState( + currentStageId = stageId, + status = OrchestrationStatus.RUNNING, + retryCount = 99, + retryPolicy = null, + ) + + val summary = assessor.assess(ctx(state = state)) + + assertEquals(RiskLevel.LOW, summary.level) + assertTrue(summary.signals.filterIsInstance().isEmpty()) + } + + @Test + fun `timed-out inference record for current stage produces InferenceTimeout signal at MEDIUM`() { + val inferenceState = InferenceState( + records = listOf( + InferenceRecord( + requestId = InferenceRequestId("r1"), + sessionId = sessionId, + stageId = stageId, + providerId = ProviderId("p1"), + status = InferenceStatus.TIMED_OUT, + latencyMs = 30_000L, + ), + ), + ) + + val summary = assessor.assess(ctx(inferenceState = inferenceState)) + + assertEquals(RiskLevel.MEDIUM, summary.level) + val signal = summary.signals.filterIsInstance().single() + assertEquals(30_000L, signal.elapsedMs) + } + + @Test + fun `timed-out inference for different stage does not produce signal`() { + val inferenceState = InferenceState( + records = listOf( + InferenceRecord( + requestId = InferenceRequestId("r1"), + sessionId = sessionId, + stageId = StageId("other-stage"), + providerId = ProviderId("p1"), + status = InferenceStatus.TIMED_OUT, + latencyMs = 30_000L, + ), + ), + ) + + val summary = assessor.assess(ctx(inferenceState = inferenceState)) + + assertEquals(RiskLevel.LOW, summary.level) + assertTrue(summary.signals.filterIsInstance().isEmpty()) + } + + @Test + fun `multiple signals fold to highest risk level`() { + val report = ValidationReport( + sections = listOf( + ValidationSection( + name = "schema", + issues = listOf( + ValidationIssue(code = "MISSING_FIELD", message = "required", + severity = ValidationSeverity.ERROR), + ), + ), + ), + ) + val state = OrchestrationState( + currentStageId = stageId, + status = OrchestrationStatus.RUNNING, + retryCount = 2, + failureReason = "timeout", + retryPolicy = RetryPolicy(maxAttempts = 3), + ) + + val summary = assessor.assess(ctx(report = report, state = state)) + + assertEquals(RiskLevel.HIGH, summary.level) + assertEquals(2, summary.signals.size) + } +} diff --git a/core/router/build.gradle b/core/router/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/core/router/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/core/router/src/main/kotlin/com/correx/core/router/Module.kt b/core/router/src/main/kotlin/com/correx/core/router/Module.kt new file mode 100644 index 00000000..3d2dd20a --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/Module.kt @@ -0,0 +1,3 @@ +package com.correx.core.router + +object Module diff --git a/core/sessions/build.gradle b/core/sessions/build.gradle new file mode 100644 index 00000000..3de4ce66 --- /dev/null +++ b/core/sessions/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + testImplementation(testFixtures(project(":testing:contracts"))) +} diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/ApprovalMode.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/ApprovalMode.kt new file mode 100644 index 00000000..c22e2757 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/ApprovalMode.kt @@ -0,0 +1,11 @@ +package com.correx.core.sessions + +import kotlinx.serialization.Serializable + +@Serializable +enum class ApprovalMode { + DENY, + PROMPT, + AUTO, + YOLO +} \ No newline at end of file diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt new file mode 100644 index 00000000..56173597 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt @@ -0,0 +1,59 @@ +package com.correx.core.sessions + +import com.correx.core.events.events.SessionCompletedEvent +import com.correx.core.events.events.SessionFailedEvent +import com.correx.core.events.events.SessionPausedEvent +import com.correx.core.events.events.SessionResumedEvent +import com.correx.core.events.events.SessionStartedEvent +import com.correx.core.events.events.StageCompletedEvent +import com.correx.core.events.events.StageFailedEvent +import com.correx.core.events.events.StageStartedEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TransitionExecutedEvent + +class DefaultSessionReducer : SessionReducer { + + override fun reduce( + state: SessionState, + event: StoredEvent + ): SessionState { + + val payload = event.payload + + val newStatus = when (payload) { + + is SessionStartedEvent -> + SessionStatus.ACTIVE + + is SessionPausedEvent -> + SessionStatus.PAUSED + + is SessionResumedEvent -> + SessionStatus.ACTIVE + + is SessionCompletedEvent -> + SessionStatus.COMPLETED + + is SessionFailedEvent, + is StageFailedEvent -> + SessionStatus.FAILED + + is StageStartedEvent, + is StageCompletedEvent, + is TransitionExecutedEvent -> + SessionStatus.ACTIVE + + else -> + state.status + } + + val createdAt = state.createdAt + ?: event.metadata.timestamp + + return state.copy( + status = newStatus, + createdAt = createdAt, + updatedAt = event.metadata.timestamp + ) + } +} \ No newline at end of file diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionRepository.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionRepository.kt new file mode 100644 index 00000000..ff0ec3e1 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionRepository.kt @@ -0,0 +1,16 @@ +package com.correx.core.sessions + +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.replay.EventReplayer + +class DefaultSessionRepository( + private val replayer: EventReplayer +) { + + fun getSession(sessionId: SessionId): Session = rebuild(sessionId) + + fun rebuild(sessionId: SessionId): Session = Session( + sessionId, + replayer.rebuild(sessionId), + ) +} \ No newline at end of file diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/Session.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/Session.kt new file mode 100644 index 00000000..b28c63bd --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/Session.kt @@ -0,0 +1,8 @@ +package com.correx.core.sessions + +import com.correx.core.events.types.SessionId + +data class Session( + val sessionId: SessionId, + val state: SessionState, +) diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionCounterProjection.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionCounterProjection.kt new file mode 100644 index 00000000..30fabc84 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionCounterProjection.kt @@ -0,0 +1,19 @@ +package com.correx.core.sessions + +import com.correx.core.events.events.StoredEvent +import com.correx.core.sessions.projections.Projection + +class SessionCounterProjection( + private val sessionId: String +) : Projection { + + override fun initial(): SessionCounterState = + SessionCounterState(sessionId, 0) + + override fun apply( + state: SessionCounterState, + event: StoredEvent + ): SessionCounterState { + return state.copy(count = state.count + 1) + } +} \ No newline at end of file diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionCounterState.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionCounterState.kt new file mode 100644 index 00000000..6e51655f --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionCounterState.kt @@ -0,0 +1,6 @@ +package com.correx.core.sessions + +data class SessionCounterState( + val sessionId: String, + val count: Int +) \ No newline at end of file diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionProjector.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionProjector.kt new file mode 100644 index 00000000..1bd6b63c --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionProjector.kt @@ -0,0 +1,19 @@ +package com.correx.core.sessions + +import com.correx.core.events.events.StoredEvent +import com.correx.core.sessions.projections.Projection + +class SessionProjector( + private val reducer: SessionReducer +) : Projection { + + override fun initial(): SessionState = + SessionState( + status = SessionStatus.CREATED + ) + + override fun apply( + state: SessionState, + event: StoredEvent + ): SessionState = reducer.reduce(state, event) +} \ No newline at end of file diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionReducer.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionReducer.kt new file mode 100644 index 00000000..047c8f8f --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionReducer.kt @@ -0,0 +1,10 @@ +package com.correx.core.sessions + +import com.correx.core.events.events.StoredEvent + +interface SessionReducer { + fun reduce( + state: SessionState, + event: StoredEvent + ): SessionState +} \ No newline at end of file diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt new file mode 100644 index 00000000..d087e90b --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt @@ -0,0 +1,10 @@ +package com.correx.core.sessions + +import kotlinx.datetime.Instant + +data class SessionState( + val status: SessionStatus, + val createdAt: Instant? = null, + val updatedAt: Instant? = null, + val invalidTransitions: Int = 0, +) diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionStatus.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionStatus.kt new file mode 100644 index 00000000..4a1217be --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionStatus.kt @@ -0,0 +1,10 @@ +package com.correx.core.sessions + +enum class SessionStatus { + CREATED, + ACTIVE, + PAUSED, + COMPLETED, + FAILED, + ; +} \ No newline at end of file diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/TransitionResult.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/TransitionResult.kt new file mode 100644 index 00000000..7b0f3d61 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/TransitionResult.kt @@ -0,0 +1,6 @@ +package com.correx.core.sessions + +sealed interface TransitionResult { + data class Applied(val newState: SessionStatus) : TransitionResult + data object Rejected : TransitionResult +} \ No newline at end of file diff --git a/core/sessions/src/test/kotlin/com/correx/core/sessions/projections/SessionCounterProjectionTest.kt b/core/sessions/src/test/kotlin/com/correx/core/sessions/projections/SessionCounterProjectionTest.kt new file mode 100644 index 00000000..960f4b54 --- /dev/null +++ b/core/sessions/src/test/kotlin/com/correx/core/sessions/projections/SessionCounterProjectionTest.kt @@ -0,0 +1,5 @@ +package com.correx.core.sessions.projections + +import com.correx.testing.contracts.fixtures.projections.CountingProjectionContractTest + +class SessionCounterProjectionTest : CountingProjectionContractTest() \ No newline at end of file diff --git a/core/stages/build.gradle b/core/stages/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/core/stages/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/core/stages/src/main/kotlin/com/correx/core/stages/Module.kt b/core/stages/src/main/kotlin/com/correx/core/stages/Module.kt new file mode 100644 index 00000000..95f480dc --- /dev/null +++ b/core/stages/src/main/kotlin/com/correx/core/stages/Module.kt @@ -0,0 +1,3 @@ +package com.correx.core.stages + +object Module diff --git a/core/tools/build.gradle b/core/tools/build.gradle new file mode 100644 index 00000000..a363773d --- /dev/null +++ b/core/tools/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + implementation(project(":core:approvals")) + implementation(project(":core:sessions")) + testImplementation "org.jetbrains.kotlin:kotlin-test" +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolReducer.kt b/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolReducer.kt new file mode 100644 index 00000000..944c7157 --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolReducer.kt @@ -0,0 +1,52 @@ +package com.correx.core.tools + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolExecutionFailedEvent +import com.correx.core.events.events.ToolExecutionRejectedEvent +import com.correx.core.events.events.ToolExecutionStartedEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.state.ToolInvocationRecord +import com.correx.core.tools.state.ToolInvocationStatus +import com.correx.core.tools.state.ToolState + +class DefaultToolReducer : ToolReducer { + override fun reduce(state: ToolState, event: StoredEvent): ToolState = + when (val p = event.payload) { + is ToolInvocationRequestedEvent -> state.copy( + invocations = state.invocations + ToolInvocationRecord( + invocationId = p.invocationId, + toolName = p.toolName, + tier = p.tier, + request = p.request, + status = ToolInvocationStatus.REQUESTED, + requestedAt = event.metadata.timestamp + ) + ) + is ToolExecutionStartedEvent -> state.updateRecord(p.invocationId) { + it.copy(status = ToolInvocationStatus.STARTED) + } + is ToolExecutionCompletedEvent -> state.updateRecord(p.invocationId) { + it.copy( + status = ToolInvocationStatus.COMPLETED, + receipt = p.receipt, + completedAt = event.metadata.timestamp + ) + } + is ToolExecutionFailedEvent -> state.updateRecord(p.invocationId) { + it.copy(status = ToolInvocationStatus.FAILED, completedAt = event.metadata.timestamp) + } + is ToolExecutionRejectedEvent -> state.updateRecord(p.invocationId) { + it.copy(status = ToolInvocationStatus.REJECTED, completedAt = event.metadata.timestamp) + } + else -> state + } +} + +private fun ToolState.updateRecord( + invocationId: ToolInvocationId, + transform: (ToolInvocationRecord) -> ToolInvocationRecord +): ToolState = copy( + invocations = invocations.map { if (it.invocationId == invocationId) transform(it) else it } +) diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolRepository.kt b/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolRepository.kt new file mode 100644 index 00000000..0f943771 --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolRepository.kt @@ -0,0 +1,9 @@ +package com.correx.core.tools + +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.core.tools.state.ToolState + +class DefaultToolRepository(private val replayer: EventReplayer) { + fun getToolState(sessionId: SessionId): ToolState = replayer.rebuild(sessionId) +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/Module.kt b/core/tools/src/main/kotlin/com/correx/core/tools/Module.kt new file mode 100644 index 00000000..e7279e5b --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/Module.kt @@ -0,0 +1,3 @@ +package com.correx.core.tools + +object Module diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/ToolProjector.kt b/core/tools/src/main/kotlin/com/correx/core/tools/ToolProjector.kt new file mode 100644 index 00000000..14d9483b --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/ToolProjector.kt @@ -0,0 +1,11 @@ +package com.correx.core.tools + +import com.correx.core.events.events.StoredEvent +import com.correx.core.sessions.projections.Projection +import com.correx.core.tools.state.ToolState + +class ToolProjector(private val reducer: ToolReducer) : Projection { + override fun initial(): ToolState = ToolState() + + override fun apply(state: ToolState, event: StoredEvent): ToolState = reducer.reduce(state, event) +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/ToolReducer.kt b/core/tools/src/main/kotlin/com/correx/core/tools/ToolReducer.kt new file mode 100644 index 00000000..10bec981 --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/ToolReducer.kt @@ -0,0 +1,8 @@ +package com.correx.core.tools + +import com.correx.core.events.events.StoredEvent +import com.correx.core.tools.state.ToolState + +interface ToolReducer { + fun reduce(state: ToolState, event: StoredEvent): ToolState +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/contract/FileAffectingTool.kt b/core/tools/src/main/kotlin/com/correx/core/tools/contract/FileAffectingTool.kt new file mode 100644 index 00000000..495c6dba --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/contract/FileAffectingTool.kt @@ -0,0 +1,8 @@ +package com.correx.core.tools.contract + +import com.correx.core.events.events.ToolRequest +import java.nio.file.Path + +interface FileAffectingTool : Tool { + fun affectedPaths(request: ToolRequest): Set +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt b/core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt new file mode 100644 index 00000000..9966c0c1 --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt @@ -0,0 +1,11 @@ +package com.correx.core.tools.contract + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest + +interface Tool { + val name: String + val tier: Tier + val requiredCapabilities: Set + fun validateRequest(request: ToolRequest): ValidationResult +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt b/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt new file mode 100644 index 00000000..1302b90b --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt @@ -0,0 +1,9 @@ +package com.correx.core.tools.contract + +enum class ToolCapability { + FILE_READ, + FILE_WRITE, + NETWORK_ACCESS, + SHELL_EXEC, + PROCESS_SPAWN +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolExecutor.kt b/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolExecutor.kt new file mode 100644 index 00000000..910598f4 --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolExecutor.kt @@ -0,0 +1,7 @@ +package com.correx.core.tools.contract + +import com.correx.core.events.events.ToolRequest + +interface ToolExecutor { + suspend fun execute(request: ToolRequest): ToolResult +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolResult.kt b/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolResult.kt new file mode 100644 index 00000000..73bda2fa --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolResult.kt @@ -0,0 +1,21 @@ +package com.correx.core.tools.contract + +import com.correx.core.events.types.ToolInvocationId +import kotlinx.serialization.Serializable + +sealed interface ToolResult { + @Serializable + data class Success( + val invocationId: ToolInvocationId, + val output: String, + val exitCode: Int = 0, + val metadata: Map = emptyMap(), + ) : ToolResult + + @Serializable + data class Failure( + val invocationId: ToolInvocationId, + val reason: String, + val recoverable: Boolean, + ) : ToolResult +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/contract/ValidationResult.kt b/core/tools/src/main/kotlin/com/correx/core/tools/contract/ValidationResult.kt new file mode 100644 index 00000000..5c3a991a --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/contract/ValidationResult.kt @@ -0,0 +1,6 @@ +package com.correx.core.tools.contract + +sealed interface ValidationResult { + data object Valid : ValidationResult + data class Invalid(val reason: String) : ValidationResult +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/registry/ToolRegistry.kt b/core/tools/src/main/kotlin/com/correx/core/tools/registry/ToolRegistry.kt new file mode 100644 index 00000000..6732330a --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/registry/ToolRegistry.kt @@ -0,0 +1,8 @@ +package com.correx.core.tools.registry + +import com.correx.core.tools.contract.Tool + +interface ToolRegistry { + fun resolve(name: String): Tool? + fun all(): List +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationRecord.kt b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationRecord.kt new file mode 100644 index 00000000..00dcc958 --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationRecord.kt @@ -0,0 +1,20 @@ +package com.correx.core.tools.state + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolReceipt +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.ToolInvocationId +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class ToolInvocationRecord( + val invocationId: ToolInvocationId, + val toolName: String, + val tier: Tier, + val request: ToolRequest, + val status: ToolInvocationStatus, + val receipt: ToolReceipt? = null, + val requestedAt: Instant, + val completedAt: Instant? = null +) diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationStatus.kt b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationStatus.kt new file mode 100644 index 00000000..da9276b9 --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationStatus.kt @@ -0,0 +1,12 @@ +package com.correx.core.tools.state + +import kotlinx.serialization.Serializable + +@Serializable +enum class ToolInvocationStatus { + REQUESTED, + STARTED, + COMPLETED, + FAILED, + REJECTED +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolState.kt b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolState.kt new file mode 100644 index 00000000..4a381f1b --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolState.kt @@ -0,0 +1,8 @@ +package com.correx.core.tools.state + +import kotlinx.serialization.Serializable + +@Serializable +data class ToolState( + val invocations: List = emptyList() +) diff --git a/core/tools/src/test/kotlin/com/correx/core/tools/DefaultToolReducerTest.kt b/core/tools/src/test/kotlin/com/correx/core/tools/DefaultToolReducerTest.kt new file mode 100644 index 00000000..b20784a0 --- /dev/null +++ b/core/tools/src/test/kotlin/com/correx/core/tools/DefaultToolReducerTest.kt @@ -0,0 +1,138 @@ +package com.correx.core.tools + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolExecutionFailedEvent +import com.correx.core.events.events.ToolExecutionRejectedEvent +import com.correx.core.events.events.ToolExecutionStartedEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.events.ToolInvokedEvent +import com.correx.core.events.events.ToolReceipt +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.state.ToolInvocationStatus +import com.correx.core.tools.state.ToolState +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class DefaultToolReducerTest { + + private val reducer = DefaultToolReducer() + private val timestamp = Instant.parse("2026-01-01T00:00:00Z") + private val invocationId = ToolInvocationId("inv-1") + private val sessionId = SessionId("s-1") + private val stageId = StageId("st-1") + + private val request = ToolRequest( + invocationId = invocationId, + sessionId = sessionId, + stageId = stageId, + toolName = "echo" + ) + + private val receipt = ToolReceipt( + invocationId = invocationId, + toolName = "echo", + exitCode = 0, + outputSummary = "done", + durationMs = 50L, + tier = Tier.T0, + timestamp = timestamp + ) + + private fun storedEvent(payload: com.correx.core.events.events.EventPayload): StoredEvent = + StoredEvent( + metadata = EventMetadata( + eventId = EventId("e-1"), + sessionId = sessionId, + timestamp = timestamp, + schemaVersion = 1, + causationId = null, + correlationId = null + ), + sequence = 1L, + payload = payload + ) + + @Test + fun `ToolInvocationRequestedEvent appends REQUESTED record`() { + val event = storedEvent( + ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request) + ) + val state = reducer.reduce(ToolState(), event) + assertEquals(1, state.invocations.size) + assertEquals(ToolInvocationStatus.REQUESTED, state.invocations[0].status) + assertEquals(invocationId, state.invocations[0].invocationId) + } + + @Test + fun `ToolExecutionStartedEvent transitions record to STARTED`() { + var state = reducer.reduce( + ToolState(), + storedEvent(ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request)) + ) + state = reducer.reduce(state, storedEvent(ToolExecutionStartedEvent(invocationId, sessionId, "echo"))) + assertEquals(ToolInvocationStatus.STARTED, state.invocations[0].status) + } + + @Test + fun `ToolExecutionCompletedEvent transitions to COMPLETED with receipt and completedAt`() { + var state = reducer.reduce( + ToolState(), + storedEvent(ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request)) + ) + state = reducer.reduce(state, storedEvent(ToolExecutionStartedEvent(invocationId, sessionId, "echo"))) + state = reducer.reduce( + state, + storedEvent(ToolExecutionCompletedEvent(invocationId, sessionId, "echo", receipt)) + ) + val record = state.invocations[0] + assertEquals(ToolInvocationStatus.COMPLETED, record.status) + assertNotNull(record.receipt) + assertEquals(0, record.receipt!!.exitCode) + assertNotNull(record.completedAt) + } + + @Test + fun `ToolExecutionFailedEvent transitions to FAILED and sets completedAt`() { + var state = reducer.reduce( + ToolState(), + storedEvent(ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request)) + ) + state = reducer.reduce(state, storedEvent(ToolExecutionFailedEvent(invocationId, sessionId, "echo", "timeout"))) + val record = state.invocations[0] + assertEquals(ToolInvocationStatus.FAILED, record.status) + assertNull(record.receipt) + assertNotNull(record.completedAt) + } + + @Test + fun `ToolExecutionRejectedEvent transitions to REJECTED and sets completedAt`() { + var state = reducer.reduce( + ToolState(), + storedEvent(ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request)) + ) + state = reducer.reduce( + state, + storedEvent(ToolExecutionRejectedEvent(invocationId, sessionId, "echo", Tier.T0, "tier denied")) + ) + val record = state.invocations[0] + assertEquals(ToolInvocationStatus.REJECTED, record.status) + assertNotNull(record.completedAt) + } + + @Test + fun `unrelated event passes through unchanged`() { + val state = ToolState() + val result = reducer.reduce(state, storedEvent(ToolInvokedEvent("test-tool"))) + assertEquals(state, result) + } +} diff --git a/core/tools/src/test/kotlin/com/correx/core/tools/contract/ToolContractTest.kt b/core/tools/src/test/kotlin/com/correx/core/tools/contract/ToolContractTest.kt new file mode 100644 index 00000000..e0a89e7f --- /dev/null +++ b/core/tools/src/test/kotlin/com/correx/core/tools/contract/ToolContractTest.kt @@ -0,0 +1,46 @@ +package com.correx.core.tools.contract + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ToolContractTest { + + private val stubTool = object : Tool { + override val name: String = "stub" + override val tier: Tier = Tier.T0 + override val requiredCapabilities: Set = emptySet() + override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid + } + + private val request = ToolRequest( + invocationId = ToolInvocationId("inv-1"), + sessionId = SessionId("s-1"), + stageId = StageId("st-1"), + toolName = "stub" + ) + + @Test + fun `stub tool returns Valid for any request`() { + assertIs(stubTool.validateRequest(request)) + } + + @Test + fun `ValidationResult Invalid carries reason`() { + val result = ValidationResult.Invalid("missing required parameter") + assertIs(result) + assertEquals("missing required parameter", result.reason) + } + + @Test + fun `tool exposes name tier and capabilities`() { + assertEquals("stub", stubTool.name) + assertEquals(Tier.T0, stubTool.tier) + assertEquals(emptySet(), stubTool.requiredCapabilities) + } +} diff --git a/core/tools/src/test/kotlin/com/correx/core/tools/state/ToolStateTest.kt b/core/tools/src/test/kotlin/com/correx/core/tools/state/ToolStateTest.kt new file mode 100644 index 00000000..8915fdf4 --- /dev/null +++ b/core/tools/src/test/kotlin/com/correx/core/tools/state/ToolStateTest.kt @@ -0,0 +1,57 @@ +package com.correx.core.tools.state + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ToolStateTest { + + private val request = ToolRequest( + invocationId = ToolInvocationId("inv-1"), + sessionId = SessionId("s-1"), + stageId = StageId("st-1"), + toolName = "echo" + ) + + @Test + fun `initial ToolState has empty invocations`() { + assertTrue(ToolState().invocations.isEmpty()) + } + + @Test + fun `ToolInvocationRecord has null receipt and completedAt by default`() { + val record = ToolInvocationRecord( + invocationId = ToolInvocationId("inv-1"), + toolName = "echo", + tier = Tier.T0, + request = request, + status = ToolInvocationStatus.REQUESTED, + requestedAt = Instant.parse("2026-01-01T00:00:00Z") + ) + assertNull(record.receipt) + assertNull(record.completedAt) + } + + @Test + fun `ToolState can accumulate multiple records`() { + val base = ToolInvocationRecord( + invocationId = ToolInvocationId("inv-1"), + toolName = "echo", + tier = Tier.T0, + request = request, + status = ToolInvocationStatus.REQUESTED, + requestedAt = Instant.parse("2026-01-01T00:00:00Z") + ) + val state = ToolState( + invocations = listOf(base, base.copy(invocationId = ToolInvocationId("inv-2"))) + ) + assertEquals(2, state.invocations.size) + } +} diff --git a/core/transitions/build.gradle b/core/transitions/build.gradle new file mode 100644 index 00000000..541e4ca1 --- /dev/null +++ b/core/transitions/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + implementation(project(":core:inference")) +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/CycleExtractor.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/CycleExtractor.kt new file mode 100644 index 00000000..c66a9975 --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/CycleExtractor.kt @@ -0,0 +1,34 @@ +package com.correx.core.transitions.analysis + +import com.correx.core.events.types.StageId +import com.correx.core.transitions.analysis.canonicalization.CycleCanonicalizer.canonicalize +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.state.CycleDfs +import com.correx.core.transitions.state.VisitState + +internal class CycleExtractor( + private val adjacencyBuilder: DeterministicAdjacencyBuilder, + private val dfs: CycleDfs, +) { + + fun extract(graph: WorkflowGraph): List { + + val adjacency = adjacencyBuilder.build(graph) + + val state = mutableMapOf() + val path = mutableListOf() + + val cycles = mutableListOf() + + val sortedNodes = graph.stageIds + .sortedBy { it.value } + + for (node in sortedNodes) { + if (state[node] == null) { + dfs.dfs(node, adjacency, state, path, cycles) + } + } + + return canonicalize(cycles) + } +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/DetectedCycle.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/DetectedCycle.kt new file mode 100644 index 00000000..c67571f1 --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/DetectedCycle.kt @@ -0,0 +1,9 @@ +package com.correx.core.transitions.analysis + +import com.correx.core.events.types.StageId +import com.correx.core.transitions.graph.TransitionEdge + +data class DetectedCycle( + val nodes: List, + val edges: List +) \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/DeterministicAdjacencyBuilder.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/DeterministicAdjacencyBuilder.kt new file mode 100644 index 00000000..3720a66b --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/DeterministicAdjacencyBuilder.kt @@ -0,0 +1,21 @@ +package com.correx.core.transitions.analysis + +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.events.types.StageId + +internal class DeterministicAdjacencyBuilder { + fun build(graph: WorkflowGraph): Map> { + + val grouped = graph.transitions + .groupBy { it.from } + + return grouped.mapValues { (_, edges) -> + edges.sortedWith( + compareBy { it.from.value } + .thenBy { it.id.value } + .thenBy { it.to.value } + ) + } + } +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/canonicalization/CycleCanonicalizer.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/canonicalization/CycleCanonicalizer.kt new file mode 100644 index 00000000..809905d3 --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/analysis/canonicalization/CycleCanonicalizer.kt @@ -0,0 +1,36 @@ +package com.correx.core.transitions.analysis.canonicalization + +import com.correx.core.transitions.analysis.DetectedCycle + +internal object CycleCanonicalizer { + fun canonicalize( + cycles: List + ): List { + val normalized = cycles.map { cycle -> + + if (cycle.nodes.isEmpty()) return@map cycle + + val open = cycle.nodes.dropLast(1) + + val minIndex = open + .withIndex() + .minBy { it.value.value } + .index + + val rotatedNodes = open.drop(minIndex) + open.take(minIndex) + val rotatedEdges = cycle.edges.drop(minIndex) + cycle.edges.take(minIndex) + + val closed = rotatedNodes + rotatedNodes.first() + + DetectedCycle(closed, rotatedEdges) + } + + return normalized + .distinctBy { cycle -> + val nodeKey = cycle.nodes.joinToString("->") { it.value } + val edgeKey = cycle.edges.joinToString("->") { it.id.value } + "$nodeKey|$edgeKey" + } + .sortedBy { it.nodes.first().value } + } +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/evaluation/EvaluationContext.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/evaluation/EvaluationContext.kt new file mode 100644 index 00000000..b078d48e --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/evaluation/EvaluationContext.kt @@ -0,0 +1,10 @@ +package com.correx.core.transitions.evaluation + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId + +data class EvaluationContext( + val sessionId: SessionId, + val currentStage: StageId, + val variables: Map +) \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/evaluation/TransitionConditionEvaluator.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/evaluation/TransitionConditionEvaluator.kt new file mode 100644 index 00000000..3085576a --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/evaluation/TransitionConditionEvaluator.kt @@ -0,0 +1,10 @@ +package com.correx.core.transitions.evaluation + +import com.correx.core.transitions.graph.TransitionCondition + +interface TransitionConditionEvaluator { + fun evaluate( + condition: TransitionCondition, + context: EvaluationContext + ): Boolean +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/execution/StageExecutionRequest.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/execution/StageExecutionRequest.kt new file mode 100644 index 00000000..cd9b175a --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/execution/StageExecutionRequest.kt @@ -0,0 +1,13 @@ +package com.correx.core.transitions.execution + +import com.correx.core.transitions.evaluation.EvaluationContext +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId + +data class StageExecutionRequest( + val sessionId: String, + val from: StageId, + val to: StageId, + val transitionId: TransitionId, + val context: EvaluationContext +) diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/execution/StageExecutionResult.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/execution/StageExecutionResult.kt new file mode 100644 index 00000000..29543615 --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/execution/StageExecutionResult.kt @@ -0,0 +1,12 @@ +package com.correx.core.transitions.execution + +sealed interface StageExecutionResult { + data class Success( + val producedArtifacts: List + ) : StageExecutionResult + + data class Failure( + val reason: String, + val retryable: Boolean, + ) : StageExecutionResult +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/execution/StageExecutor.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/execution/StageExecutor.kt new file mode 100644 index 00000000..a56b37cf --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/execution/StageExecutor.kt @@ -0,0 +1,7 @@ +package com.correx.core.transitions.execution + +interface StageExecutor { + fun execute( + request: StageExecutionRequest + ): StageExecutionResult +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/GraphOrdering.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/GraphOrdering.kt new file mode 100644 index 00000000..4148166e --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/GraphOrdering.kt @@ -0,0 +1,15 @@ +package com.correx.core.transitions.graph + +import com.correx.core.events.types.StageId + +internal fun WorkflowGraph.sortedStages(): List = + stages.keys.sortedBy { it.value } + +internal fun WorkflowGraph.sortedTransitions(): List = + transitions.sortedWith( + compareBy( + { it.from.value }, + { it.id.value }, + { it.to.value } + ) + ) \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt new file mode 100644 index 00000000..657bd79d --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt @@ -0,0 +1,18 @@ +package com.correx.core.transitions.graph + +import com.correx.core.inference.GenerationConfig +import com.correx.core.inference.ModelCapability +import kotlinx.serialization.Serializable + +@Serializable +data class StageConfig( + val requiredCapabilities: Set = emptySet(), + val tokenBudget: Int = 4096, + val generationConfig: GenerationConfig = GenerationConfig( + temperature = 0.7, + topP = 1.0, + maxTokens = 2048, + ), + val allowedTools: Set = emptySet(), + val maxRetries: Int = 3, +) diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/TransitionCondition.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/TransitionCondition.kt new file mode 100644 index 00000000..db14ba55 --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/TransitionCondition.kt @@ -0,0 +1,7 @@ +package com.correx.core.transitions.graph + +import com.correx.core.transitions.evaluation.EvaluationContext + +fun interface TransitionCondition { + fun evaluate(context: EvaluationContext): Boolean +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/TransitionEdge.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/TransitionEdge.kt new file mode 100644 index 00000000..93e7a8df --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/TransitionEdge.kt @@ -0,0 +1,11 @@ +package com.correx.core.transitions.graph + +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId + +data class TransitionEdge( + val id: TransitionId, + val from: StageId, + val to: StageId, + val condition: TransitionCondition +) \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/WorkflowGraph.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/WorkflowGraph.kt new file mode 100644 index 00000000..ad3c671f --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/WorkflowGraph.kt @@ -0,0 +1,14 @@ +package com.correx.core.transitions.graph + +import com.correx.core.events.types.StageId + +data class WorkflowGraph( + val stages: Map, + val transitions: Set, + val start: StageId, +) { + val stageIds: Set get() = stages.keys + init { + require(start in stages) { "start stage must exist in stages" } + } +} diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/mapper/DefaultStageExecutionEventMapper.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/mapper/DefaultStageExecutionEventMapper.kt new file mode 100644 index 00000000..82dd3e8b --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/mapper/DefaultStageExecutionEventMapper.kt @@ -0,0 +1,47 @@ +package com.correx.core.transitions.mapper + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StageCompletedEvent +import com.correx.core.events.events.StageFailedEvent +import com.correx.core.events.events.TransitionExecutedEvent +import com.correx.core.transitions.execution.StageExecutionRequest +import com.correx.core.transitions.execution.StageExecutionResult +import com.correx.core.events.types.SessionId + +class DefaultStageExecutionEventMapper : StageExecutionEventMapper { + + override fun toEvents( + request: StageExecutionRequest, + result: StageExecutionResult + ): List { + + val baseTransition = TransitionExecutedEvent( + sessionId = SessionId(request.sessionId), + from = request.from, + to = request.to, + transitionId = request.transitionId + ) + + return when (result) { + + is StageExecutionResult.Success -> listOf( + baseTransition, + StageCompletedEvent( + sessionId = SessionId(request.sessionId), + stageId = request.to, + transitionId = request.transitionId + ) + ) + + is StageExecutionResult.Failure -> listOf( + baseTransition, + StageFailedEvent( + sessionId = SessionId(request.sessionId), + stageId = request.to, + transitionId = request.transitionId, + reason = result.reason + ) + ) + } + } +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/mapper/StageExecutionEventMapper.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/mapper/StageExecutionEventMapper.kt new file mode 100644 index 00000000..8d748f2f --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/mapper/StageExecutionEventMapper.kt @@ -0,0 +1,12 @@ +package com.correx.core.transitions.mapper + +import com.correx.core.events.events.EventPayload +import com.correx.core.transitions.execution.StageExecutionRequest +import com.correx.core.transitions.execution.StageExecutionResult + +interface StageExecutionEventMapper { + fun toEvents( + request: StageExecutionRequest, + result: StageExecutionResult + ): List +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CyclePolicy.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CyclePolicy.kt new file mode 100644 index 00000000..b33493e8 --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CyclePolicy.kt @@ -0,0 +1,15 @@ +package com.correx.core.transitions.policy + +sealed interface CyclePolicy { + data class Retry( + val maxAttempts: Int + ) : CyclePolicy + + data class Refinement( + val maxIterations: Int + ) : CyclePolicy + + data class Approval( + val timeoutMs: Long + ) : CyclePolicy +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CyclePolicyBinding.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CyclePolicyBinding.kt new file mode 100644 index 00000000..9dd1d5ea --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CyclePolicyBinding.kt @@ -0,0 +1,6 @@ +package com.correx.core.transitions.policy + +data class CyclePolicyBinding( + val cycle: CycleSignature, + val policy: CyclePolicy +) \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CyclePolicyResolver.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CyclePolicyResolver.kt new file mode 100644 index 00000000..9d51e70a --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CyclePolicyResolver.kt @@ -0,0 +1,12 @@ +package com.correx.core.transitions.policy + +class CyclePolicyResolver( + private val bindings: Set +) { + + fun resolve(signature: CycleSignature): CyclePolicy? { + return bindings + .firstOrNull { it.cycle == signature } + ?.policy + } +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CycleSignature.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CycleSignature.kt new file mode 100644 index 00000000..f7b10f9b --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CycleSignature.kt @@ -0,0 +1,9 @@ +package com.correx.core.transitions.policy + +import com.correx.core.events.types.StageId +import java.util.SortedSet + +data class CycleSignature( + val nodes: SortedSet, + val edges: SortedSet>, +) diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CycleSignatureFactory.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CycleSignatureFactory.kt new file mode 100644 index 00000000..c5314c77 --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/CycleSignatureFactory.kt @@ -0,0 +1,19 @@ +package com.correx.core.transitions.policy + +import com.correx.core.events.types.StageId +import com.correx.core.transitions.graph.TransitionEdge +import java.util.TreeSet + +object CycleSignatureFactory { + fun from(nodes: List, edges: List): CycleSignature { + val nodeSet = TreeSet(compareBy { it.value }) + nodeSet.addAll(nodes) + + val edgeSet = TreeSet>( + compareBy({ it.first.value }, { it.second.value }) + ) + edges.forEach { edgeSet.add(it.from to it.to) } + + return CycleSignature(nodes = nodeSet, edges = edgeSet) + } +} diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/PolicyValidation.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/PolicyValidation.kt new file mode 100644 index 00000000..9540494a --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/policy/PolicyValidation.kt @@ -0,0 +1,23 @@ +package com.correx.core.transitions.policy + +class PolicyValidation { + fun validate( + bindings: Set, + knownCycles: Set + ): List { + + val errors = mutableListOf() + + val bound = bindings.map { it.cycle }.toSet() + + val unbound = knownCycles - bound + + if (unbound.isNotEmpty()) { + errors += unbound.map { + "Cycle $it has no policy binding" + } + } + + return errors + } +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/DefaultTransitionResolver.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/DefaultTransitionResolver.kt new file mode 100644 index 00000000..b172cbf5 --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/DefaultTransitionResolver.kt @@ -0,0 +1,35 @@ +package com.correx.core.transitions.resolution + +import com.correx.core.transitions.evaluation.EvaluationContext +import com.correx.core.transitions.evaluation.TransitionConditionEvaluator +import com.correx.core.transitions.graph.WorkflowGraph + +class DefaultTransitionResolver( + private val evaluator: TransitionConditionEvaluator +) : TransitionResolver { + + @Suppress("ReturnCount") + override fun resolve( + graph: WorkflowGraph, + context: EvaluationContext + ): TransitionDecision { + val outgoing = graph.transitions + .asSequence() + .filter { it.from == context.currentStage } + .sortedWith(TransitionOrdering.comparator) + .toList() + + if (outgoing.isEmpty()) return TransitionDecision.NoMatch + + for (edge in outgoing) { + val result = evaluator.evaluate(edge.condition, context) + if (result) { + return TransitionDecision.Move( + transitionId = edge.id, + to = edge.to + ) + } + } + return TransitionDecision.Stay + } +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/TransitionDecision.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/TransitionDecision.kt new file mode 100644 index 00000000..73e5227b --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/TransitionDecision.kt @@ -0,0 +1,20 @@ +package com.correx.core.transitions.resolution + +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId + +sealed interface TransitionDecision { + + data class Move( + val transitionId: TransitionId, + val to: StageId + ) : TransitionDecision + + data object Stay : TransitionDecision + + data class Blocked( + val reason: String + ) : TransitionDecision + + data object NoMatch : TransitionDecision +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/TransitionOrdering.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/TransitionOrdering.kt new file mode 100644 index 00000000..1c38fecf --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/TransitionOrdering.kt @@ -0,0 +1,10 @@ +package com.correx.core.transitions.resolution + +import com.correx.core.transitions.graph.TransitionEdge + +object TransitionOrdering { + val comparator: Comparator = + compareBy { it.from.value } + .thenBy { it.id.value } + .thenBy { it.to.value } +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/TransitionResolver.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/TransitionResolver.kt new file mode 100644 index 00000000..2e7deeb7 --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/resolution/TransitionResolver.kt @@ -0,0 +1,11 @@ +package com.correx.core.transitions.resolution + +import com.correx.core.transitions.evaluation.EvaluationContext +import com.correx.core.transitions.graph.WorkflowGraph + +interface TransitionResolver { + fun resolve( + graph: WorkflowGraph, + context: EvaluationContext + ): TransitionDecision +} \ No newline at end of file diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/state/CycleDfs.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/state/CycleDfs.kt new file mode 100644 index 00000000..569f73e0 --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/state/CycleDfs.kt @@ -0,0 +1,41 @@ +package com.correx.core.transitions.state + +import com.correx.core.transitions.analysis.DetectedCycle +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.events.types.StageId + +internal class CycleDfs { + fun dfs( + node: StageId, + adjacency: Map>, + state: MutableMap, + path: MutableList, + cycles: MutableList + ) { + state[node] = VisitState.VISITING + path.add(node) + + val edges = adjacency[node].orEmpty() + + for (edge in edges) { + val next = edge.to + when (state[next]) { + VisitState.VISITING -> { + val cycleStart = path.indexOf(next) + if (cycleStart >= 0) { + val cycleNodes = path.subList(cycleStart, path.size) + next + val cycleEdges = cycleNodes.zipWithNext { from, to -> + adjacency[from]?.firstOrNull { it.to == to } + }.filterNotNull() + cycles.add(DetectedCycle(nodes = cycleNodes, edges = cycleEdges)) + } + } + + VisitState.VISITED -> Unit + null -> dfs(next, adjacency, state, path, cycles) + } + } + path.removeAt(path.lastIndex) + state[node] = VisitState.VISITED + } +} diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/state/VisitState.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/state/VisitState.kt new file mode 100644 index 00000000..9f3de49c --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/state/VisitState.kt @@ -0,0 +1,6 @@ +package com.correx.core.transitions.state + +internal enum class VisitState { + VISITING, + VISITED +} \ No newline at end of file diff --git a/core/transitions/src/test/kotlin/com/correx/core/transitions/analysis/canonicalization/CycleCanonicalizerTest.kt b/core/transitions/src/test/kotlin/com/correx/core/transitions/analysis/canonicalization/CycleCanonicalizerTest.kt new file mode 100644 index 00000000..0921ddf4 --- /dev/null +++ b/core/transitions/src/test/kotlin/com/correx/core/transitions/analysis/canonicalization/CycleCanonicalizerTest.kt @@ -0,0 +1,56 @@ +package com.correx.core.transitions.analysis.canonicalization + +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.transitions.analysis.DetectedCycle +import com.correx.core.transitions.graph.TransitionEdge +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CycleCanonicalizerTest { + + private fun edge(id: String, from: String, to: String) = TransitionEdge( + id = TransitionId(id), + from = StageId(from), + to = StageId(to), + condition = { true } + ) + + @Test + fun `same node set but different edges produce two entries`() { + // A -> B -> A with edge t1, and A -> B -> A with edge t2 — different edges + val cycleWithT1 = DetectedCycle( + nodes = listOf(StageId("A"), StageId("B"), StageId("A")), + edges = listOf(edge("t1", "A", "B"), edge("t1b", "B", "A")) + ) + val cycleWithT2 = DetectedCycle( + nodes = listOf(StageId("A"), StageId("B"), StageId("A")), + edges = listOf(edge("t2", "A", "B"), edge("t2b", "B", "A")) + ) + + val result = CycleCanonicalizer.canonicalize(listOf(cycleWithT1, cycleWithT2)) + + assertEquals(2, result.size) + } + + @Test + fun `two rotations of the same cycle dedup to one entry`() { + // Cycle A -> B -> C -> A, represented starting at B or starting at A + val eAB = edge("t1", "A", "B") + val eBC = edge("t2", "B", "C") + val eCA = edge("t3", "C", "A") + + val startAtA = DetectedCycle( + nodes = listOf(StageId("A"), StageId("B"), StageId("C"), StageId("A")), + edges = listOf(eAB, eBC, eCA) + ) + val startAtB = DetectedCycle( + nodes = listOf(StageId("B"), StageId("C"), StageId("A"), StageId("B")), + edges = listOf(eBC, eCA, eAB) + ) + + val result = CycleCanonicalizer.canonicalize(listOf(startAtA, startAtB)) + + assertEquals(1, result.size) + } +} diff --git a/core/validation/build.gradle b/core/validation/build.gradle new file mode 100644 index 00000000..e26fe44b --- /dev/null +++ b/core/validation/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + implementation(project(":core:sessions")) + implementation(project(":core:transitions")) +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/approval/ApprovalRequest.kt b/core/validation/src/main/kotlin/com/correx/core/validation/approval/ApprovalRequest.kt new file mode 100644 index 00000000..30e0a49e --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/approval/ApprovalRequest.kt @@ -0,0 +1,10 @@ +package com.correx.core.validation.approval + +import com.correx.core.events.types.SessionId +import com.correx.core.validation.model.ValidationReport + +data class ApprovalRequest( + val sessionId: SessionId?, + val riskSummary: ValidationRiskStats, + val validationReport: ValidationReport +) \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/approval/ApprovalTrigger.kt b/core/validation/src/main/kotlin/com/correx/core/validation/approval/ApprovalTrigger.kt new file mode 100644 index 00000000..510feeb3 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/approval/ApprovalTrigger.kt @@ -0,0 +1,46 @@ +package com.correx.core.validation.approval + +import com.correx.core.validation.model.ValidationReport +import com.correx.core.validation.model.ValidationSeverity + +class ApprovalTrigger { + + /** + * Evaluates whether a validation report requires human approval. + * + * All approvals produced here are implicitly **Tier.T2 (REVERSIBLE)** — validation decisions + * affect workflow progression but not external state. This tier will be made explicit on + * [ApprovalRequest] once the risk model (Epic 12, task 4) is wired in. + */ + fun evaluate(report: ValidationReport): ApprovalRequest? { + + val errors = report.sections + .flatMap { it.issues } + .count { it.severity == ValidationSeverity.ERROR } + + val warnings = report.sections + .flatMap { it.issues } + .count { it.severity == ValidationSeverity.WARNING } + + val hasCyclePolicyIssue = report.sections + .flatMap { it.issues } + .any { it.code == "CYCLE_POLICY_MISSING" } + + val risk = ValidationRiskStats( + errorCount = errors, + warningCount = warnings, + hasCyclePoliciesMissing = hasCyclePolicyIssue + ) + + // decision boundary (still NOT execution) + if (errors > 0 || hasCyclePolicyIssue) { + return ApprovalRequest( + sessionId = null, + riskSummary = risk, + validationReport = report + ) + } + + return null + } +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/approval/RiskSummary.kt b/core/validation/src/main/kotlin/com/correx/core/validation/approval/RiskSummary.kt new file mode 100644 index 00000000..0724cd5f --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/approval/RiskSummary.kt @@ -0,0 +1,7 @@ +package com.correx.core.validation.approval + +data class RiskSummary( + val errorCount: Int, + val warningCount: Int, + val hasCyclePoliciesMissing: Boolean +) \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/approval/ValidationRiskStats.kt b/core/validation/src/main/kotlin/com/correx/core/validation/approval/ValidationRiskStats.kt new file mode 100644 index 00000000..1aa43f89 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/approval/ValidationRiskStats.kt @@ -0,0 +1,7 @@ +package com.correx.core.validation.approval + +data class ValidationRiskStats( + val errorCount: Int, + val warningCount: Int, + val hasCyclePoliciesMissing: Boolean, +) diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/graph/GraphValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/graph/GraphValidator.kt new file mode 100644 index 00000000..22c12e8d --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/graph/GraphValidator.kt @@ -0,0 +1,54 @@ +package com.correx.core.validation.graph + +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationLocation +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.model.ValidationSeverity +import com.correx.core.validation.pipeline.Validator + +class GraphValidator : Validator { + + override fun validate(context: ValidationContext): ValidationSection { + + val graph = context.graph + val issues = mutableListOf() + + // 1. start node exists (redundant safety check) + if (graph.start !in graph.stages) { + issues += ValidationIssue( + code = "GRAPH_START_INVALID", + message = "Start stage does not exist in graph", + severity = ValidationSeverity.ERROR + ) + } + + // 2. dangling transitions + graph.transitions.forEach { edge -> + if (edge.from !in graph.stages || edge.to !in graph.stages) { + issues += ValidationIssue( + code = "GRAPH_DANGLING_TRANSITION", + message = "Transition references missing stage", + severity = ValidationSeverity.ERROR, + location = ValidationLocation.Graph( + transitionId = edge.id + ) + ) + } + } + + // 3. cycles (ONLY attach, no interpretation) + context.detectedCycles.forEach { cycle -> + issues += ValidationIssue( + code = "GRAPH_CYCLE_DETECTED", + message = "Cycle detected: ${cycle.nodes}", + severity = ValidationSeverity.INFO + ) + } + + return ValidationSection( + name = "graph", + issues = issues + ) + } +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationContext.kt b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationContext.kt new file mode 100644 index 00000000..2ff5fc52 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationContext.kt @@ -0,0 +1,13 @@ +package com.correx.core.validation.model + +import com.correx.core.sessions.SessionState +import com.correx.core.transitions.analysis.DetectedCycle +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.policy.CyclePolicyBinding + +data class ValidationContext( + val graph: WorkflowGraph, + val detectedCycles: List = emptyList(), + val cyclePolicies: Set = emptySet(), + val sessionState: SessionState? = null, +) diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationIssue.kt b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationIssue.kt new file mode 100644 index 00000000..e60875e3 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationIssue.kt @@ -0,0 +1,7 @@ +package com.correx.core.validation.model + +data class ValidationIssue( + val code: String, + val message: String, + val severity: ValidationSeverity, + val location: ValidationLocation? = null) diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationLocation.kt b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationLocation.kt new file mode 100644 index 00000000..23e98be1 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationLocation.kt @@ -0,0 +1,16 @@ +package com.correx.core.validation.model + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId + +sealed interface ValidationLocation { + data class Graph( + val stageId: StageId? = null, + val transitionId: TransitionId? = null + ) : ValidationLocation + + data class Session( + val sessionId: SessionId + ) : ValidationLocation +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationReport.kt b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationReport.kt new file mode 100644 index 00000000..0aba2dc1 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationReport.kt @@ -0,0 +1,10 @@ +package com.correx.core.validation.model + +data class ValidationReport( + val sections: List +) { + fun hasErrors(): Boolean = + sections.any { section -> + section.issues.any { it.severity == ValidationSeverity.ERROR } + } +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationSection.kt b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationSection.kt new file mode 100644 index 00000000..9694cd13 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationSection.kt @@ -0,0 +1,6 @@ +package com.correx.core.validation.model + +data class ValidationSection( + val name: String, + val issues: List = emptyList(), + val metadata: Map = emptyMap()) diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationSeverity.kt b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationSeverity.kt new file mode 100644 index 00000000..efd3e6d3 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/model/ValidationSeverity.kt @@ -0,0 +1,7 @@ +package com.correx.core.validation.model + +enum class ValidationSeverity { + INFO, + WARNING, + ERROR +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/ValidationOutcome.kt b/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/ValidationOutcome.kt new file mode 100644 index 00000000..28c0ff96 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/ValidationOutcome.kt @@ -0,0 +1,14 @@ +package com.correx.core.validation.pipeline + +import com.correx.core.validation.approval.ApprovalRequest +import com.correx.core.validation.model.ValidationReport + +sealed interface ValidationOutcome { + data class Passed(val report: ValidationReport) : ValidationOutcome + /** + * @param retryable set by the validator based on failure type; orchestrator must read this + * and never override it. + */ + data class Rejected(val report: ValidationReport, val retryable: Boolean) : ValidationOutcome + data class NeedsApproval(val request: ApprovalRequest) : ValidationOutcome +} diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/ValidationPipeline.kt b/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/ValidationPipeline.kt new file mode 100644 index 00000000..5d8ab8cb --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/ValidationPipeline.kt @@ -0,0 +1,32 @@ +package com.correx.core.validation.pipeline + +import com.correx.core.validation.approval.ApprovalTrigger +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.model.ValidationReport +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.model.ValidationSeverity + +class ValidationPipeline( + private val validators: List, + private val approvalTrigger: ApprovalTrigger? = null +) { + + fun validate(context: ValidationContext): ValidationOutcome { + val sections = mutableListOf() + for (validator in validators) { + val section = validator.validate(context) + sections += section + if (section.issues.any { it.severity == ValidationSeverity.ERROR }) { + // Structural validation errors are not retryable — the graph must be fixed. + return ValidationOutcome.Rejected(ValidationReport(sections), retryable = false) + } + } + val report = ValidationReport(sections) + val approvalRequest = approvalTrigger?.evaluate(report) + return if (approvalRequest != null) { + ValidationOutcome.NeedsApproval(approvalRequest) + } else { + ValidationOutcome.Passed(report) + } + } +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/Validator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/Validator.kt new file mode 100644 index 00000000..d3dbd841 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/pipeline/Validator.kt @@ -0,0 +1,8 @@ +package com.correx.core.validation.pipeline + +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.model.ValidationSection + +fun interface Validator { + fun validate(context: ValidationContext): ValidationSection +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/semantic/SemanticRule.kt b/core/validation/src/main/kotlin/com/correx/core/validation/semantic/SemanticRule.kt new file mode 100644 index 00000000..cf53cea0 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/semantic/SemanticRule.kt @@ -0,0 +1,8 @@ +package com.correx.core.validation.semantic + +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.model.ValidationIssue + +interface SemanticRule { + fun validate(context: ValidationContext): List +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/semantic/SemanticValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/semantic/SemanticValidator.kt new file mode 100644 index 00000000..abb8779a --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/semantic/SemanticValidator.kt @@ -0,0 +1,20 @@ +package com.correx.core.validation.semantic + +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.pipeline.Validator + +class SemanticValidator( + private val rules: List +) : Validator { + + override fun validate(context: ValidationContext): ValidationSection { + + val issues = rules.flatMap { it.validate(context) } + + return ValidationSection( + name = "semantic", + issues = issues + ) + } +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/semantic/rules/CyclePolicyBindingRule.kt b/core/validation/src/main/kotlin/com/correx/core/validation/semantic/rules/CyclePolicyBindingRule.kt new file mode 100644 index 00000000..6d10b8f0 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/semantic/rules/CyclePolicyBindingRule.kt @@ -0,0 +1,37 @@ +package com.correx.core.validation.semantic.rules + +import com.correx.core.transitions.policy.CycleSignature +import com.correx.core.transitions.policy.CycleSignatureFactory +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationSeverity +import com.correx.core.validation.semantic.SemanticRule + +class CyclePolicyBindingRule( + private val requirePolicyForCycles: Boolean +) : SemanticRule { + + override fun validate(context: ValidationContext): List { + + if (!requirePolicyForCycles) return emptyList() + + val knownSignatures = context.detectedCycles + .map { CycleSignatureFactory.from(it.nodes, it.edges) } + .toSet() + + val bound = context.cyclePolicies + .map { it.cycle } + .toSet() + + val unbound = knownSignatures - bound + + return unbound.map { signature -> + ValidationIssue( + code = "CYCLE_POLICY_MISSING", + message = "Cycle has no policy binding: $signature", + severity = ValidationSeverity.WARNING, + location = null + ) + } + } +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/session/SessionValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/session/SessionValidator.kt new file mode 100644 index 00000000..cabeeb62 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/session/SessionValidator.kt @@ -0,0 +1,60 @@ +package com.correx.core.validation.session + +import com.correx.core.sessions.SessionStatus +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.model.ValidationSeverity +import com.correx.core.validation.pipeline.Validator + +class SessionValidator : Validator { + override fun validate(context: ValidationContext): ValidationSection { + + val state = context.sessionState ?: return ValidationSection( + name = "session", + issues = emptyList() + ) + + val issues = mutableListOf() + + val createdAt = state.createdAt + val updatedAt = state.updatedAt + + // 1. temporal consistency + if (createdAt != null && updatedAt != null) { + if (createdAt > updatedAt) { + issues += ValidationIssue( + code = "SESSION_TIME_INCONSISTENT", + message = "createdAt is after updatedAt", + severity = ValidationSeverity.ERROR + ) + } + } + + // 2. invalid transitions sanity + if (state.invalidTransitions < 0) { + issues += ValidationIssue( + code = "SESSION_INVALID_TRANSITIONS_NEGATIVE", + message = "invalidTransitions cannot be negative", + severity = ValidationSeverity.ERROR + ) + } + + // 3. status sanity (light semantic check only) + if (state.status == SessionStatus.FAILED && + state.invalidTransitions == 0 + ) { + + issues += ValidationIssue( + code = "SESSION_SUSPICIOUS_FAILURE_STATE", + message = "FAILED session with zero invalid transitions", + severity = ValidationSeverity.WARNING + ) + } + + return ValidationSection( + name = "session", + issues = issues + ) + } +} \ No newline at end of file diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/transition/TransitionValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/transition/TransitionValidator.kt new file mode 100644 index 00000000..e2330051 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/transition/TransitionValidator.kt @@ -0,0 +1,72 @@ +package com.correx.core.validation.transition + +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.resolution.TransitionOrdering +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationLocation +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.model.ValidationSeverity +import com.correx.core.validation.pipeline.Validator + +class TransitionValidator( + private val ordering: Comparator = TransitionOrdering.comparator +) : Validator { + + override fun validate(context: ValidationContext): ValidationSection { + + val graph = context.graph + val issues = mutableListOf() + + // group transitions by source node + val grouped = graph.transitions.groupBy { it.from } + + // 1. endpoint integrity + graph.transitions.forEach { edge -> + if (edge.from !in graph.stages) { + issues += ValidationIssue( + code = "TRANSITION_INVALID_FROM", + message = "Transition references missing 'from' stage", + severity = ValidationSeverity.ERROR, + location = ValidationLocation.Graph( + transitionId = edge.id + ) + ) + } + + if (edge.to !in graph.stages) { + issues += ValidationIssue( + code = "TRANSITION_INVALID_TO", + message = "Transition references missing 'to' stage", + severity = ValidationSeverity.ERROR, + location = ValidationLocation.Graph( + transitionId = edge.id + ) + ) + } + } + + // 2. deterministic ordering check per node + grouped.forEach { (from, edges) -> + + val sorted = edges.sortedWith(ordering) + + // detect duplicate ordering signatures (ambiguity risk) + val signatures = sorted.map { it.to }.toSet() + + if (signatures.size != sorted.size) { + issues += ValidationIssue( + code = "TRANSITION_NON_DETERMINISTIC_ORDER", + message = "Non-deterministic ordering detected for node $from", + severity = ValidationSeverity.WARNING, + location = ValidationLocation.Graph(stageId = from) + ) + } + } + + return ValidationSection( + name = "transition", + issues = issues + ) + } +} \ No newline at end of file diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 00000000..b527ee01 --- /dev/null +++ b/detekt.yml @@ -0,0 +1,3 @@ +style: + NewLineAtEndOfFile: + active: false \ No newline at end of file diff --git a/docs/architecture/context-layers.md b/docs/architecture/context-layers.md new file mode 100644 index 00000000..1cecfe13 --- /dev/null +++ b/docs/architecture/context-layers.md @@ -0,0 +1,88 @@ +--- +name: "Context Layers" +description: "Context hierarchy, compression, and token budgeting" +depth: 2 +links: + - "../index.md" + - "./overview.md" + - "./replay-model.md" +--- + +# context layers + +**version:** 0.1-draft +**status:** architectural reference + +--- + +## 1. motivation + +Raw conversation accumulation destroys performance in local models. Correx forces all context to be synthesized, filtered, and layer‑separated before it reaches a model. + +--- + +## 2. layer model + +``` +L0 live execution [active request, current tool output, pending approval text] +L1 stage‑local context [artifacts and receipts from the current stage] +L2 compressed session [summaries of completed stages; kept under a token cap] +L3 project memory [persistent facts, architecture decisions, key outputs] +L4 archival history [full event log; not used for inference] +``` + +Models receive a ContextPack built from L0–L2, with L3 optionally injected if relevant. + +--- + +## 3. context pack building pipeline + +1. **Retrieve** – pull relevant events from projections using the current session cursor. +2. **Deduplicate** – remove duplicate artifact versions, identical tool receipts, and stale retries. (Tool output deduplication happens later, within the tool‑specific compressor.) +3. **Rank** – order entries by recency and relevance to the current stage’s declared goal (simple heuristic, no model required). +4. **Compress (layer‑specific, deterministic):** + * **Tool logs** → RTK‑style pipeline: filter noise → group similar items → deduplicate repeated lines → truncate to token budget; **full output always saved on failure** and replaced with a file pointer. Per‑tool regex patterns from the tool definition control what’s extracted. + * **Conversation** → keep last N messages verbatim; **manually triggered compaction** (`/compact`) summarizes older turns; **micro‑compaction** silently clears old tool outputs when token pressure rises; pinned messages survive compaction. No automatic summarization. + * **Artifacts** → chunkable artifacts (e.g., plans) split into steps; only current step ±1 remains in context, rest stored on disk. Non‑chunkable artifacts use their mandatory `summary` field directly. + * **Steering notes** → kept verbatim. + * **Event history → L2** → **harvest structured DecisionPoints** from completed stages: artifact `summary` + `rationale` + approval steering + stage outcome. No summarization model; pure field extraction. +5. **Inject policies** – attach active permissions, allowed tools, and stage‑specific rules. +6. **Tokenize & trim** – enforce hard token budget; if exceeded, oldest L2 DecisionPoints are dropped first (they graduate to L3 project memory). +7. **Assemble** – produce the final, deterministic **ContextPack**. + +This pipeline is entirely rule‑based, replay‑safe, and uses zero model calls for compression. + +--- + +## 4. context pack structure + +```kotlin +data class ContextPack( + val layers: Map>, + val budgetUsed: Int, + val budgetLimit: Int +) +``` + +Each entry is a typed reference to an event, artifact excerpt, summary, or policy snippet. + +--- + +## 5. router context isolation + +The Router maintains its own separate L2 memory, rebuilt from stage summaries. During active execution, the Router’s context is unloaded from the execution model’s context window. When the user interrupts, the Router rebuilds its view from the latest L2 snapshot, interprets steering, and injects it without polluting the execution model. + +--- + +## 6. token budgeting + +* Each model and stage can define a max token limit. +* System prompts and policies reserve a portion. +* If the compressed context still exceeds the budget, older L2 entries are merged into L3 and dropped from L2. +* A session‑level cap on L2 memory exists to prevent gradual bloat in long‑running sessions. + +--- + +## 7. observability + +The context processor emits events (`ContextPackBuilt`, `LayerTruncated`) with metrics on compression ratios, token usage, and deduplication hits. This data is invaluable for tuning compression strategies. \ No newline at end of file diff --git a/docs/architecture/event-model.md b/docs/architecture/event-model.md new file mode 100644 index 00000000..f77bf470 --- /dev/null +++ b/docs/architecture/event-model.md @@ -0,0 +1,103 @@ +--- +name: "Event Model" +description: "Canonical event structure, categories, and causality" +depth: 2 +links: + - "../index.md" + - "./overview.md" + - "./replay-model.md" +--- + +# event model + +**version:** 0.1-draft +**status:** architectural reference + +--- + +## 1. purpose + +Events are the sole source of truth for all workflow state in correx. Every meaningful action (user input, stage start, artifact produced, approval granted, transition executed) is recorded as an immutable event. + +--- + +## 2. event structure + +Every event contains: + +```kotlin +sealed interface Event { + val id: EventId + val sessionId: SessionId + val timestamp: Instant + val type: EventType + val payload: EventPayload + val causationId: EventId? // what directly caused this event? + val correlationId: CorrelationId? // which workflow/execution lineage? + val sequence: Long // strict append order within session + val version: Int // schema version +} +``` + +--- + +## 3. event categories + +| category | owner module | examples | +|----------|--------------|----------| +| DomainEvents | core:events, core:artifacts | ArtifactProduced, ArtifactSuperseded | +| LifecycleEvents | core:sessions | SessionCreated, SessionCompleted | +| InferenceEvents | core:inference | InferenceStarted, InferenceCompleted | +| ToolEvents | core:tools | ToolInvocationRequested, ToolExecutionCompleted | +| ValidationEvents | core:validation | ArtifactValidated, ArtifactRejected | +| ApprovalEvents | core:approvals | ApprovalRequested, ApprovalGranted | +| CompressionEvents | core:context | ContextPackBuilt, CompressionApplied | +| TransitionEvents | core:transitions | TransitionExecuted, StageScheduled | +| ProjectionEvents | core:events | ProjectionRebuilt | +| SystemEvents | various | WatchdogTimeout, ProviderHealthChanged | + +--- + +## 4. causal chain (simplified) + +``` +UserInputReceived + └→ SessionCreated + └→ StageScheduled + └→ ContextPackBuilt + └→ InferenceStarted + └→ ArtifactProduced + └→ ArtifactValidated + ├→ (if approval needed) ApprovalRequested → ApprovalGranted + └→ TransitionExecuted + └→ (next stage) +``` + +Causation IDs form an explicit directed acyclic graph; every event can be traced back to its origin. + +--- + +## 5. ordering and append guarantees + +* Events are append‑only. Existing events are never modified or deleted. +* Sequence numbers are strictly increasing within a session, gap‑free. +* Compensating events (e.g., `ArtifactSuperseded`) are themselves new events; they do not alter history. +* Ordering during replay must exactly match the original insertion order. + +--- + +## 6. replay semantics + +Given a sequence of events, replay rebuilds all projections and workflow state deterministically. For inference‑skipping replay, artifacts and validation outcomes are read directly from recorded events rather than re‑invoking models. + +--- + +## 7. snapshotting + +Snapshots are periodically rebuilt from event streams to speed up replay. They are **disposable optimisation artifacts**; the event log remains the authority. + +--- + +## 8. extension and versioning + +New event types may be added by core modules or plugins. Schema evolution is handled via version numbers and migration logic that never alters historical events. \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 00000000..637ec7fd --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,101 @@ +--- +name: "System Overview" +description: "System overview and core metaphor of Correx" +depth: 2 +links: + - "../index.md" + - "./event-model.md" + - "./replay-model.md" + - "./context-layers.md" + - "./security-boundaries.md" +--- + +# system overview + +**version:** 0.1-draft +**status:** architectural narrative + +--- + +## 1. what is correx? + +Correx is a **local‑first orchestration runtime** for structured LLM workflows. It provides a deterministic shell around probabilistic cognition, treating language models as interchangeable execution engines while owning memory, state, validation, and permissions. + +The system is not a chatbot framework. It is a config‑driven workflow orchestrator that treats every model output as an untrusted proposal and only advances state after multiple layers of validation and approval. + +--- + +## 2. core metaphor + +**Harness is the brain, models are the muscles, Router is the face.** + +* **Harness (core kernel):** orchestrates workflow, owns lifecycle, validates everything, maintains event‑sourced state. +* **Router:** conversational façade that chit‑chats with the user, provides summaries, and interprets steering; always available but never persists in execution context. +* **Agents:** ephemeral, stateless workers that consume synthesized context and emit structured artifacts. +* **Models:** stateless inference engines; they receive only a tightly compressed context pack and produce a single response. + +--- + +## 3. key architectural properties + +| property | implementation | +|----------|----------------| +| event‑sourced | all state is derivable from immutable events | +| deterministic‑enough | orchestration logic is deterministic; inference randomness is contained | +| replayable | full session replay without original models or live tools | +| provider‑agnostic | local GGUF and remote OpenAI‑compatible models are interchangeable | +| context‑efficient | aggressive compression, budgeting, and layering prevents context bloat | +| bounded autonomy | explicit tiers, approval gates, and cancellation boundaries | +| validation‑first | every artifact passes routing → schema → semantic → approval validation | + +--- + +## 4. system layers + +``` +┌─────────────────┐ +│ user │ +└───────┬─────────┘ + │ + ▼ +┌─────────────────┐ +│ interfaces │ cli, api, websocket, frontend +└───────┬─────────┘ + │ + ▼ +┌─────────────────┐ +│ orchestration │ session life, stage scheduling, retry, coordination +└───────┬─────────┘ + │ + ▼ +┌─────────────────┐ +│ core domain │ events, transitions, validation, approvals, context, artifacts +└───────┬─────────┘ + │ + ▼ +┌─────────────────┐ +│ infrastructure │ persistence, inference providers, tool runtime, sandboxing +└─────────────────┘ +``` + +Core never depends on infrastructure. Infrastructure implements contracts defined by core. + +--- + +## 5. execution flow (simplified) + +1. User input arrives via Router. +2. Session is created or resumed; projection rebuilt from events. +3. Orchestrator determines next stage (from transition graph). +4. Context Processor synthesises a ContextPack from L0–L2 layers. +5. Inference module selects and calls the appropriate model. +6. Model output is parsed into an artifact. +7. Validation pipeline checks artifact (routing, schema, semantic, approval). +8. If approved, transition engine evaluates conditions and moves to next stage. +9. All steps are recorded as events. + +--- + +## 6. design philosophy + +Correx is an **orchestration kernel** that enforces strict separation of concerns, immutability, and replayability. Reliability comes from explicit constraints, not from trusting model intelligence. \ No newline at end of file diff --git a/docs/architecture/replay-model.md b/docs/architecture/replay-model.md new file mode 100644 index 00000000..17bf44de --- /dev/null +++ b/docs/architecture/replay-model.md @@ -0,0 +1,93 @@ +--- +name: "Replay Model" +description: "Replay modes, guarantees, and snapshot/cursor management" +depth: 2 +links: + - "../index.md" + - "./overview.md" + - "./event-model.md" +--- + +# replay model + +**version:** 0.1-draft +**status:** architectural reference + +--- + +## 1. purpose + +Replay is the ability to reconstruct the entire state of a session (projections, context builds, validation outcomes, transitions) purely from the immutable event log. It is a mandatory architectural guarantee, not an afterthought. + +--- + +## 2. replay modes + +| mode | description | +|------|-------------| +| **full replay** | re‑apply all events, including inference (may re‑run models if available) | +| **partial replay** | replay from a given cursor/sequence number | +| **inference‑skipping replay** | skip model calls; use recorded `InferenceCompleted` events directly | +| **projection‑only replay** | rebuild projections without touching orchestration | +| **deterministic simulation** | test mode: compare current outcomes against a golden reference | + +--- + +## 3. what can be replayed? + +* Session lifecycle (creation → completion) +* Stage scheduling and transitions +* Context pack building (subject to the same compression config, but compression events may be reused) +* Validation decisions (recorded pass/reject) +* Approval decisions (recorded as events) +* Tool receipts (recorded; actual tool code not re‑executed unless in simulation mode) +* All projections + +--- + +## 4. what is NOT needed for replay? + +* The original model weights +* Live tool access +* Network connectivity +* Provider credentials +* The original Router UI + +Replay is self‑contained within the event store and configuration. + +--- + +## 5. deterministic guarantees + +Replay is deterministic when: + +* the event store is unchanged +* the configuration (workflows, policies, compression rules) is identical +* the replay strategy matches (e.g., inference‑skipping uses recorded artifacts) + +Non‑determinism from model inference is thereby contained: the replay decision to trust a recorded artifact is itself a deterministic choice driven by the replay mode. + +--- + +## 6. use cases + +* **debugging:** replay a failed session step‑by‑step, inspecting context packs and validation outcomes. +* **audit:** prove which decision led to a tool execution and why. +* **recovery:** if the harness crashes, resume from the last stable event cursor. +* **dataset generation:** replay workflows with different inference providers to produce synthetic training data. +* **testing:** deterministic simulation lets you verify orchestration logic without real models. + +--- + +## 7. snapshot and cursor management + +To avoid replaying 100k+ events every time, the system supports periodic snapshots and replay cursors. A snapshot is a point‑in‑time projection bundle paired with the sequence number of the last applied event. Replay then only re‑processes events after that cursor. + +--- + +## 8. implementation constraints + +* All projections must be buildable from events alone. +* Context pack builds must be replays (using recorded compression events when possible). +* No component may depend on in‑memory state that is not derived from events. +* Events must remain serializable and versioned forever. \ No newline at end of file diff --git a/docs/architecture/security-boundaries.md b/docs/architecture/security-boundaries.md new file mode 100644 index 00000000..571d6493 --- /dev/null +++ b/docs/architecture/security-boundaries.md @@ -0,0 +1,110 @@ +--- +name: "Security Boundaries" +description: "Trust boundaries and component isolation rules" +depth: 2 +links: + - "../index.md" + - "./overview.md" +--- + +# security boundaries + +**version:** 0.1-draft +**status:** architectural reference + +--- + +## 1. trust model + +Correx operates under the assumption that **models are untrusted**, **tools are dangerous**, and **plugins may be malicious**. The system enforces explicit security boundaries between components to contain risk. + +--- + +## 2. principal boundaries + +``` +┌───────────────┐ +│ user │ human, interactive steering, ultimate authority +└───────┬───────┘ + │ + ▼ +┌───────────────┐ +│ router │ facial facade; can only summarise, interpret, forward +└───────┬───────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ harness (core) │ +│ orchestrator, validation, approvals, │ +│ transitions, policies, event store │ +│ │ +│ all sensitive decisions happen here │ +└───┬───────┬───────┬──────────┬──────┬───────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +┌─────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ +│model│ │tool │ │prov- │ │plugin│ │pers- │ +│ │ │ │ │ider │ │ │ │ist │ +└─────┘ └──────┘ └──────┘ └──────┘ └──────┘ + +Boundaries are strictly enforced: +- Model ⇄ Harness: only text in, artifact out +- Tool ⇄ Harness: only receipts, no direct state mutation +- Provider ⇄ Harness: only inference interface +- Plugin ⇄ Harness: only via published contracts +- Persistence ⇄ Harness: only through storage interfaces +``` + +--- + +## 3. specific boundary rules + +### 3.1 model boundary + +* Models receive only ContextPacks; they never access raw events, files, or network. +* Output is untrusted; validation pipeline scrutinises every artifact. +* No state carried between model calls. + +### 3.2 tool boundary + +* All tools are assigned an approval tier (T0–T4). +* Execution requires explicit approval matching the tier and session mode. +* Tools run sandboxed (containerised or restricted shell). +* Tools may only affect the filesystem paths allowed by policies. +* Network access is blocked by default; curl, etc., require tier T3 and explicit approval. + +### 3.3 provider boundary + +* Local and remote providers are abstracted behind the same `InferenceProvider` interface. +* Credentials are never exposed to core logic; they reside in environment variables or secret vaults. +* Provider health failures trigger orchestration fallback, not panic. + +### 3.4 plugin boundary + +* Plugins (custom validators, compressors, tools) interact via stable DTOs. +* They cannot mutate core projections or approval decisions. +* Plugin code is loaded into an isolated classloader/process in later versions. + +### 3.5 user boundary + +* User interaction happens only through the Router and approval prompts. +* Steering input is recorded verbatim and later injected as guidance into context packs. +* Session cancellation is immediate and enforced by the orchestrator. + +--- + +## 4. extra safeguards + +* **Audit trail:** every approval, validation rejection, and tool execution is permanently recorded. +* **Least privilege:** each component starts with zero access and gains only what is explicitly granted. +* **Execution timeouts:** all inference and tool calls have strict durations; watchdog kills overruns. +* **Secrets isolation:** no secret appears in logs, events, or context unless explicitly allowed. +* **Replay safety:** replay does not re‑execute risky side effects; it only reconstructs state. + +--- + +## 5. what we do not protect against (by design) + +* Deliberate user bypasses (e.g., yolo mode) – they are logged but allowed. +* Model‑generated harmful content – semantic validation can flag it, but ultimate responsibility lies with the user’s policies. +* Physical machine compromise – we assume the OS and sandbox provide the last line of defence. \ No newline at end of file diff --git a/docs/decisions/adr-0000-invariants.md b/docs/decisions/adr-0000-invariants.md new file mode 100644 index 00000000..e5bc6233 --- /dev/null +++ b/docs/decisions/adr-0000-invariants.md @@ -0,0 +1,241 @@ +--- +name: "Adr 0000 Invariants" +description: "Foundational architectural invariants of the Correx system" +depth: 2 +links: ["../index.md", "../architecture/overview.md", "../decisions/adr-0001-event-sourcing.md"] +--- + +# ADR 0000: architectural invariants of the correx system + +**status:** accepted +**date:** 08.05.2026 +**deciders:** Kami + +--- + +## context + +Correx is an event-sourced orchestration system with deterministic replay requirements, LLM-driven inference, and multi-layered execution (validation, approvals, tools, context synthesis, and transitions). + +Without explicit invariants, the system risks: + +* implicit coupling between modules +* nondeterministic behavior creeping into core logic +* inconsistent interpretation of event streams across bounded contexts +* broken replay guarantees under evolution of modules + +This ADR defines the **hard architectural rules** that all other ADRs and implementation decisions must obey. + +These invariants are not suggestions. They are constraints on system evolution. + +--- + +## invariants + +### 1. event log is the single source of truth + +All system state MUST be derivable from the event stream. + +* no hidden mutable state is allowed to influence execution outcomes +* all decisions, approvals, transitions, and tool executions MUST emit events +* projections are disposable and rebuildable at any time + +implication: + +* caching is allowed only if it is derivable from events +* any system behavior not reproducible from events is a bug + +--- + +### 2. projections are bounded-context owned + +Each bounded context owns its own projection(s). + +* `sessions` owns session state projection +* `artifacts` owns artifact state projection +* `context` owns context assembly projection +* `validation` owns validation state projection + +projections: + +* MAY NOT mutate each other +* MAY NOT be shared as a global mutable model +* MAY be reconstructed independently from the event stream + +implication: + +* there is no “global system state object” +* cross-domain reasoning must go through events or read-only queries + +--- + +### 3. deterministic core vs nondeterministic inputs separation + +The system is split into two layers: + +**deterministic core:** + +* event handling +* state transitions +* approval logic +* validation routing decisions +* scheduling + +**nondeterministic layer:** + +* LLM inference +* tool output +* user input +* external system responses + +rule: + +* nondeterministic outputs MAY influence events +* but ONLY deterministic core may commit events + +implication: + +* LLMs propose; core decides +* tools suggest; core validates and records + +--- + +### 4. no cross-module implementation dependencies inside core (outdated) + +Inside `:core`, modules MAY depend only on: + +* `:core:contracts` (or equivalent shared model module) +* or strictly lower-level primitives + +forbidden: + +* `context → validation` +* `validation → approvals` +* `router → kernel internals` + +allowed: + +* interaction only via: + + * events + * interfaces + * explicit orchestration entrypoints + +implication: + +* core is a DAG, not a mesh + +--- + +### 5. event causality is strictly linear per session + +Within a single session: + +* every event MUST have exactly one causation parent (except root) +* sequence ordering is total and monotonic +* correlation IDs define logical grouping but do not define ordering + +implication: + +* no ambiguous causality graphs +* replay is deterministic and linear + +--- + +### 6. policy layer is absolute and non-overridable + +Policy evaluation is the highest authority layer. + +* approvals cannot override policy denial +* validation cannot bypass policy constraints +* tool execution cannot ignore policy results + +policy failure is terminal for the operation path. + +implication: + +* approval system is not a security boundary; policy is + +--- + +### 7. tool execution is sandbox-first by default + +All tools: + +* MUST declare execution tier +* MUST be assumed unsafe until validated +* MUST produce structured receipts + +side effects: + +* are only valid if captured in events + +implication: + +* “silent execution” is not allowed in system design + +--- + +### 8. compression and summarization are non-authoritative + +Any compressed, summarized, or derived representation: + +* MUST NOT replace original events or artifacts +* MUST be reproducible from source data +* MUST be explicitly marked as derived + +implication: + +* no lossy compression in the source-of-truth layer +* only in read models + +--- + +### 9. LLM outputs are untrusted until validated + +Any output from inference: + +* is considered a proposal +* cannot affect state unless validated and approved +* cannot bypass schema validation + +implication: + +* prompt injection is structurally contained, not just filtered + +--- + +### 10. replay must be environment-independent + +Replaying a session: + +* MUST NOT depend on current system state +* MUST NOT depend on external services +* MUST NOT require live LLM calls (unless explicitly in “live replay mode”) + +implication: + +* full determinism is a first-class requirement, not a debug feature + +--- + +## consequences + +**positive:** + +* system behavior remains consistent under evolution +* replay and debugging are guaranteed properties, not best-effort +* module boundaries become enforceable in code and architecture +* future distributed execution becomes feasible without redesign + +**negative:** + +* requires discipline in module design (especially core dependencies) +* increases upfront design overhead for new components +* forces strict separation between “thinking” and “executing” layers + +--- + +## status + +This ADR is foundational. All future ADRs are subordinate to these invariants. Any contradiction requires revision of this document before implementation proceeds. diff --git a/docs/decisions/adr-0001-event-sourcing.md b/docs/decisions/adr-0001-event-sourcing.md new file mode 100644 index 00000000..53b69505 --- /dev/null +++ b/docs/decisions/adr-0001-event-sourcing.md @@ -0,0 +1,58 @@ +--- +name: "Adr 0001 Event Sourcing" +description: "Decision to use event sourcing as primary persistence model" +depth: 2 +links: ["../index.md", "../architecture/event-model.md", "./adr-0000-invariants.md"] +--- + +# ADR 0001: use event sourcing as the foundational state model + +**status:** accepted +**date:** 07.05.2026 (May) +**deciders:** Kami + +--- + +## context + +Correx orchestrates workflows driven by nondeterministic language models. To achieve reliability, we need: + +* full traceability of every decision +* the ability to replay and debug sessions offline +* deterministic recovery after crashes +* a way to generate synthetic training data from execution history + +Traditional mutable‑state architectures would make debugging a nightmare: hidden state, race conditions, and the inability to step backward would undermine the core promise of “deterministic‑enough” execution. + +## decision + +We adopt **event sourcing** as the fundamental persistence and state management pattern. + +* All workflow state is derived from an append‑only log of immutable events. +* Projections (the current state of a session) are rebuilt from events on demand or via snapshots. +* No mutable memory exists outside the event stream. + +## consequences + +**positive:** + +* Complete auditability: every state transition, approval, and validation is recorded. +* Replayability: a session can be re‑run from scratch with different models or in “dry” mode. +* Recovery: if the Harness crashes, we can resume by replaying the event log. +* Testability: orchestration logic can be tested with fabricated event streams. +* Synthetic dataset generation: replay can feed other models or evaluations. + +**negative:** + +* Eventual storage growth; mitigated by snapshots and configurable retention policies. +* Complexity in designing projections and replay logic; mitigated by clear module boundaries (`:core:events`, `:core:projections`). +* Event schema evolution must be handled deliberately; versioning and migration tooling needed. + +## alternatives considered + +* **Mutable relational state with audit tables**: would still need manual change tracking, harder to guarantee deterministic replay. +* **Op log in a distributed database**: overkill for local‑first operation; introduces ordering complexity. + +## status + +This decision is foundational and will not be revisited without a complete architectural overhaul. \ No newline at end of file diff --git a/docs/decisions/adr-0002-kotlin-choice.md b/docs/decisions/adr-0002-kotlin-choice.md new file mode 100644 index 00000000..95ac8521 --- /dev/null +++ b/docs/decisions/adr-0002-kotlin-choice.md @@ -0,0 +1,57 @@ +--- +name: "Adr 0002 Kotlin Choice" +description: "Decision to implement in Kotlin" +depth: 2 +links: ["../index.md", "./adr-0000-invariants.md"] +--- + +# ADR 0002: use kotlin as the primary implementation language + +**status:** accepted +**date:** 07.05.2026 (May) +**deciders:** Kami + +--- + +## context + +Correx is a state‑heavy, event‑heavy, concurrency‑heavy orchestration runtime. The language choice affects maintainability, type safety, concurrency model, and ecosystem compatibility for a local‑first tool. + +## decision + +We implement the core, orchestration, and infrastructure layers in **Kotlin** (JVM), using the following primary libraries: + +* **coroutines** – structured concurrency +* **kotlinx.serialization** – event/artifact serialization +* **Exposed** – type‑safe SQL for persistence +* **Ktor** – web/API server +* **Hoplite** / Typesafe Config – configuration + +Frontend interface uses **SvelteKit**; CLI uses **Clikt**. + +## rationale + +1. **Sealed hierarchies and data classes** map naturally to event types, artifact schemas, and validation outcomes. +2. **Coroutines** provide safe, cancellable concurrency that aligns with the session lifecycle (cancellation tokens, explicit scopes). +3. **Immutability by default** reduces accidental state mutation – critical for event sourcing. +4. **Type‑safe DSLs** enable clean definition of transitions, conditions, and tool contracts directly in config or code. +5. The existing team knows Java/Kotlin, reducing ramp‑up. +6. Libraries like `kotlinx.serialization` give deterministic, reflection‑free serialization. +7. Strong typing prevents many categories of bugs that would be hard to catch in a dynamic language when dealing with complex state machines. + +## alternatives considered + +* **Python**: excellent for glue and rapid prototyping, but dynamic typing and global interpreter lock make deterministic concurrency, state integrity, and long‑term maintenance riskier. Python was used in the failed `llm-pipeline` prototype and contributed to hidden mutable state issues. +* **TypeScript (Node.js)**: single‑threaded event loop simplifies some concurrency but lacks the type‑safety and structured concurrency needed for complex orchestration state machines. Validation and serialization would rely on third‑party libraries with varying degrees of strictness. +* **Rust**: performance and safety are excellent, but the borrow checker and ecosystem immaturity for LLM tooling would slow development significantly. + +## consequences + +* JVM startup overhead; acceptable for a tool that runs as a persistent server or long‑lived CLI. +* Requires Kotlin knowledge; mitigated by good documentation and a growing community. +* Interop with native libraries (e.g., llama.cpp) will be via JNI or external processes; we accept that complexity and encapsulate it in infrastructure providers. +* We must enforce module dependency rules (with tools like ArchUnit) to prevent core from depending on infrastructure. + +## status + +This decision is foundational and aligns with the architectural need for a “workflow engine” rather than a “scripting environment”. \ No newline at end of file diff --git a/docs/decisions/adr-0003-compression-strategy.md b/docs/decisions/adr-0003-compression-strategy.md new file mode 100644 index 00000000..6444bc3e --- /dev/null +++ b/docs/decisions/adr-0003-compression-strategy.md @@ -0,0 +1,103 @@ +--- +name: "Adr 0003 Compression Strategy" +description: "Rule‑based compression, no summariser model for v1" +depth: 2 +links: ["../index.md", "../architecture/context-layers.md"] +--- + +# ADR 0003: adopt rule‑based compression with structured decision points; defer model‑based summarisation + +**status:** accepted +**date:** 07.05.2026 (May) +**deciders:** Kami + +--- + +## context + +Correx must feed large volumes of tool output, conversation history, artifacts, and event streams into limited local model context windows. Raw accumulation destroys performance, especially for smaller models. We need a compression strategy that: + +* is deterministic and replay‑safe +* does not itself require a large or separate model (for v1) +* preserves decision‑critical context, not just surface summaries +* works across multiple data sources (tools, chat, artifacts, events) + +## decision + +We adopt a **100% rule‑based compression system** for v1. No summariser model will be used for compression, even as an option. + +The four data sources will be handled as follows: + +### 1. tool output – RTK‑style pipeline + +All shell and tool command output is compressed using a configurable pipeline: + +1. **smart filter** – strip comments, whitespace, and known boilerplate +2. **group** – aggregate similar items (e.g., files by directory, errors by type) +3. **deduplicate** – collapse repeated identical lines with occurrence counts (`Error: timeout (×14)`) +4. **truncate** – keep first N and last M lines; discard middle unless marked critical + +On command failure, the full unfiltered output is saved to disk and only a pointer path is included in the compressed receipt. The model may request the raw log if needed. + +User‑defined tools can declare their own compression patterns (regex + fallback) directly in the tool definition, not in a global config. This ties compression ownership to the tool. + +### 2. conversation history – manual compaction with micro‑compaction + +For Router ↔ User conversations: + +* the last N messages are kept verbatim (configurable; default N = 6) +* **micro‑compaction** at ~60‑70% context pressure silently clears old tool outputs, keeping conversation intact +* **manual compaction** (like `/compact`) at logical breakpoints summarises older conversation into a brief paragraph, preserving action items and user intent +* users may **pin individual messages** to prevent their removal during compaction + +No automatic summarisation of conversation will occur; compaction is only triggered explicitly or when token pressure requires micro‑compaction. + +### 3. artifacts – chunking + summary field + +Large artifacts (e.g., plans) are broken into chunks; only the current step ± 1 neighbor is kept in context. Remaining chunks are stored on disk with a file pointer left in the artifact. + +All artifacts must include a `summary` field as part of their canonical schema. Non‑chunkable artifacts use this summary directly in context packs. + +### 4. event history → L2 – structured decision points + +L2 (compressed session memory) is built not by summarising events but by harvesting structured fields from existing validated artifacts and approval events: + +* `summary` from the artifact → what was done +* `rationale` from the artifact (optional) → why it was done +* user steering text from approval events → human guidance +* stage outcome (success/failure/retry) → final status + +These are assembled into a lightweight `DecisionPoint` record per completed stage. No model‑based summarisation is performed; the process is purely rule‑based and deterministic. + +Older decision points eventually graduate to L3 project memory when the L2 token cap is exceeded. + +### 5. no tiny summariser model + +We explicitly postpone any model‑based compression beyond v1. Even v2 will re‑evaluate only if rule‑based compression proves insufficient, and then only for specific narrow tasks. + +## consequences + +**positive:** + +* full determinism and replayability for all compression steps +* zero additional inference cost for compression +* tool output compressed dramatically (≥90% reduction on typical dev commands) +* conversation compaction is user‑controlled, preventing silent loss of important instructions +* decision points capture the *why*, not just the *what*, allowing later stages to reason about past choices +* architecture scales with more sophisticated compression later without changing contracts + +**negative:** + +* rule‑based extraction may miss decision nuances when models omit the `rationale` field (mitigated by prompt design and user steering) +* manual compaction requires user awareness; if never used, conversation history may still grow until micro‑compaction kicks in +* per‑tool regex patterns require tool authors to think about summarisation, adding a small authoring burden (mitigated by sensible defaults and fallback truncation) + +## alternatives considered + +* **dedicated tiny summariser model** – would improve compression quality but introduces nondeterminism, increased system complexity, and higher resource usage. Rejected for v1. +* **only truncation** – simplest but loses too much critical information. Rejected. +* **full context window without compression** – impossible for local models with limited context (≤32k tokens) and long‑running sessions. Rejected. + +## status + +This decision defines the v1 compression architecture. It may be revisited in a future ADR if empirical data shows rule‑based compression is insufficient for critical decision‑making. \ No newline at end of file diff --git a/docs/decisions/adr-0004-approval-tier-design.md b/docs/decisions/adr-0004-approval-tier-design.md new file mode 100644 index 00000000..fc69f231 --- /dev/null +++ b/docs/decisions/adr-0004-approval-tier-design.md @@ -0,0 +1,145 @@ +--- +name: "Adr 0004 Approval Tier Design" +description: "Five‑tier approval system with grants, timeouts, diff previews" +depth: 2 +links: ["../index.md", "../modules/core-approvals-submodule-spec.md"] +--- + +# ADR 0004: approval tier design with scoped grants, timeouts, and mandatory diff previews + +**status:** accepted +**date:** 07.05.2026 (May) +**deciders:** Kami + +--- + +## context + +Correx executes potentially destructive operations (shell commands, file mutations, network calls) based on model proposals. To maintain bounded autonomy and auditability, every such operation must pass through an approval gate. The system needs a configurable, replay‑safe approval model that is neither overly intrusive in safe contexts nor permissive in risky ones. + +Key requirements: + +- tier‑based risk classification for all tools and operations +- session‑scoped elevation of trust (modes) and fine‑grained grants +- first‑class support for user steering during approval +- timeout handling when the user is not available +- mandatory diff/preview of proposed changes before irreversible actions +- replay determinism without user interaction + +## decision + +We adopt a **five‑tier approval system** (T0–T4) with configurable timeouts, session/stage/project‑scoped grants, and mandatory diff previews. The approval engine is implemented in `:core:approvals`, called by `:core:validation`, and coordinates with `:core:orchestration` and `:core:sessions`. + +### 1. tier definitions + +| tier | meaning | default auto‑approve mode | examples | +|------|---------|---------------------------|----------| +| T0 | inference only | auto (always) | generating text, plan, summary | +| T1 | read‑only tools | auto (but mode can restrict) | `ls`, `cat`, `git status`, `grep` within allowlisted paths | +| T2 | reversible mutation | prompt | `git commit`, file edits inside versioned project, `pip install` in venv | +| T3 | external/network | prompt or deny (configurable) | `curl`, `git push`, `npm publish`, remote API calls | +| T4 | destructive | deny | `rm -rf`, database drop, force push to main | + +Every tool and stage declares its required tier. The highest tier among a chain (e.g., a pipe involving `curl`) determines the approval requirement. + +### 2. session modes + +Session mode sets the maximum tier that is automatically approved without user interaction. + +| mode | auto‑approve up to | use case | +|------|-------------------|----------| +| `deny` | nothing (prompt for T0+) | maximum safety, untrusted instructions | +| `prompt` | T1 | normal daily use (default) | +| `auto` | T2 | trusted, focused development session | +| `yolo` | T4 (all) | testing, debugging; heavily logged, explicit opt‑in required | + +Modes can be changed mid‑session via `/mode `, which emits an `ApprovalModeChanged` event. Yolo mode requires a confirmation prompt and enables verbose execution logging (full stdin/stdout/stderr, timing, scrubbed env vars) stored in a per‑session debug directory. + +### 3. session, stage, and project‑scoped grants + +Beyond the mode, users may issue fine‑grained, replayable grants that elevate auto‑approval for specific actions or time windows. + +| scope | duration | example | event emitted | +|-------|----------|---------|---------------| +| **session** | until session end | "auto‑approve all T2 for this session" | `ApprovalSessionGrantAdded` | +| **stage** | until current stage completes | "auto‑approve T2 for the rest of this stage" | `ApprovalStageGrantAdded` | +| **project** | persists across sessions (stored in project config) | "always auto‑approve `git commit` in this repo" | `GrantLoadedFromProject` (on load) | + +Grants are additive; they cannot lower the effective tier of a tool (a T3 tool remains T3, but a grant may auto‑approve it within scope). The approval engine evaluates grants in order of specificity: session overrides stage, stage overrides project. + +### 4. approval lifecycle (end‑to‑end) + +1. An artifact or tool invocation is about to proceed. +2. Validation pipeline’s approval layer queries `:core:approvals` with the current tier, session mode, and active grants. +3. If the tier is **auto‑approved** (by mode or grant), an `ApprovalGrantedAuto` event is recorded; execution proceeds immediately. +4. If the tier requires **user prompting**, an `ApprovalRequired` event is emitted. The session enters `AWAITING_APPROVAL` and orchestration suspends. +5. Router presents the approval prompt, including any generated diff/preview. +6. User responds with `approve` (optionally with steering text), `reject`, or an auto‑approve grant. The response is recorded as `ApprovalGranted` or `ApprovalRejected`. +7. If approved and steering text is present, it is captured verbatim and injected into the next context pack as a `UserSteering` entry. +8. Orchestration resumes (or transitions to recovery on rejection). + +### 5. approval timeouts + +Default timeout behavior for all tiers is **pause indefinitely** (`AWAITING_APPROVAL` persists until user response). For local‑first safety, this ensures no action is taken if the user steps away. + +Users may override timeout actions per tier in global or project config: + +```yaml +approvals: + timeouts: + T0: auto + T1: auto + T2: pause + T3: reject + T4: reject +``` + +Supported timeout actions: `reject`, `pause`, `auto_deny` (records rejection, does not pause). If a timeout fires, the corresponding decision event is recorded. + +### 6. diff/preview mandatory for T2+ + +Before any T2+ operation is approved, the user must see exactly what will happen. + +- **File mutations (filesystem tool):** a unified diff of proposed changes. Generated by writing the proposed content to a temporary file and computing a `git diff` against the original (using the real file or a temporary copy). The diff is included in the approval prompt. +- **Shell commands:** the exact command string, plus if the command is known to modify files (e.g., `rm`, `mv`), a list of affected paths via dry‑run inspection. +- **Git operations:** the diff of working tree changes or commit message/patch. +- **Scripts (bash, python):** the full script content is shown as a preview. + +Tools that cannot produce a preview are capped at T1 (read‑only) and may not mutate state. + +### 7. nested/chained commands + +- **Simple pipes** (`cat | grep | awk`): a single `ToolInvocationRequested` event; the entire pipeline inherits the highest risk tier of its components (e.g., if any part reaches network, T3). +- **Multi‑command scripts:** executed as a single tool invocation; tier determined by tool definition; preview shows the full script content. +- **Interactive sessions** (`python -i`, `bash -i`): treated as T3 minimum; short default timeout (30 s); user must be present. Output is captured as a side‑channel log; not treated as an artifact until the session ends and a receipt is produced. + +### 8. replay determinism + +During replay, all approval decisions are re‑applied from the event log. No user interaction is required. If a session is replayed in a mode different from the original (e.g., yolo → prompt), replay still uses the recorded decisions; the mode change only affects future new events. + +## consequences + +**positive:** + +- transparent, auditable, fine‑grained control over risk +- mandatory diff previews prevent blind execution of destructive commands +- session/stage/project grants balance safety with usability +- steering injection turns human approval into active guidance for models +- replay works without user presence, preserving audit integrity +- yolo mode enables efficient testing with full debug logs + +**negative:** + +- more complex orchestration (suspension/resumption, timeout enforcement) compared to a simpler approve‑everything or deny‑everything model +- mandatory diff previews impose a requirement on tool implementations (must support preview interface); tool authors bear a small additional burden +- interactive session handling is intentionally limited in v1 + +## alternatives considered + +- **binary approve/deny without tiers:** too coarse; some operations are inherently safe and should not require user presence; others must never auto‑approve. +- **per‑stage approval only (no tool‑level):** insufficient granularity; a single stage may run multiple independent risky commands. +- **no yolo mode:** slows down development and testing; rejected. + +## status + +This decision defines the v1 approval subsystem. Future extensions may add evaluator‑model‑based approvals, batch‑approve for multiple tool calls, and richer sandbox isolation. The core tier + grant + preview model is expected to remain stable. \ No newline at end of file diff --git a/docs/decisions/adr-0005-persistence-choice.md b/docs/decisions/adr-0005-persistence-choice.md new file mode 100644 index 00000000..9d67280d --- /dev/null +++ b/docs/decisions/adr-0005-persistence-choice.md @@ -0,0 +1,114 @@ +--- +name: "Adr 0005 Persistence Choice" +description: "SQLite primary event store, deferred Redis/Elasticsearch" +depth: 2 +links: ["../index.md", "../modules/core-events-submodule-spec.md" +--- + +# ADR 0005: persistence choice – SQLite primary event store with deferred Redis and Elasticsearch + +**status:** accepted +**date:** 07.05.2026 (May) +**deciders:** correx design team + +--- + +## context + +Correx requires an append‑only event store as the system’s authoritative source of truth. The store must support: + +* strict per‑session ordering and idempotent writes +* efficient replay via sequence‑range scans +* atomic multi‑event transactions +* zero‑setup, embedded operation for local‑first deployment +* a design that allows future migration to distributed backends + +We evaluated SQLite, MongoDB, RocksDB, Redis, Kafka, and PostgreSQL against these requirements, with an emphasis on simplicity for v1 while preserving architectural flexibility. + +## decision + +**SQLite with WAL mode** is the primary event store for correx v1. + +All events, snapshots, and materialised projections will be stored in a single local SQLite database. The `:core` modules will define repository interfaces (`EventStore`, `SnapshotStore`); `:infrastructure:persistence:sqlite` will provide implementations. + +### rationale for SQLite + +* **Embedded, zero‑setup** – critical for a local‑first tool; no external daemons. +* **WAL mode** – allows concurrent reads from multiple processes (e.g., CLI, dashboard) while the orchestrator writes. +* **Single‑file portability** – easy backup, archival, and replay. +* **Sufficient write performance** – session events are written sequentially and rarely exceed a few thousand per session; SQLite’s write throughput is more than adequate. +* **Mature Kotlin/Java support** via `sqlite-jdbc` and optional query libraries (Exposed). +* **Familiarity** – simpler mental model for contributors; SQL inspection during development is straightforward. + +### event storage schema + +A single `events` table: + +| column | type | description | +|--------|------|-------------| +| `session_id` | text | UUID of the session | +| `sequence` | integer | strict monotonic ordering within session | +| `event_id` | text | globally unique event UUID | +| `timestamp` | text | ISO‑8601 creation time | +| `type` | text | event category and name | +| `payload` | text | JSON blob (schema versioned) | +| `causation_id` | text | parent event UUID | +| `correlation_id` | text | execution lineage identifier | +| `version` | integer | event schema version | + +Index: `(session_id, sequence)` unique. Queries: range scans by session with cursor. + +### snapshotting + +**Trigger policy:** semantic, not purely count‑based. + +Snapshots are created after key “snapshot‑material” events: + +* `SessionInitialized` +* `StageCompleted` +* `ArtifactValidated` +* `ApprovalGranted` +* `SessionPaused` + +plus a fallback **max‑event count** (default 200) to prevent replay of unbounded gaps. A minimum interval (30 seconds) prevents snapshot storms. + +Snapshots are stored in a dedicated table, keyed by `(session_id, sequence)` and contain the serialized current projection state. They are always an optimisation; the event stream remains authoritative. Replay always applies events after the snapshot cursor, including any destructive or compensating events, so no state is silently lost. + +### log‑event correlation + +All external logs (tool stdout/stderr, inference debug) carry the corresponding `event_id` and `correlation_id` in their output. Tool receipts stored as `ToolExecutionCompleted` event payloads include a file path to the raw log. Drift between logs and events is prevented by design: events are written atomically first; logs reference those events. + +### future backends (v2+) + +We explicitly note two additional data stores that are **not part of v1** but are planned as optional infrastructure modules later: + +* **Redis** – for in‑process pub/sub streaming of events to real‑time dashboards, CLI progress, and live inspection. Redis is not a store of record; events remain in SQLite. +* **Elasticsearch** – as a secondary read‑model for full‑text search across artifact summaries, rationales, and tool outputs, and for observability queries (e.g., “show all sessions where approval tier T3 was used”). Events are pushed asynchronously via a projection subscriber; Elasticsearch is not the primary store. + +Both are optional, additive, and do not alter the core `EventStore` contract. + +## consequences + +**positive:** + +* zero‑setup local experience; the harness “just works” after install +* event store is inspectable with standard SQL tools during development and debugging +* semantic snapshotting ensures fast replay without capturing inconsistent intermediate states +* event‑first design prevents log/event drift +* clean migration path to PostgreSQL, RocksDB, or distributed backends via the `EventStore` interface + +**negative:** + +* SQLite’s single‑writer limitation means concurrent sessions must be serialised at the persistence layer (acceptable for single‑node v1; future versions will require a different backend for distributed workers) +* JSON payloads in SQLite lack native JSONB indexing; complex queries over event payloads are slower than in PostgreSQL or Elasticsearch (mitigated by keeping queries simple: always by session + sequence range) +* snapshot material events must be chosen conservatively; if an important event type is omitted, replay may be slower than ideal (adjustable via config) + +## alternatives considered + +* **RocksDB (embedded KV):** excellent write throughput and natural append‑only model; rejected because SQLite’s inspection friendliness and ecosystem maturity better suit a small team iterating rapidly. +* **MongoDB / PostgreSQL / Kafka:** require external daemons, violating local‑first simplicity. They remain strong candidates for v2 distributed deployments. +* **Redis as primary store:** insufficient durability guarantees for source‑of‑truth event storage; retained as a pub/sub transport for real‑time events. + +## status + +This decision governs the v1 persistence architecture. Re‑evaluation is expected when distributed workers, high‑volume concurrent sessions, or advanced full‑text search become hard requirements. \ No newline at end of file diff --git a/docs/decisions/adr-0006-event-store-append-only.md b/docs/decisions/adr-0006-event-store-append-only.md new file mode 100644 index 00000000..4306288b --- /dev/null +++ b/docs/decisions/adr-0006-event-store-append-only.md @@ -0,0 +1,76 @@ +--- +name: "Adr 0006 Event Store Append Only" +description: "Append‑only, ordered, idempotent event store invariants" +depth: 2 +links: ["../index.md", "../modules/core-events-submodule-spec.md", "./adr-0005-persistence-choice.md"] +--- + +# ADR-0006: Event Store Append-Only & Ordering Invariant + +**status:** accepted +**date:** 08.05.2026 +**deciders:** Kami + +--- + +## context + +The system relies on an append-only event log as the single source of truth. Replay correctness, session reconstruction, and deterministic projections all depend on strict ordering and immutability of persisted events. + +Without enforced guarantees at the storage layer, higher-level invariants (projection determinism, replay correctness) cannot be trusted. + +This ADR formalizes storage-level constraints for all EventStore implementations. + +--- + +## content + +The EventStore MUST enforce: + +### 1. append-only semantics + +* events cannot be updated or deleted +* event_id is immutable and unique per event + +### 2. idempotency + +* repeated append attempts with same event_id MUST be ignored or rejected safely +* duplicate events MUST NOT be stored + +### 3. per-session ordering + +* sequence MUST be strictly monotonically increasing per session_id +* ordering MUST be total within a session stream + +### 4. causality preservation + +* causation_id and correlation_id MUST be preserved as metadata only +* they MUST NOT affect ordering + +### 5. deterministic read model + +* read(session_id) MUST always return events in sequence order +* read consistency MUST not depend on implementation timing + +--- + +## consequences + +**positive:** + +* guarantees replay correctness foundation +* enables deterministic projection logic +* simplifies concurrency model (append-only reduces state conflicts) +* allows multiple backend implementations (sqlite, in-memory) + +**negative:** + +* no event mutation allowed (requires compensating events instead) +* stricter implementation requirements for persistence engines +* potential write amplification in correction scenarios + +--- + +## status + +accepted \ No newline at end of file diff --git a/docs/decisions/adr-0007-projection-replay.md b/docs/decisions/adr-0007-projection-replay.md new file mode 100644 index 00000000..ec75c7ba --- /dev/null +++ b/docs/decisions/adr-0007-projection-replay.md @@ -0,0 +1,83 @@ +--- +name: "Adr 0007 Projection Replay" +description: "Projection purity and replay determinism contract" +depth: 2 +links: ["../index.md", "../modules/core-events-submodule-spec.md", "./adr-0006-event-store-append-only.md"] +--- + +# ADR-0007: Projection & Replay Determinism Contract + +**status:** accepted +**date:** 08.05.2026 +**deciders:** Kami + +--- + +## context + +Projections and replay are responsible for reconstructing system state from event streams. Any nondeterminism in projection logic or replay execution breaks system consistency guarantees. + +The system requires strict guarantees that state reconstruction is reproducible across time, environment, and implementation. + +This ADR formalizes projection and replay correctness requirements. + +--- + +## content + +### 1. projection purity + +* Projection MUST be a pure function: + `EventStream → State` +* no hidden state or external dependencies are allowed +* projections MUST NOT mutate shared state + +### 2. determinism requirement + +* identical event streams MUST produce identical state +* ordering of events MUST be respected (sequence order) + +### 3. replay invariance + +* replay MUST produce identical results regardless of: + + * runtime environment + * execution timing + * storage backend implementation + +### 4. environment independence + +* replay MUST NOT depend on: + + * live services + * LLM calls + * external APIs +* replay MUST be fully deterministic offline + +### 5. disposable projections + +* projection state MUST be rebuildable at any time +* no projection state is authoritative; only event log is + +--- + +## consequences + +**positive:** + +* enables safe rebuild of entire system state +* guarantees consistency across infrastructure implementations +* simplifies debugging (full replayability) +* enables future snapshotting and caching layers + +**negative:** + +* prohibits non-deterministic projection logic +* forces strict separation of computation vs external effects +* requires careful handling of ordering assumptions + +--- + +## status + +accepted diff --git a/docs/decisions/adr-0008-contract-enforcment-with-tests.md b/docs/decisions/adr-0008-contract-enforcment-with-tests.md new file mode 100644 index 00000000..81d458df --- /dev/null +++ b/docs/decisions/adr-0008-contract-enforcment-with-tests.md @@ -0,0 +1,82 @@ +--- +name: "Adr 0008 Contract Enforcment With Tests" +description: "Contract testing to enforce event and replay invariants" +depth: 2 +links: ["../index.md", "./adr-0006-event-store-append-only.md", "./adr-0007-projection-replay.md"] +--- + +# ADR-0009: Contract Testing Enforcement of Event & Replay Invariants + +**status:** accepted +**date:** 08.05.2026 +**deciders:** Kami + +--- + +## context + +System correctness depends not only on design-time invariants (ADR-0007, ADR-0008) but also on continuous enforcement across implementations. + +Multiple EventStore and Replay implementations exist (in-memory, sqlite, future persistence layers). Without shared contract tests, invariants may diverge silently. + +This ADR defines the role of contract tests as enforcement layer for system invariants. + +--- + +## content + +### 1. contract-first enforcement model + +* all EventStore implementations MUST satisfy shared contract tests +* all replay implementations MUST satisfy shared replay contracts + +### 2. EventStore contract requirements + +Contract tests MUST enforce: + +* append-only behavior +* idempotency of event_id +* strict ordering per session +* deterministic read consistency +* correct sequence continuity + +### 3. Replay contract requirements + +Contract tests MUST enforce: + +* deterministic reconstruction of state +* identical results across repeated replay executions +* correctness over full event streams +* correctness over partial event streams (cursor-based replay) + +### 4. implementation independence + +* contract tests MUST NOT depend on specific implementation details +* tests operate only through interfaces (EventStore, EventReplayer, Projection) + +### 5. cross-backend equivalence + +* in-memory and persistent stores MUST produce identical replay results for identical inputs + +--- + +## consequences + +**positive:** + +* prevents divergence between implementations +* guarantees correctness across persistence backends +* enforces architectural invariants at CI level +* makes replay correctness regression-resistant + +**negative:** + +* increases test suite complexity +* requires careful abstraction of test fixtures +* slower feedback cycles for persistence changes + +--- + +## status + +accepted diff --git a/docs/decisions/adr-0009-replay-engine-projection-separation.md b/docs/decisions/adr-0009-replay-engine-projection-separation.md new file mode 100644 index 00000000..d3d8d295 --- /dev/null +++ b/docs/decisions/adr-0009-replay-engine-projection-separation.md @@ -0,0 +1,189 @@ +--- +name: "Adr 0009 Replay Engine Projection Separation" +description: "Generic replay engine separated from domain projections" +depth: 2 +links: ["../index.md", "../modules/core-events-submodule-spec.md", "../modules/core-sessions-submodule-spec.md"] +--- + +# ADR 0009: Generic Replay Engine and Projection Separation + +**status:** accepted +**date:** 08.05.2026 +**deciders:** Kami + +--- + +## context + +Correx uses an append-only event store as the single source of truth. + +Session state and future domain states are reconstructed entirely from ordered event streams. Replay determinism and projection purity are core system invariants. + +During implementation of Epic 2 (Session Lifecycle FSM), a distinction emerged between: + +* generic replay infrastructure +* domain-specific projection semantics + +The system already contained reusable replay infrastructure in `:core:events`: + +* `Projection` +* `StateBuilder` +* `EventReplayer` + +At the same time, session lifecycle logic introduced: + +* `SessionProjector` +* `SessionFsm` +* `SessionState` +* `SessionEvent` + +A design decision was required to prevent: + +* replay logic duplication +* infrastructure/domain coupling +* projection semantics leaking into the event engine + +--- + +## content + +The replay architecture is separated into two layers: + +### 1. Generic replay engine (`:core:events`) + +The `:core:events` module owns: + +* replay orchestration +* projection execution contracts +* state folding mechanics +* ordered event replay infrastructure + +This layer contains: + +* `Projection` +* `StateBuilder` +* `EventReplayer` +* replay implementations + +This layer MUST remain domain-agnostic. + +It MUST NOT contain: + +* session lifecycle logic +* FSM semantics +* domain state models +* business transition rules + +--- + +### 2. Domain projections (`:core:sessions`) + +The `:core:sessions` module owns: + +* session lifecycle semantics +* FSM transition logic +* session state reconstruction +* event interpretation for session domain + +This layer contains: + +* `SessionState` +* `SessionStatus` +* `SessionEvent` +* `SessionFsm` +* `SessionProjector` + +`SessionProjector` implements: + +```kotlin +Projection +``` + +and is executed by the generic replay engine. + +--- + +### 3. Replay flow + +Replay is defined as: + +```text +EventStore + ↓ +EventReplayer + ↓ +Projection + ↓ +State +``` + +For sessions: + +```text +EventStore + ↓ +EventReplayer + ↓ +SessionProjector + ↓ +SessionState +``` + +--- + +### 4. Projection purity rules + +All projections MUST be: + +* deterministic +* side-effect free +* replay-safe +* stateless outside reduction input + +Projections MUST NOT: + +* perform IO +* access system clocks +* mutate external state +* depend on runtime execution order outside event sequence + +Projection output MUST depend solely on: + +* previous state +* current event + +--- + +### 5. Session replay semantics + +Session state is not persisted directly. + +`SessionState` is reconstructed entirely from replaying ordered `StoredEvent` streams. + +FSM transitions are interpreted from persisted `EventPayload` instances through session-specific mapping logic. + +--- + +## consequences + +**positive:** + +* replay engine becomes reusable across domains +* deterministic replay guarantees remain centralized +* domain logic remains isolated from persistence mechanics +* multiple independent projections can coexist over same event stream +* session lifecycle logic remains testable in isolation +* future projections can reuse replay infrastructure without duplication + +**negative:** + +* introduces additional abstraction layers +* domain projections require explicit wiring into replay engine +* event interpretation requires mapper logic between payloads and FSM events +* debugging replay chains may require traversing multiple layers + +--- + +## status + +accepted diff --git a/docs/decisions/adr-0010-l3-vector-retrieval.md b/docs/decisions/adr-0010-l3-vector-retrieval.md new file mode 100644 index 00000000..9ab4d8cd --- /dev/null +++ b/docs/decisions/adr-0010-l3-vector-retrieval.md @@ -0,0 +1,93 @@ +--- +name: "Adr 0010 L3 Vector Retrieval" +description: "SQLite-vec default, Qdrant optional — semantic retrieval for L3 context injection" +depth: 2 +links: ["../index.md", "../architecture/context-layers.md", "../epics/epic-7-resolution.md"] +--- + +# ADR 0010: L3 context retrieval — SQLite-vec default with optional Qdrant backend + +**status:** accepted +**date:** 11.05.2026 (May) +**deciders:** correx design team + +--- + +## context + +L3 (durable project memory) holds persistent facts, architecture decisions, and key outputs across sessions. The context engine (Epic 7) assembles token-budgeted packs from L0–L2; L3 is injected selectively when relevant. + +Injecting all L3 entries unconditionally is not viable — the layer grows unboundedly and most entries will be irrelevant to any given stage. A relevance signal is needed to select which L3 entries enter a pack. + +Semantic similarity via vector embeddings is the natural fit: embed each L3 entry at write time, query by the current stage's context at pack-build time, inject the top-K matches. + +**Determinism constraint (ADR-0000 #10):** vector search is nondeterministic — the same query may return different results as the index evolves. Allowing live vector search to determine pack contents would break replay. This is resolved by committing the selected L3 entry IDs as an event (`L3EntriesSelectedEvent`) before pack assembly. Replay uses the committed IDs, not a live query. + +## decision + +Correx will use **SQLite-vec** as the default vector store for L3 retrieval, with **Qdrant** as an optional configurable backend. + +`:core:context` defines a `VectorStore` port (interface). `:infrastructure:tools:sqlite-vec` and `:infrastructure:tools:qdrant` provide implementations. The active backend is selected via configuration — no core module is coupled to either. + +### SQLite-vec (default) + +* embedded, zero-setup — consistent with the local-first posture established in ADR-0005 +* co-located with the primary SQLite event store; single-file deployment +* accessed via SQLite JDBC — no additional client library +* sufficient for local sessions where L3 grows to tens of thousands of entries + +### Qdrant (optional) + +* self-hosted Rust binary, HTTP/gRPC API +* scales to large L3 indices and supports filtered ANN queries +* activated by setting `vectorStore.backend = qdrant` in configuration +* requires a sidecar process; not suitable for zero-setup local deployments + +### determinism contract + +1. at L3 entry write time: embed content, store vector in the active backend +2. at pack-build time: query top-K by cosine similarity to the current stage context +3. commit `L3EntriesSelectedEvent(sessionId, stageId, selectedEntryIds, queryEmbedding)` +4. pack assembly reads `selectedEntryIds` from the event — not from a live query +5. replay uses the committed event; the vector store is never queried during replay + +The vector store is a read model for selection. The event log remains authoritative. + +### VectorStore port + +```kotlin +interface VectorStore { + fun upsert(id: ContextEntryId, vector: FloatArray, metadata: Map) + fun query(vector: FloatArray, topK: Int, filter: Map = emptyMap()): List + fun delete(id: ContextEntryId) +} + +data class VectorMatch(val id: ContextEntryId, val score: Float) +``` + +## consequences + +**positive:** + +* zero-setup default — SQLite-vec adds no external process for local deployments +* Qdrant available for production or large-scale use without changing core code +* determinism preserved — vector search never touches replay path +* selection is auditable and replayable via `L3EntriesSelectedEvent` +* port/adapter pattern is consistent with existing infrastructure separation (ADR-0005) + +**negative:** + +* SQLite-vec has practical limits at very large L3 indices (tens of millions of entries); Qdrant required at that scale +* embedding generation (not vector retrieval) is a nondeterministic step — embedding model version changes may alter which entries are selected for future sessions (existing committed events are unaffected) +* two infrastructure implementations to maintain + +## alternatives considered + +* **Chroma:** Python-native; adds a Python service dependency inconsistent with a JVM-first project. +* **pgvector:** requires PostgreSQL; heavier than SQLite for local-first deployment. +* **Keyword/tag-based filtering:** deterministic but misses semantic relevance; insufficient for open-ended L3 memory. +* **Inject all L3 entries:** correct but not token-budget-safe as L3 grows. + +## status + +accepted. Implementation deferred to the epic that introduces L3 write and retrieval infrastructure. diff --git a/docs/decisions/adr-0011-transition-engine-contracts.md b/docs/decisions/adr-0011-transition-engine-contracts.md new file mode 100644 index 00000000..7f9c3571 --- /dev/null +++ b/docs/decisions/adr-0011-transition-engine-contracts.md @@ -0,0 +1,60 @@ +--- +name: "Adr 0011 Transition Engine Contracts" +description: "Defines ownership of StageId and the no-match behavior of the transition resolver" +depth: 2 +links: ["../index.md", "./adr-0001-event-sourcing.md", "./adr-0009-replay-engine-projection-separation.md"] +--- + +# ADR 0011: transition engine contracts — StageId ownership and no-match behavior + +**status:** accepted +**date:** 15.05.2026 (May) +**deciders:** Kami + +--- + +## context + +Two behaviors in the transition engine were undefined, creating silent failure modes: + +1. **No-match behavior**: `DefaultTransitionResolver` returned `Stay` when a stage had no outgoing transitions. This conflated two distinct cases: "conditions not yet met, keep waiting" (`Stay`) and "no transitions are defined for this stage" (structurally undefined). The latter would cause the orchestrator to loop indefinitely with no path forward. + +2. **`StageId` origin**: nothing explicitly prevented the transition engine from constructing `StageId` values internally. This would break the invariant that the orchestrator is the sole source of stage identity, making execution traces harder to reason about and replay. + +## decision + +### 1. No-match is a typed failure + +`TransitionDecision` gains a `NoMatch` case. The resolver returns `NoMatch` when the graph contains no outgoing edges for the current stage. This is distinct from: + +- `Stay` — outgoing edges exist but no condition evaluated to true; waiting is the correct response. +- `Blocked` — a specific guard explicitly prevented the transition. +- `NoMatch` — the graph has no definition for what to do next; this is a workflow authoring error or an unhandled terminal state. + +The orchestrator is responsible for observing `NoMatch` and emitting a `StageFailedEvent` with reason `"no matching transition"`. The resolver is pure: it classifies; it does not emit events. + +### 2. StageId is always caller-generated + +`StageId` values are created exclusively by the orchestrator (or workflow graph construction at startup). The transition engine — resolver, evaluator, and related types — must never construct a `StageId`. It receives `StageId` values through `EvaluationContext` and `WorkflowGraph` and returns them in `TransitionDecision.Move`. It does not originate them. + +This rule preserves: +- **Replay determinism**: stage identity is fixed at graph construction time, not derived during evaluation. +- **Single source of truth**: the orchestrator owns execution flow; the transition engine advises it. + +## consequences + +**positive:** + +- No-match is observable and testable; indefinite loops caused by missing transitions become explicit failures. +- `Stay` retains its precise meaning: "wait for conditions", not "don't know what to do". +- `StageId` origin is unambiguous; code review can statically verify compliance. + +**negative:** + +- Callers of `resolve()` that previously exhaustively handled `Move | Stay | Blocked` must now handle `NoMatch`. This is intentional: forcing the call site to handle it prevents silent suppression. + +## alternatives considered + +- **Return `Blocked` with a special reason string**: rejected. Encoding structural failure as a `Blocked` with a magic string degrades type safety and makes exhaustive matching useless. +- **Throw an exception on no-match**: rejected. Exceptions are not part of the resolver's contract. The orchestrator decides what a `NoMatch` means in context (fail, alert, retry on graph reload); that policy does not belong in the resolver. +- **Allow transition engine to generate `StageId`**: rejected. Any engine-generated ID would be invisible to the event log before the `StageFailedEvent`, breaking the invariant that all state transitions are traceable. diff --git a/docs/design/des-doc-v0.1.md b/docs/design/des-doc-v0.1.md new file mode 100644 index 00000000..0b8c7952 --- /dev/null +++ b/docs/design/des-doc-v0.1.md @@ -0,0 +1,662 @@ +--- +name: "Des Doc V0.1" +description: "Design document with full layer diagrams" +depth: 1 +links: ["../index.md", "./spec-v0.1.md"] +--- + +harness design document + +version: 0.1 +status: architectural draft + +1. overview + +Harness is a local-first orchestration runtime for structured LLM execution. + +The system is designed around a strict separation of concerns: + +layer| responsibility +Router| conversational UX +Harness| orchestration + policy +Agent Runtime| task execution +Model Manager| inference lifecycle +Context Processor| memory synthesis +Event Store| persistence + replay +Tool Runtime| external actions + +The architecture assumes: + +- LLMs are probabilistic +- context is expensive +- memory must be externalized +- workflows require validation +- inference is transient +- orchestration owns reliability + +The system intentionally resembles operating system architecture more than chatbot architecture. + +--- + +2. architectural goals + +primary goals + +- replayable execution +- bounded autonomy +- local-first operation +- model/provider abstraction +- deterministic-enough workflows +- strict validation +- structured artifacts +- observability +- hardware-aware scheduling + +--- + +secondary goals + +- distributed execution +- remote provider failover +- semantic memory +- automatic evaluation +- synthetic training data generation + +--- + +3. system architecture + + ┌────────────────┐ + │ User │ + └───────┬────────┘ + │ + ▼ + ┌────────────────────────┐ + │ Router │ + │ conversational facade │ + └──────────┬─────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Harness │ +│ │ +│ ┌────────────┐ ┌────────────┐ ┌──────────────────┐ │ +│ │ Session │ │ Transition │ │ Approval Engine │ │ +│ │ Lifecycle │ │ Engine │ │ │ │ +│ └─────┬──────┘ └─────┬──────┘ └────────┬─────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ Event Bus │ │ +│ └───────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Execution Layer │ +│ │ +│ ┌──────────────┐ ┌──────────────────────────────┐ │ +│ │ StageRuntime │ │ ContextProcessor │ │ +│ └──────┬───────┘ └──────────────┬───────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Agent Runtime │ │ +│ └──────────────────────┬───────────────────────┘ │ +└─────────────────────────┼────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Inference Layer │ +│ │ +│ ┌───────────────┐ ┌────────────────────────────┐ │ +│ │ Model Manager │────▶│ Inference Providers │ │ +│ └───────────────┘ │ local / remote / hybrid │ │ +│ └────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────┐ + │ Tool Runtime │ + └────────────────┘ + +--- + +4. execution lifecycle + +step 1 — user interaction + +User communicates only with Router. + +Router responsibilities: + +- intent interpretation +- steering ingestion +- summarization +- conversational continuity + +Router never owns workflow state. + +--- + +step 2 — session initialization + +Harness: + +- creates session +- initializes projections +- emits SessionCreated event +- resolves workflow entry stage + +--- + +step 3 — context synthesis + +ContextProcessor: + +- retrieves relevant events +- compresses history +- filters artifacts +- injects policies +- builds token budget + +Output: + +ContextPack + +--- + +step 4 — stage execution + +StageRuntime: + +- resolves agent role +- resolves required capabilities +- acquires suitable model +- executes inference + +--- + +step 5 — artifact validation + +Pipeline: + +1. routing validation +2. payload validation +3. semantic validation +4. approval validation + +Only validated artifacts emit success transitions. + +--- + +step 6 — transition evaluation + +TransitionEngine: + +- evaluates rule graph +- emits transition event +- schedules next stage + +--- + +step 7 — replay + persistence + +Every mutation: + +- emits immutable event +- updates projections +- persists session state + +--- + +5. event sourcing architecture + +rationale + +Event sourcing is mandatory because: + +- LLM execution is nondeterministic +- debugging requires replayability +- context reconstruction must be deterministic +- memory requires compression pipelines + +--- + +event categories + +DomainEvents +InferenceEvents +ToolEvents +ApprovalEvents +CompressionEvents +LifecycleEvents +SystemEvents + +--- + +event flow + +UserInputReceived + ↓ +StageScheduled + ↓ +ContextBuilt + ↓ +InferenceStarted + ↓ +ArtifactProduced + ↓ +ArtifactValidated + ↓ +ApprovalRequested + ↓ +TransitionExecuted + +--- + +6. context processing design + +core assumption + +Raw conversational accumulation destroys smaller models. + +Context must be: + +- hierarchical +- compressed +- relevance-ranked +- bounded + +--- + +context layers + +layer| meaning +L0| live execution +L1| active stage +L2| compressed session memory +L3| durable project memory +L4| archival history + +--- + +context synthesis pipeline + +events + ↓ +deduplication + ↓ +relevance ranking + ↓ +semantic compression + ↓ +artifact extraction + ↓ +policy injection + ↓ +token budgeting + ↓ +ContextPack + +--- + +compression rules + +tool logs + +Raw: + +4000 lines shell output + +Compressed: + +pytest failed: +- auth_test.py +- timeout in token refresh + +--- + +retention policy + +data| strategy +artifacts| latest valid +tool logs| summarize +conversation| semantic +transitions| retain +approvals| retain + +--- + +7. model management + +design goals + +- limited hardware optimization +- hot-swapping +- GPU residency control +- provider abstraction +- inference isolation + +--- + +model manager responsibilities + +- model lifecycle +- loading/unloading +- GPU scheduling +- swap timeout enforcement +- health monitoring +- concurrency limits + +--- + +scheduling strategies + +residency modes + +mode| behavior +persistent| never unload +dynamic| unload after timeout +ephemeral| unload immediately + +--- + +capability routing + +Stages do not request model names. + +Stages request capabilities: + +requirements: + - coding + - reasoning + - tool_calling + +Registry resolves: + +- best local model +- available GPU budget +- fallback providers + +--- + +8. agent runtime + +design assumptions + +Agents are: + +- stateless +- ephemeral +- replaceable + +Agents never: + +- persist memory +- mutate workflow state directly +- own permissions + +--- + +agent lifecycle + +spawn + ↓ +receive ContextPack + ↓ +execute + ↓ +emit Artifact + ↓ +terminate + +--- + +9. approval system + +rationale + +Autonomous systems require bounded risk. + +Approval gates prevent: + +- destructive execution +- runaway automation +- hidden escalation + +--- + +approval tiers + +tier| meaning +T0| inference only +T1| read-only +T2| reversible mutation +T3| external/network +T4| destructive + +--- + +steering-aware approvals + +Approvals may inject corrective context. + +Example: + +approved, but verify migrations against staging schema first + +Approval events become part of future context synthesis. + +--- + +10. transition engine + +rule-based execution + +Transitions are declarative. + +No workflow logic is hardcoded. + +--- + +example + +transitions: + - when: + artifact.status == "success" + goto: validation + + - when: + retries > 3 + goto: failed + +--- + +safeguards + +Required: + +- cycle detection +- deadlock detection +- transition tracing +- graph visualization + +--- + +11. validation system + +layered validation + +layer 1 — routing + +Checks: + +- stage compatibility +- capability availability +- policy alignment + +--- + +layer 2 — schema + +Pydantic validation: + +- structure +- typing +- required fields + +--- + +layer 3 — semantic + +Checks: + +- hallucinated paths +- invalid references +- unsafe commands +- contradictory artifacts + +--- + +layer 4 — approval + +Checks: + +- policy thresholds +- user permissions +- escalation rules + +--- + +12. persistence model + +storage backend + +Default: + +SQLite + +Future: + +- PostgreSQL +- distributed event stores + +--- + +persisted entities + +entity| purpose +events| source of truth +projections| fast reads +artifacts| outputs +approvals| audit +transitions| replay +summaries| context synthesis + +--- + +13. observability + +required telemetry + +- token usage +- inference latency +- stage duration +- retries +- approval frequency +- tool failures +- transition graphs + +--- + +debugging features + +- replay from cursor +- event inspection +- context inspection +- transition trace +- artifact lineage + +--- + +14. security model + +principles + +- least privilege +- explicit escalation +- isolated execution +- auditable actions + +--- + +recommendations + +- sandbox shell tools +- filesystem allowlists +- network policies +- process isolation +- execution timeouts +- secret vault integration + +--- + +15. scalability roadmap + +v1 + +single-node: + +- sqlite +- local inference +- sequential execution + +--- + +v2 + +multi-provider: + +- distributed workers +- remote execution +- shared event store + +--- + +v3 + +adaptive orchestration: + +- evaluator models +- speculative execution +- automatic routing optimization + +--- + +16. architectural risks + +risk| mitigation +context entropy| aggressive compression +workflow spaghetti| transition tracing +infinite retries| bounded retry policies +GPU thrashing| residency scheduler +hallucinated execution| semantic validators +hidden state| event sourcing + +--- + +17. philosophy + +Harness treats LLMs as bounded semantic processors embedded inside deterministic orchestration. + +Reliability emerges from: + +- validation +- event sourcing +- constrained execution +- context synthesis +- approval systems +- replayability + +not from trusting model intelligence alone. \ No newline at end of file diff --git a/docs/design/funure-semantic-rules.md b/docs/design/funure-semantic-rules.md new file mode 100644 index 00000000..5fb82e65 --- /dev/null +++ b/docs/design/funure-semantic-rules.md @@ -0,0 +1,13 @@ +--- +name: "Funure Semantic Rules" +description: "Placeholder for future semantic validation rules" +depth: 1 +links: ["../index.md"] +--- + +potential rules later: +stage reachability consistency vs session expectations +invalid artifact flow constraints +agent binding completeness +tool availability mismatch +policy timeout sanity checks \ No newline at end of file diff --git a/docs/design/lang-framewok-missing-pieces.md b/docs/design/lang-framewok-missing-pieces.md new file mode 100644 index 00000000..fb071913 --- /dev/null +++ b/docs/design/lang-framewok-missing-pieces.md @@ -0,0 +1,361 @@ +--- +name: "Lang Framewok Missing Pieces" +description: "Language/framework recommendations and missing subsystems" +depth: 1 +links: ["../index.md", "./spec-v0.1.md"] +--- + +language: +use Kotlin. + +not because it’s trendy, but because architecture is inherently: + +* state-heavy +* concurrency-heavy +* schema-heavy +* event-heavy +* validation-heavy + +that maps extremely well to: + +* sealed hierarchies +* coroutines +* structured concurrency +* immutable data classes +* serialization +* type-safe DSLs + +avoid: +* Python as primary runtime +* TypeScript as orchestration core + +they’re excellent glue languages, but this system is closer to: +* a workflow engine +* an orchestration runtime +* a distributed state machine +than an app backend. + +kotlin gives: +* better long-term maintainability +* safer concurrency +* better event typing +* cleaner DSLs +* stronger replay guarantees + +framework stack +core runtime: +* plain kotlin first +* minimal framework dependence + +web/api: +* [Ktor](https://ktor.io?utm_source=chatgpt.com) + +reasons: +* coroutine-native +* lightweight +* excellent websocket support +* no spring complexity +* easy embedding +* modular + +do NOT use: +* [Spring Boot](https://spring.io/projects/spring-boot?utm_source=chatgpt.com) +spring will slowly eat the architecture: +* hidden lifecycle +* implicit DI magic +* reflection-heavy +* runtime complexity +* startup overhead +* difficult deterministic control +system wants explicit orchestration. + +persistence: +* [Exposed](https://github.com/JetBrains/Exposed?utm_source=chatgpt.com) OR plain SQL +* [SQLite](https://www.sqlite.org/index.html?utm_source=chatgpt.com) initially +* migrate later to [PostgreSQL](https://www.postgresql.org/?utm_source=chatgpt.com) + +serialization: +* [kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization?utm_source=chatgpt.com) + +config: +* [Hoplite](https://github.com/sksamuel/hoplite?utm_source=chatgpt.com) + or +* [Typesafe Config](https://github.com/lightbend/config?utm_source=chatgpt.com) + +logging: +* structured logging ONLY +* json logs +* correlation ids everywhere + +CLI: +* [Clikt](https://ajalt.github.io/clikt/?utm_source=chatgpt.com) + +TUI: +* honestly optional initially. +* if needed later: + + * [Mordant](https://github.com/ajalt/mordant?utm_source=chatgpt.com) + * or web dashboard instead + +web UI: +frontend: +* [SvelteKit](https://svelte.dev/docs/kit/introduction?utm_source=chatgpt.com) + +not react. + +reasons: +* simpler state model +* less boilerplate +* lower memory +* faster iteration +* easier websocket/event-stream integration + +ui should behave like: +* workflow inspector +* event debugger +* replay console +* orchestration monitor + +NOT “chatgpt clone ui”. + +transport: +* websocket first +* event-stream oriented + +REST only for: +* management +* configs +* health +* exports + +real-time state should be event-driven. + +architecture split +important: +core MUST NOT depend on infrastructure. +only interfaces/ports. +hexagonal architecture fits this system very well. + +recommended internal layering +domain layer +pure logic: +* events +* transitions +* policies +* approvals +* artifacts +* projections +* session state + +NO IO. + +application/service layer +orchestration: +* stage execution +* replay coordination +* context synthesis +* validation pipeline +* routing + +infrastructure layer +actual implementations: +* sqlite +* llama.cpp +* shell execution +* websocket +* filesystem + +interface layer +external access: +* cli +* api +* ui +* websocket + +plugin layer +dynamic extensibility. +missing pieces in the spec + +1. scheduler subsystem + +* queueing +* prioritization +* cancellation +* starvation prevention +* concurrency caps +* backpressure + +eventually: +```text id="on4q1p" +StageScheduled +StageDeferred +StageBlocked +StagePreempted +``` + +2. capability negotiation +currently: +stage requests capabilities. + +but models/tools/providers should advertise: + +* hard capabilities +* soft capabilities +* confidence +* limits + +example: + +```yaml id="w4h8b4" +coding: + score: 0.92 +reasoning: + score: 0.61 +tool_calling: + score: 0.74 +``` + +otherwise routing becomes binary and crude. + +3. deterministic tool contracts + +VERY important. + +tools should never return freeform text internally. + +tool outputs must be typed. + +bad: + +```json id="v4g2pr" +"pytest failed due to auth issue" +``` + +good: + +```json id="i08vuj" +{ + "failed_tests": [ + { + "file": "auth_test.py", + "reason": "timeout" + } + ] +} +``` + +models can consume summaries. +harness consumes structure. + +4. model sandboxing + +define explicitly: + +* max execution time +* max tokens +* max context +* max retries +* cancellation semantics +* kill signals +* watchdogs + +otherwise local models WILL hang eventually. + +5. config versioning/migrations + +absolutely need: + +```yaml id="v0i3qf" +config_version: 1 +``` + +plus migration system. + +6. projection snapshots + +replaying 100k events eventually becomes painful. + +need: + +* periodic snapshots +* projection checkpoints +* replay cursors + +classic event sourcing problem. + +7. artifact lineage graph + +this one is important and often missed. + +artifacts should track: + +* parent artifacts +* originating events +* tool receipts +* approvals +* validator passes + +this enables: + +* blame tracing +* replay diffing +* synthetic training extraction + +8. state machine formalization + +session lifecycle should become an explicit finite state machine. + +not enums. + +otherwise invalid transitions creep in later. + +9. policy engine + +currently mixed into approvals/validation. + +should probably become its own subsystem. + +because eventually policies will govern: + +* tools +* providers +* models +* routing +* retries +* approvals +* networking +* secrets +* filesystem access + +10. trust boundaries + +* model boundary +* tool boundary +* plugin boundary +* provider boundary +* ui boundary + +especially if third-party plugins become possible later. + +big recommendation +```text +everything important is append-only +``` + +events: +append-only + +artifacts: +immutable + +receipts: +immutable + +approvals: +immutable + +summaries: +versioned + +projections: +rebuildable \ No newline at end of file diff --git a/docs/design/spec-v0.1.md b/docs/design/spec-v0.1.md new file mode 100644 index 00000000..b80d1b30 --- /dev/null +++ b/docs/design/spec-v0.1.md @@ -0,0 +1,672 @@ +--- +name: "Spec V0.1" +description: "Early system specification (Harness v0.1)" +depth: 1 +links: ["../index.md", "../architecture/overview.md"] +--- + +harness architecture specification + +version: 0.1-draft + +1. purpose + +Harness is a config-driven orchestration runtime for local and remote LLM workflows. + +The system treats models as interchangeable execution engines while the Harness owns: + +- lifecycle +- memory +- orchestration +- permissions +- validation +- event sourcing +- workflow transitions +- context synthesis + +Primary goals: + +- deterministic-enough execution on nondeterministic models +- local-first operation +- replayable execution +- bounded autonomy +- strong observability +- minimal human steering +- model/provider agnosticism + +Non-goals: + +- AGI simulation +- unconstrained autonomous agents +- hidden implicit memory +- permanently accumulating context + +--- + +2. architecture overview + +┌──────────────────────────┐ +│ User │ +└────────────┬─────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ Router │ +│ conversational interface │ +└────────────┬─────────────┘ + │ summaries + ▼ +┌──────────────────────────┐ +│ Harness │ +│ orchestration kernel │ +│ event bus │ +│ approval engine │ +│ lifecycle owner │ +└────────────┬─────────────┘ + │ + ┌───────┴────────┐ + ▼ ▼ +┌───────────┐ ┌────────────┐ +│ Stage │ │ Context │ +│ Runtime │ │ Processor │ +└─────┬─────┘ └─────┬──────┘ + │ │ + ▼ ▼ +┌──────────────────────────┐ +│ Agent Runtime │ +└────────────┬─────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ Inference Layer │ +│ local / remote models │ +└────────────┬─────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ Tools │ +└──────────────────────────┘ + +--- + +3. core principles + +3.1 event sourced + +All system state is reconstructable from events. + +No mutable hidden memory exists outside event projections. + +Benefits: + +- replayability +- deterministic debugging +- auditability +- recovery +- analytics +- synthetic dataset generation + +--- + +3.2 models are stateless + +Models do not own: + +- memory +- workflow state +- permissions +- lifecycle + +Models receive synthesized context only. + +--- + +3.3 validation-first execution + +Every agent output is validated at multiple levels: + +1. routing validation +2. payload schema validation +3. semantic validation +4. approval validation + +Invalid artifacts cannot progress workflow state. + +--- + +3.4 context is synthesized + +Raw accumulation is forbidden. + +All context is: + +- filtered +- deduplicated +- compressed +- summarized +- relevance-ranked + +--- + +4. terminology + +term| meaning +Harness| orchestration kernel +Router| conversational interface layer +Stage| workflow state +Agent| execution worker +Role| semantic responsibility +Artifact| validated structured output +Transition| movement between stages +Event| immutable state mutation +Projection| derived state view +Context Pack| synthesized model input +Approval Gate| human/system checkpoint + +--- + +5. configuration system + +5.1 config locations + +Global: + +~/.config/harness/ + +Project-local: + +./.harness/ + +Precedence: + +project > user > defaults + +--- + +5.2 config files + +harness.yaml +models.yaml +tools.yaml +stages.yaml +policies.yaml +compression.yaml + +--- + +6. model registry + +6.1 model definition + +models: + qwen_coder_14b: + provider: local + path: /models/qwen-coder.gguf + + capabilities: + - coding + - tool_calling + - reasoning + + context_size: 32768 + + kv_cache: + quantization: q8_0 + + gpu: + layers: 48 + residency: dynamic + swap_timeout_sec: 300 + + inference: + temperature: 0.2 + top_p: 0.9 + repeat_penalty: 1.1 + + limits: + max_parallel_sessions: 2 + +--- + +7. providers + +7.1 local provider + +Responsibilities: + +- llama.cpp process management +- GPU residency +- model loading/unloading +- warm pools +- swap scheduling +- health monitoring + +--- + +7.2 remote provider + +providers: + openai_compatible: + base_url: https://api.example.com/v1 + api_key_env: HARNESS_API_KEY + + extra: + timeout: 120 + retries: 3 + +--- + +8. stages + +Stages define execution states. + +Example: + +stages: + implementation: + role: coder + + requirements: + - coding + - tool_calling + + allowed_tools: + - filesystem + - git + - shell + + approvals: + tool_tier_3: required + + transitions: + on_success: + - validation + on_failure: + - retry + +--- + +9. agents + +Agents are ephemeral execution workers. + +Agents: + +- consume Context Packs +- emit Artifacts +- do not persist memory + +--- + +10. router + +Router responsibilities: + +- user interaction +- conversational continuity +- summarizing system state +- steering interpretation + +Router is always available but not persistent in execution context. + +Execution agents unload router context during active work. + +After completion: + +- outputs are summarized +- summaries are injected into router memory + +--- + +11. artifacts + +All stage outputs MUST emit structured artifacts. + +Example: + +class ImplementationArtifact(BaseModel): + summary: str + modified_files: list[str] + risks: list[str] + commands_executed: list[str] + next_recommendation: str + +--- + +12. validation pipeline + +12.1 routing validation + +Checks: + +- valid stage transitions +- capability compatibility +- policy compliance + +--- + +12.2 payload validation + +Pydantic validation: + +- schema correctness +- required fields +- typing + +--- + +12.3 semantic validation + +Checks: + +- contradictory outputs +- hallucinated files +- invalid commands +- policy violations +- unsafe operations + +--- + +13. approval system + +13.1 approval tiers + +tier| meaning +T0| inference only +T1| read-only +T2| reversible mutation +T3| external/network +T4| destructive + +--- + +13.2 approval modes + +mode| meaning +prompt| require confirmation +auto| auto approve +deny| reject automatically +yolo| bypass safeguards + +--- + +13.3 approval actions + +User may: + +- approve +- reject +- auto-approve for session +- steer execution + +Example: + +approved, but verify auth edge cases first + +--- + +14. event system + +14.1 event categories + +DomainEvents +SystemEvents +InferenceEvents +ToolEvents +ApprovalEvents +CompressionEvents +LifecycleEvents + +--- + +14.2 event structure + +class Event(BaseModel): + id: UUID + session_id: UUID + timestamp: datetime + type: str + payload: dict + causation_id: UUID | None + correlation_id: UUID | None + +--- + +15. replay system + +Replay modes: + +- full replay +- partial replay +- replay from cursor +- inference-skipping replay +- deterministic simulation + +Replay reconstructs projections and workflow state. + +--- + +16. context processor + +The ContextProcessor synthesizes minimal relevant context. + +Responsibilities: + +- deduplication +- summarization +- ranking +- token budgeting +- artifact extraction +- tool compression + +--- + +16.1 context layers + +L0 live execution +L1 stage-local context +L2 compressed session memory +L3 project memory +L4 archival history + +--- + +16.2 compression strategies + +compression: + tool_logs: + mode: summarize + + artifacts: + mode: latest_only + + events: + mode: deduplicate + + conversations: + mode: semantic_summary + +--- + +17. tool system + +Tools are config-driven and capability-scoped. + +Default tools may be: + +- disabled +- replaced +- overridden + +Example: + +tools: + shell: + enabled: true + tier: T2 + + git: + enabled: true + tier: T2 + + curl: + enabled: true + tier: T3 + +--- + +18. transitions + +Transitions are rule-based. + +No hardcoded workflow graphs exist in code. + +Example: + +transitions: + - when: + artifact.status == "success" + goto: validation + + - when: + retries > 3 + goto: failed + +--- + +19. retry policies + +19.1 strategies + +strategy| behavior +retry| retry execution +fail_safe| skip and continue +fail_fast| terminate session + +--- + +19.2 retry configuration + +retry: + strategy: corrective + max_attempts: 3 + inject_failure_reason: true + temperature_backoff: true + +--- + +20. persistence + +Default persistence: + +SQLite + +Future: + +- PostgreSQL +- event stores +- distributed backends + +Persisted: + +- events +- projections +- artifacts +- approvals +- transitions +- summaries + +--- + +21. session lifecycle + +Harness owns session lifecycle. + +States: +created +active +paused +awaiting_approval +failed +completed +cancelled + +--- + +22. observability + +Required: + +- event tracing +- transition tracing +- inference timing +- token accounting +- tool execution logs +- replay diagnostics + +Recommended: + +- DAG visualization +- live stage graph +- approval history + +--- + +23. security model + +Principles: + +- least privilege +- explicit approvals +- isolated tools +- auditability +- bounded execution + +Recommendations: + +- sandbox shell tools +- filesystem allowlists +- network policy control +- secret isolation +- execution timeouts + +--- + +24. future extensions + +Potential: + +- distributed agents +- evaluator models +- speculative execution +- long-term semantic memory +- automatic fine-tuning corpus extraction +- capability benchmarking +- adaptive routing + +--- + +25. anti-goals + +Avoid: + +- hidden prompts +- invisible memory mutation +- unrestricted recursion +- self-modifying workflows +- implicit approvals +- context accumulation without compression +- permanent agent processes + +--- + +26. philosophy summary + +Harness is not an “AI agent framework”. + +It is: + +- an orchestration kernel +- an event-sourced execution runtime +- a bounded autonomy system +- a deterministic shell around probabilistic cognition \ No newline at end of file diff --git a/docs/design/structure.md b/docs/design/structure.md new file mode 100644 index 00000000..a61010f6 --- /dev/null +++ b/docs/design/structure.md @@ -0,0 +1,361 @@ +--- +name: "Structure" +description: "Repository and module directory structure" +depth: 1 +links: ["../index.md", "../modules/modules-and-spec.md"] +--- + +```text +correx/ +│ +├── apps/ # runnable entrypoints +│ ├── cli/ # clikt-based cli +│ ├── server/ # ktor api + websocket server +│ ├── worker/ # future distributed executor +│ └── desktop/ # optional later +│ +├── core/ # PURE DOMAIN + ORCHESTRATION +│ │ +│ ├── kernel/ # orchestration brain +│ │ ├── SessionOrchestrator.kt +│ │ ├── WorkflowCoordinator.kt +│ │ ├── ExecutionScheduler.kt +│ │ └── RuntimeSupervisor.kt +│ │ +│ ├── events/ # event sourcing primitives +│ │ ├── model/ +│ │ ├── store/ +│ │ ├── append/ +│ │ ├── replay/ +│ │ ├── snapshot/ +│ │ └── projection/ +│ │ +│ ├── transitions/ # workflow graph engine +│ │ ├── engine/ +│ │ ├── dsl/ +│ │ ├── parser/ +│ │ ├── validator/ +│ │ ├── graph/ +│ │ └── conditions/ +│ │ +│ ├── context/ # context synthesis +│ │ ├── layers/ +│ │ ├── ranking/ +│ │ ├── compression/ +│ │ ├── budgeting/ +│ │ ├── summarization/ +│ │ ├── dedup/ +│ │ └── builders/ +│ │ +│ ├── inference/ # model abstraction +│ │ ├── contracts/ +│ │ ├── routing/ +│ │ ├── scheduling/ +│ │ ├── lifecycle/ +│ │ ├── capabilities/ +│ │ └── isolation/ +│ │ +│ ├── stages/ # stage runtime +│ │ ├── runtime/ +│ │ ├── execution/ +│ │ ├── registry/ +│ │ └── resolution/ +│ │ +│ ├── agents/ # ephemeral execution wrappers +│ │ ├── runtime/ +│ │ ├── spawning/ +│ │ ├── contracts/ +│ │ └── teardown/ +│ │ +│ ├── artifacts/ # structured outputs +│ │ ├── model/ +│ │ ├── schemas/ +│ │ ├── lineage/ +│ │ ├── validation/ +│ │ └── serialization/ +│ │ +│ ├── validation/ # layered validation +│ │ ├── routing/ +│ │ ├── schema/ +│ │ ├── semantic/ +│ │ ├── policy/ +│ │ ├── approvals/ +│ │ └── pipeline/ +│ │ +│ ├── approvals/ # approval engine +│ │ ├── tiers/ +│ │ ├── policies/ +│ │ ├── escalation/ +│ │ ├── steering/ +│ │ └── decisions/ +│ │ +│ ├── tools/ # tool orchestration +│ │ ├── contracts/ +│ │ ├── runtime/ +│ │ ├── receipts/ +│ │ ├── sandbox/ +│ │ └── registry/ +│ │ +│ ├── router/ # conversational facade +│ │ ├── memory/ +│ │ ├── interpretation/ +│ │ ├── summarization/ +│ │ └── steering/ +│ │ +│ ├── sessions/ # lifecycle + fsm +│ │ ├── lifecycle/ +│ │ ├── state/ +│ │ ├── projections/ +│ │ └── recovery/ +│ │ +│ ├── policies/ # separate policy engine +│ │ ├── evaluation/ +│ │ ├── enforcement/ +│ │ ├── filesystem/ +│ │ ├── network/ +│ │ └── execution/ +│ │ +│ ├── observability/ +│ │ ├── tracing/ +│ │ ├── metrics/ +│ │ ├── diagnostics/ +│ │ ├── event_inspection/ +│ │ └── replay_debugging/ +│ │ +│ └── config/ +│ ├── loading/ +│ ├── validation/ +│ ├── migrations/ +│ ├── defaults/ +│ └── schemas/ +│ +├── infrastructure/ # IO + implementations +│ │ +│ ├── persistence/ +│ │ ├── sqlite/ +│ │ ├── postgres/ +│ │ ├── snapshots/ +│ │ └── migrations/ +│ │ +│ ├── inference/ +│ │ ├── llama_cpp/ +│ │ ├── ollama/ +│ │ ├── vllm/ +│ │ ├── openai_compatible/ +│ │ └── mock/ +│ │ +│ ├── tools/ +│ │ ├── shell/ +│ │ ├── git/ +│ │ ├── filesystem/ +│ │ ├── docker/ +│ │ ├── network/ +│ │ └── sandboxing/ +│ │ +│ ├── security/ +│ │ ├── secrets/ +│ │ ├── isolation/ +│ │ ├── allowlists/ +│ │ └── permissions/ +│ │ +│ ├── scheduler/ +│ │ ├── queues/ +│ │ ├── concurrency/ +│ │ ├── throttling/ +│ │ └── backpressure/ +│ │ +│ └── telemetry/ +│ ├── logging/ +│ ├── tracing/ +│ └── exporters/ +│ +├── interfaces/ # transport + api contracts +│ │ +│ ├── api/ +│ │ ├── rest/ +│ │ ├── websocket/ +│ │ ├── dto/ +│ │ ├── mapping/ +│ │ └── auth/ +│ │ +│ ├── cli/ +│ │ ├── commands/ +│ │ ├── formatting/ +│ │ ├── interactive/ +│ │ └── progress/ +│ │ +│ └── sdk/ +│ ├── client/ +│ └── protocol/ +│ +├── plugins/ # extension ecosystem +│ │ +│ ├── tools/ +│ ├── validators/ +│ ├── compressors/ +│ ├── providers/ +│ ├── transitions/ +│ ├── stages/ +│ └── policies/ +│ +├── frontend/ # sveltekit ui +│ │ +│ ├── src/ +│ │ ├── routes/ +│ │ ├── lib/ +│ │ ├── components/ +│ │ ├── stores/ +│ │ ├── websocket/ +│ │ └── visualizations/ +│ │ +│ └── static/ +│ +├── testing/ +│ ├── replay/ +│ ├── integration/ +│ ├── fixtures/ +│ ├── projections/ +│ ├── transitions/ +│ ├── approvals/ +│ └── deterministic/ +│ +├── docs/ +│ ├── architecture/ +│ ├── events/ +│ ├── transitions/ +│ ├── plugins/ +│ ├── configs/ +│ └── threat_model/ +│ +└── examples/ + ├── workflows/ + ├── configs/ + ├── plugins/ + └── stages/ +``` + +architecturally: + +```text +ui/cli/api + ↓ +interfaces layer + ↓ +application/orchestration layer + ↓ +domain/core layer + ↓ +ports/contracts + ↓ +infrastructure layer +``` + +rules: + +* core NEVER imports infrastructure +* infrastructure implements ports/interfaces from core +* plugins only talk through contracts +* ui never touches persistence directly +* projections are rebuildable only from events +* tools never mutate state directly +* models never own memory/state +* router never owns execution state + +important internal split + +1. domain/core (pure deterministic logic) + +contains: + +* events +* transitions +* projections +* approvals +* policies +* artifact definitions +* session fsm + +must be: + +* testable without IO +* replayable +* deterministic + +2. application layer + +contains: + +* orchestration +* workflow execution +* context building +* retries +* scheduling +* coordination + +this is the “brain”. + +3. infrastructure layer + +contains: + +* sqlite +* llama.cpp +* shell +* websocket +* filesystem +* network + +replaceable adapters only. + +4. interface layer + +contains: + +* cli +* web api +* websocket protocol +* sdk + +thin wrappers only. + +most important subsystem boundaries + +event system +source of truth. + +projection system +derived/read models only. + +transition engine +pure deterministic graph executor. + +context processor +stateless synthesizer. + +validation pipeline +hard gatekeeper. + +approval engine +risk boundary. + +tool runtime +isolated side effects. + +model manager +resource scheduler. + +router +human-facing facade only. + +if implemented correctly, you should eventually be able to: + +* replay entire sessions deterministically +* swap model providers without touching orchestration +* rebuild every projection from events +* run headless without UI +* replace frontend entirely +* distribute workers later +* test most logic without inference +* fuzz transitions/approvals safely + +that’s usually the sign the boundaries are correct. diff --git a/docs/epics/epic-1-resolution.md b/docs/epics/epic-1-resolution.md new file mode 100644 index 00000000..f5d90e90 --- /dev/null +++ b/docs/epics/epic-1-resolution.md @@ -0,0 +1,249 @@ +# Epic 1 — Event System (Append-Only Event Backbone) + +## completed deliverables + +### 1. event identity and metadata model + +implemented a strict identity and causality model for all events in the system. + +final structures: + +* `EventId` +* `SessionId` +* `CorrelationId` +* `CausationId` +* `EventMetadata` + +key properties: + +* immutable identifiers +* replay-safe metadata +* explicit causality tracking (no implicit execution order semantics outside sequence) + +metadata is strictly separated from payload. + +--- + +### 2. event payload model (domain-agnostic core) + +introduced polymorphic event payload system as the root domain abstraction. + +final structure: + +* `EventPayload` (sealed polymorphic interface) +* domain events: + + * `ToolInvokedEvent` + * `ApprovalGrantedEvent` + * `SessionStartedEvent` + * `SessionPausedEvent` + * `SessionResumedEvent` + * `SessionCompletedEvent` + * `SessionFailedEvent` + +properties: + +* domain-extensible +* serialization-safe via kotlinx.serialization module +* no infrastructure coupling +* no persistence awareness + +--- + +### 3. event envelope model + +implemented a strict separation between event metadata and payload. + +final structure: + +```text id="e1" +StoredEvent + ├── metadata (identity + causality + timestamp) + ├── sequence (per-session ordering) + └── payload (domain event) +``` + +envelope guarantees: + +* complete event immutability +* replay-safe structure +* strict ordering contract support +* backend-agnostic representation + +--- + +### 4. event store abstraction (core contract) + +introduced append-only event store contract as system backbone. + +interface guarantees: + +* append-only semantics +* deterministic ordering per session +* idempotent writes via `eventId` +* read consistency guarantees +* replay-safe retrieval model + +core operations: + +```text id="e2" +append(event) +appendAll(events) +read(sessionId) +readFrom(sessionId, sequence) +lastSequence(sessionId) +``` + +store is the **single source of truth** in the system. + +--- + +### 5. in-memory event store (reference implementation) + +implemented deterministic in-memory store for contract validation and testing. + +properties: + +* thread-safe append operations +* per-session sequencing via atomic counters +* duplicate event protection (eventId-based idempotency) +* deterministic read ordering guarantees + +used as baseline correctness oracle for all other stores. + +--- + +### 6. persistence alignment (sqlite implementation foundation) + +introduced SQLite-based event store as infrastructure implementation. + +responsibilities: + +* persistent event storage +* enforcement of append-only constraints at DB level +* deterministic reconstruction of event streams +* compatibility with core event envelope model + +ensures parity with in-memory reference implementation via contract tests. + +--- + +### 7. serialization system + +implemented deterministic serialization layer for event persistence. + +components: + +* `JsonEventSerializer` +* `SerializersModule` with polymorphic `EventPayload` +* `eventJson` configuration instance + +guarantees: + +* stable cross-run serialization +* replay-safe encoding/decoding +* polymorphic payload correctness +* strict schema versioning support + +serialization is treated as **infrastructure concern only**, not domain logic. + +--- + +### 8. contract-based enforcement system + +introduced contract test framework to enforce event system invariants across implementations. + +enforced invariants: + +* append-only behavior +* idempotency via eventId +* strict per-session ordering +* deterministic read output +* cross-implementation parity (in-memory vs sqlite) + +contract layer ensures: + +> correctness is enforced structurally, not assumed per implementation + +--- + +### 9. concurrency and ordering guarantees + +validated event store behavior under concurrent access conditions. + +guarantees established: + +* safe concurrent appends per session +* deterministic sequence assignment +* protection against race-condition-induced ordering violations +* linearized per-session event streams + +ensures event log integrity under multi-threaded execution. + +--- + +### 10. replay readiness foundation (implicit but critical outcome) + +Epic 1 established the foundational requirement for all higher systems: + +> any state must be reconstructable from the event stream alone + +achieved via: + +* strict envelope model +* deterministic ordering guarantees +* immutable event storage +* idempotent append semantics + +this directly enables Epic 2 projection and replay system. + +--- + +# final architecture after Epic 1 + +```text id="e3" +EventStore (append-only, ordered, idempotent) + ↓ +StoredEvent (metadata + sequence + payload) + ↓ +Polymorphic EventPayload system + ↓ +Serialization layer (kotlinx.serialization) +``` + +--- + +# major architectural outcomes + +Epic 1 established: + +* append-only event-sourced backbone +* strict identity + causality model +* polymorphic domain event system +* deterministic persistence contract +* replay-safe serialization layer +* cross-backend contract enforcement +* concurrency-safe event ordering + +--- + +# what Epic 1 intentionally does NOT include + +not implemented: + +* projections / state reconstruction +* FSM / session lifecycle logic +* workflow execution engine +* transition system +* kernel orchestration +* runtime execution model + +those are explicitly deferred to Epic 2+ + +--- + +# final state + +Correx now has: + +> a deterministic, append-only event backbone with strict identity, causality, and ordering guarantees, fully replay-ready and validated through cross-implementation contract tests, serving as the foundational truth layer for all higher-level system behavior. diff --git a/docs/epics/epic-1.5-resolution.md b/docs/epics/epic-1.5-resolution.md new file mode 100644 index 00000000..6e28ca09 --- /dev/null +++ b/docs/epics/epic-1.5-resolution.md @@ -0,0 +1,200 @@ +# Epic 1.5 — Store Invariants, Replay Contracts & Projection Enforcement Layer + +## completed deliverables + +### 1. event store invariants formalization + +extended and hardened the EventStore correctness model beyond basic append/read semantics. + +formalized invariants: + +* append-only enforcement as strict storage rule +* idempotency via `eventId` uniqueness +* monotonic per-session sequencing +* total ordering guarantee per session stream +* deterministic read consistency across implementations + +this clarified EventStore as a **strict correctness boundary**, not just a persistence abstraction. + +--- + +### 2. replay contract system (cross-store determinism) + +introduced a unified contract layer for verifying replay correctness across all EventStore implementations. + +contract guarantees: + +* identical event streams MUST produce identical replay outputs +* ordering must be preserved independent of storage backend +* partial replay (cursor-based) must remain deterministic +* replay must be stateless and side-effect free + +covered implementations: + +* in-memory event store +* sqlite event store +* future persistence adapters + +--- + +### 3. projection contract enforcement layer + +defined formal constraints for projection systems to ensure deterministic state reconstruction. + +projection invariants: + +* projection MUST be a pure function: `EventStream → State` +* projections MUST NOT retain or mutate internal state +* projections MUST NOT depend on external systems +* identical inputs MUST produce identical outputs + +introduced projection-level contract tests to enforce: + +* determinism across repeated builds +* correct handling of empty streams +* correct ordering sensitivity +* stable state reconstruction semantics + +--- + +### 4. test fixtures & reusable contract infrastructure + +introduced shared testing primitives to enforce consistency across modules. + +components: + +* deterministic `stored()` / `event()` builders +* reusable EventStore contract test suite +* reusable replay contract test suite +* projection contract test base class + +ensures: + +> correctness rules are defined once and enforced everywhere + +--- + +### 5. concurrency correctness validation layer + +formalized and tested concurrency guarantees for EventStore implementations. + +validated properties: + +* safe concurrent appends +* absence of race-condition-based sequence corruption +* uniqueness enforcement under parallel writes +* deterministic final stream state under concurrent load + +introduced stress-style test utilities for reproducible concurrency validation. + +--- + +### 6. projection infrastructure alignment + +aligned projection system with replay engine from Epic 2 while maintaining strict separation of concerns. + +ensured: + +* projections depend only on event stream abstraction +* projections remain implementation-agnostic +* replay engine is the only execution driver +* no direct store coupling inside projection logic + +this created a clean boundary: + +```text id="p1" +EventStore → EventReplayer → Projection → State +``` + +--- + +### 7. event system boundary tightening + +refined event system usage rules across all layers: + +* event payload remains the only domain extension point +* metadata is strictly non-semantic +* sequence is the only ordering primitive +* causality is informational only (not execution-driving) + +ensured event system remains the **single authoritative truth layer** without semantic leakage. + +--- + +### 8. architectural separation enforcement + +formalized module separation rules: + +* `core/events` → source of truth + replay primitives +* `core/events/projections` → deterministic state builders +* `infrastructure/persistence` → storage implementations only +* `testing/contracts` → cross-module correctness enforcement + +enforced rule: + +> no module may assume correctness of another without contract validation + +--- + +### 9. replay correctness guarantee foundation + +strengthened replay guarantees introduced in Epic 2: + +* deterministic replay across time and environment +* backend-independent state reconstruction +* full reproducibility of session state from event stream +* elimination of hidden state assumptions in projection lifecycle + +Epic 1.5 made replay correctness **explicitly testable and enforced**, not implicit. + +--- + +# final architecture after Epic 1.5 + +```text id="epic15" +EventStore (contract-enforced) + ↓ +StoredEvent stream (ordered, idempotent) + ↓ +EventReplayer (deterministic engine) + ↓ +Projection contracts (pure functions) + ↓ +State (reconstructed, disposable) +``` + +--- + +# major architectural outcomes + +Epic 1.5 established: + +* formal correctness contracts for EventStore and replay systems +* deterministic projection enforcement layer +* reusable cross-module test contract infrastructure +* concurrency-safe event persistence validation +* strict replay determinism guarantees across implementations +* hardened separation between storage, replay, and projection layers + +--- + +# what Epic 1.5 intentionally does NOT include + +not implemented: + +* session lifecycle FSM (Epic 2) +* transition engine execution semantics (Epic 3) +* workflow orchestration +* runtime kernel +* tool execution system +* policy/approval integration + +those systems are explicitly higher-level and depend on this layer being stable. + +--- + +# final state + +Correx now has: + +> a formally contract-enforced event sourcing foundation with deterministic replay, validated projection semantics, and strict cross-implementation guarantees ensuring that all state reconstruction logic is reproducible, testable, and backend-independent. diff --git a/docs/epics/epic-1.5.md b/docs/epics/epic-1.5.md new file mode 100644 index 00000000..84979bf0 --- /dev/null +++ b/docs/epics/epic-1.5.md @@ -0,0 +1,178 @@ +# Epic 1.5: Event Store Hardening (Concurrency, Integrity, and Replay Safety) + +**status:** proposed +**date:** 08.05.2026 (May) +**scope:** `:core:events`, `:infrastructure:persistence` + +--- + +## context + +Epic 1 establishes a functional event-sourced storage system (in-memory + SQLite) with correct basic semantics: append, read, ordering, and idempotency. + +However, current implementation assumes ideal conditions: + +* single-threaded or externally synchronized writes +* stable event schema +* no replay pressure under large histories +* no concurrent access patterns + +These assumptions do not hold once the system is integrated into orchestration (`:core:kernel`) and async execution (`coroutines`, CLI + server). + +Epic 1.5 introduces **hard guarantees required for real execution environments**, without changing the external EventStore contract. + +--- + +## goal + +Make the event store: + +> deterministic, concurrency-safe, replay-stable, and schema-resilient under real execution conditions + +--- + +## scope (what IS included) + +### 1. concurrency model definition + +Define and enforce: + +* single-writer guarantee per EventStore instance +* behavior under concurrent `append` / `appendAll` +* read consistency rules during writes + +**deliverable:** + +* documented concurrency contract in `EventStore` + +--- + +### 2. SQLite transactional correctness + +Ensure SQLite implementation guarantees: + +* atomic `appendAll` (single transaction boundary) +* no partial writes on failure +* deterministic ordering under concurrent calls (within single instance constraints) + +**work:** + +* explicit transaction wrapping +* `BEGIN/COMMIT/ROLLBACK` safety handling +* removal of implicit autocommit ambiguity + +--- + +### 3. sequence integrity under concurrency + +Guarantee: + +* per-session sequence monotonicity +* no race-condition-based sequence duplication +* consistent `lastSequence()` computation under concurrent writes + +**possible approaches:** + +* synchronized write lock per session +* or single global write lock (simpler, acceptable for v1) + +--- + +### 4. contract test hardening (EventStoreContractTest upgrade) + +Expand contract tests to include: + +* concurrent append simulation +* appendAll atomicity verification +* read consistency during write bursts +* deterministic replay under repeated execution + +--- + +### 5. snapshot preparation layer (interface only) + +Introduce: + +* `SnapshotStore` interface (no full implementation required yet) +* hook points in EventStore for snapshot triggers + +This is structural preparation only. + +--- + +### 6. event schema version discipline + +Enforce: + +* `version` field is mandatory and meaningful +* serialization must be version-aware +* unknown version behavior defined (fail fast initially) + +No migration logic yet, only rules. + +--- + +### 7. replay determinism guarantee + +Formalize: + +> replay of the same event stream must produce identical projections regardless of store implementation + +This becomes a top-level invariant. + +--- + +## explicit exclusions (important) + +Epic 1.5 does NOT include: + +* sessions / FSM logic +* transitions / graph engine +* approval system evolution +* context processing +* inference orchestration +* distributed storage (Kafka, Redis, etc.) + +--- + +## consequences + +### positive + +* removes race-condition ambiguity in kernel integration +* guarantees EventStore correctness under async execution +* makes replay safe for debugging and dataset generation +* stabilizes foundation for all higher-level epics + +### negative + +* introduces stricter constraints on store implementations +* adds concurrency complexity to SQLite layer +* increases test surface area significantly +* forces early clarity on threading model (good, but non-trivial) + +--- + +## rationale + +Without this layer, higher-level systems will assume inconsistent event semantics depending on: + +* execution timing +* store implementation +* concurrency patterns in kernel + +This breaks the core promise of Correx: + +> deterministic replayable execution over nondeterministic inference systems + +Epic 1.5 ensures the event layer is not just correct, but **operationally invariant**. + +--- + +## status + +Recommended immediately after Epic 1 completion and before introduction of: + +* `:core:sessions` +* `:core:kernel` +* `:core:transitions` diff --git a/docs/epics/epic-1.md b/docs/epics/epic-1.md new file mode 100644 index 00000000..17078e2d --- /dev/null +++ b/docs/epics/epic-1.md @@ -0,0 +1,193 @@ +# Epic 1: Event System Core (Append-only Log Foundation) + +**status:** accepted +**date:** 07.05.2026 (May) +**scope:** `:core:events` + +--- + +## context + +Correx is built on deterministic replay of nondeterministic execution. The only reliable source of truth is a structured event log. + +To support: + +* reproducible session execution +* crash recovery +* offline debugging +* synthetic data generation + +the system requires a strict event sourcing foundation. + +This epic establishes the minimal, correct event system abstraction before any orchestration logic exists. + +--- + +## goal + +Define and implement a **fully functional event sourcing core** that provides: + +> an append-only, ordered, immutable event log with deterministic replay capability + +--- + +## scope (what IS included) + +### 1. event model definition + +Define the canonical event structure: + +* `EventEnvelope` + + * `eventId` + * `sessionId` + * `sequence` + * `timestamp` + * `version` + * `causationId` + * `correlationId` + * `payload` + +### 2. payload abstraction + +Define `EventPayload` interface for polymorphic event content. + +Implement concrete event types (initial minimal set): + +* tool invocation event +* approval event (minimal placeholder) +* session lifecycle event (minimal placeholder) + +No domain expansion beyond structural needs. + +--- + +### 3. serialization system + +Implement deterministic serialization using `kotlinx.serialization`: + +* polymorphic module for `EventPayload` +* stable JSON encoding (`Json`) +* version field included for forward compatibility + +Guarantee: + +> identical event → identical serialized form + +--- + +### 4. EventStore interface + +Define core contract: + +* `append(event)` +* `appendAll(events)` +* `read(sessionId): List` +* `lastSequence(sessionId): Long` + +Rules: + +* append-only semantics +* no mutation or update operations +* ordering guaranteed per session + +--- + +### 5. in-memory EventStore implementation + +Provide reference implementation: + +* deterministic ordering per session +* idempotency via eventId deduplication +* sequence generation per session +* strict ordering enforcement + +Used for: + +* contract testing +* fast execution +* deterministic simulation + +--- + +### 6. SQLite EventStore implementation (v1 baseline) + +Provide persistence-backed implementation: + +* append events into SQLite table +* enforce uniqueness via `event_id` +* store sequence per session +* support ordered reads via SQL query + +No concurrency guarantees in Epic 1 (deferred to Epic 1.5). + +--- + +### 7. contract test suite (EventStoreContractTest) + +Define shared behavior validation: + +* ordering is preserved +* idempotency rules are consistent +* read returns correct sequence +* appendAll preserves ordering semantics +* both implementations behave identically + +This becomes the **canonical correctness definition**. + +--- + +## explicit exclusions (important) + +Epic 1 does NOT include: + +* concurrency safety guarantees +* snapshotting +* transaction management hardening +* session lifecycle logic +* workflow transitions +* orchestration kernel +* distributed storage +* performance optimization +* schema migration system + +--- + +## consequences + +### positive + +* establishes single source of truth for all system state +* enables deterministic replay foundation +* allows testing higher-level logic via event streams +* decouples domain logic from persistence mechanics +* provides interchangeable storage backends + +### negative + +* naive SQLite implementation may not reflect production concurrency needs +* no snapshot support → replay cost increases over time +* schema evolution is manually managed initially +* requires strict discipline to avoid leaking mutable state concepts upward + +--- + +## rationale + +Event sourcing must be introduced as a **pure, minimal abstraction first**, without mixing: + +* orchestration logic +* concurrency concerns +* optimization layers + +This ensures that all higher-level systems depend on a stable, predictable contract rather than implementation behavior. + +--- + +## status + +Epic 1 is the **foundation layer of Correx architecture**. + +All subsequent epics (sessions, transitions, kernel, approvals, context) assume: + +> EventStore is correct, deterministic, and interchangeable across implementations. diff --git a/docs/epics/epic-10-resolution.md b/docs/epics/epic-10-resolution.md new file mode 100644 index 00000000..346e776f --- /dev/null +++ b/docs/epics/epic-10-resolution.md @@ -0,0 +1,277 @@ +# Epic 10 — Orchestration Kernel (Lifecycle, Retry, Replay) + +## completed deliverables + +### 1. orchestration state and status model + +implemented a strict state model for orchestration lifecycle tracking. + +final structures: + +* `OrchestrationStatus` (enum) +* `OrchestrationState` + +key properties: + +* immutable status transitions +* explicit lifecycle tracking (IDLE → RUNNING → PAUSED/COMPLETED/FAILED) +* retry count tracking +* pause reason tracking +* pending approval flag +* failure reason tracking + +status values: + +* IDLE — initial state, awaiting workflow start +* RUNNING — active stage execution +* PAUSED — awaiting approval or user action +* COMPLETED — successful workflow termination +* FAILED — workflow terminated due to error +* CANCELED — workflow terminated due to cancellation + +--- + +### 2. workflow event model + +introduced workflow lifecycle events as the orchestration backbone. + +final structure: + +* `WorkflowStartedEvent` — marks workflow initiation +* `WorkflowCompletedEvent` — marks successful completion +* `WorkflowFailedEvent` — marks failure with reason and retry context + +properties: + +* explicit lifecycle boundaries +* causality-safe event emission +* retry-exhausted tracking for failure events + +--- + +### 3. orchestration pause/resume events + +introduced explicit pause/resume semantics for approval workflows. + +final structure: + +* `OrchestrationPausedEvent` — marks pause with reason (APPROVAL_PENDING, USER_REQUESTED) +* `OrchestrationResumedEvent` — marks resumption after approval + +properties: + +* explicit pause reasons for auditability +* stage-aware pause tracking +* deterministic resume semantics + +--- + +### 4. inference state management (read-only projection) + +implemented inference state as a read-only projection for auditability. + +final structures: + +* `InferenceRecord` — immutable inference attempt snapshot +* `InferenceState` — collection of inference records +* `InferenceReducer` / `DefaultInferenceReducer` +* `InferenceProjector` +* `InferenceRepository` + +key properties: + +* append-only inference history +* per-request tracking (requestId, providerId, stageId) +* status tracking (started, completed, failed, timed out) +* token usage and latency recording +* failure reason persistence + +--- + +### 5. retry coordination system + +implemented deterministic retry logic with exponential backoff support. + +final structures: + +* `RetryPolicy` — configures maxAttempts and backoffMs +* `RetryCoordinator` / `DefaultRetryCoordinator` +* `RetryAttemptedEvent` — audit trail for retry attempts + +key properties: + +* attempt-based retry control +* configurable backoff delays +* event-emitted retry tracking +* failure reason preservation + +--- + +### 6. replay orchestrator (deterministic execution) + +implemented deterministic replay for testing and audit scenarios. + +final structures: + +* `ReplayOrchestrator` — concrete implementation of `SessionOrchestrator` +* `ReplayInferenceProvider` — artifact-based inference +* `ReplayArtifactMissingException` — replay failure signal +* `ReplayStrategy` — SkipInference, SkipValidation, Full + +key properties: + +* bypasses live inference, uses recorded artifacts +* deterministic output for testing +* strategy-configurable replay depth +* exception-based artifact missing detection + +--- + +### 7. session orchestrator abstraction + +implemented abstract base for orchestration implementations. + +final structures: + +* `SessionOrchestrator` (abstract) — base interface +* `DefaultSessionOrchestrator` — concrete implementation + +key properties: + +* unified stage execution model +* inference routing integration +* validation pipeline integration +* approval engine integration +* event emission abstraction +* cancellation support + +--- + +### 8. stage execution and outcome model + +implemented stage execution result types for orchestration decisions. + +final structure: + +* `StageOutcome` (sealed interface, renamed from StageExecutionResult) + * `Success` — stage completed with artifact + * `ValidationFailure` — validation failed, retryable flag + * `InferenceFailure` — inference failed, retryable flag + * `ApprovalRequired` — approval pending + * `Cancelled` — stage cancelled + +properties: + +* outcome-based decision making +* retryability metadata +* artifact capture for replay + +--- + +### 9. orchestration configuration + +introduced configurable orchestration parameters. + +final structure: + +* `OrchestrationConfig` + +key properties: + +* `retryPolicy` — RetryPolicy instance +* `replayStrategy` — ReplayStrategy instance +* `stageTimeoutMs` — per-stage timeout configuration + +--- + +### 10. serialization system (orchestration events) + +registered all orchestration events in the serialization module. + +components: + +* `Serialization.kt` in `:core:events` +* 6 new orchestration events registered: + * `WorkflowStartedEvent` + * `WorkflowCompletedEvent` + * `WorkflowFailedEvent` + * `OrchestrationPausedEvent` + * `OrchestrationResumedEvent` + * `RetryAttemptedEvent` + +* `InferenceStatus` — `TIMED_OUT` added + +--- + +### 11. testing fixtures (extended) + +extended test fixtures for comprehensive coverage. + +components: + +* `TransitionFixtures` +* `InferenceFixtures` +* `ContextFixtures` +* `ValidationFixtures` + +--- + +# final architecture after Epic 10 + +```text id="e10" +Orchestration Kernel +├── SessionOrchestrator (abstract base) +│ ├── DefaultSessionOrchestrator +│ └── ReplayOrchestrator +│ +├── State Management +│ ├── OrchestrationState +│ ├── InferenceState +│ └── Event Replayer +│ +├── Retry System +│ ├── RetryPolicy +│ ├── RetryCoordinator +│ └── DefaultRetryCoordinator +│ +└── Replay Support + ├── ReplayStrategy + ├── ReplayInferenceProvider + └── ReplayArtifactMissingException +``` + +--- + +# major architectural outcomes + +Epic 10 established: + +* orchestration lifecycle management +* deterministic replay capability +* retry coordination with audit trail +* inference state projection +* stage outcome model +* orchestrator abstraction layer +* pause/resume semantics + +--- + +# what Epic 10 intentionally does NOT include + +not implemented: + +* live inference providers (replay only) +* approval workflow execution (abstraction only) +* stage timeout enforcement (config only) +* context budget enforcement (TODO epic-11) +* risk model integration (TODO epic-11) + +those are explicitly deferred to Epic 11+ + +--- + +# final state + +Correx now has: + +> a complete orchestration kernel with lifecycle management, retry coordination, deterministic replay, and inference state projection, enabling both live execution and reproducible test scenarios. diff --git a/docs/epics/epic-11.md b/docs/epics/epic-11.md new file mode 100644 index 00000000..10750104 --- /dev/null +++ b/docs/epics/epic-11.md @@ -0,0 +1,685 @@ +# 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) \ No newline at end of file diff --git a/docs/epics/epic-12.md b/docs/epics/epic-12.md new file mode 100644 index 00000000..c5034098 --- /dev/null +++ b/docs/epics/epic-12.md @@ -0,0 +1,416 @@ +# 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` 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): 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` only. orchestrator hardcodes context budget, generation config, capabilities. + +**fix:** +```kotlin +data class StageConfig( + val requiredCapabilities: Set = emptySet(), + val tokenBudget: Int = 4096, + val generationConfig: GenerationConfig = GenerationConfig(), + val allowedTools: Set = emptySet(), + val maxRetries: Int = 3, +) + +data class WorkflowGraph( + val stages: Map, + val transitions: Set, + val start: StageId, +) { + val stageIds: Set 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` with `Map` +- `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, + 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, + val edges: SortedSet>, +) +``` + +**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` 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` — 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` + +--- + +## 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) \ No newline at end of file diff --git a/docs/epics/epic-2-resolution.md b/docs/epics/epic-2-resolution.md new file mode 100644 index 00000000..473a152b --- /dev/null +++ b/docs/epics/epic-2-resolution.md @@ -0,0 +1,280 @@ +# Epic 2 — Session Lifecycle (FSM + Projection Layer) + +## completed deliverables + +### 1. session state model (projection output) + +implemented a deterministic session state representation derived entirely from event streams. + +final structure: + +* `SessionState` (pure projection output) +* `SessionStatus` +* `SessionEvent` (FSM input domain) + +key characteristics: + +* immutable +* replay-safe +* derived only from events +* no identity leakage + +session identity explicitly removed from projection state. + +--- + +### 2. session identity boundary separation + +introduced explicit separation between identity and state. + +final model: + +* `Session` → identity wrapper +* `SessionState` → projection result + +structure: + +```text id="s1" +Session(sessionId) + ↓ +SessionState +``` + +this removed previous anti-pattern: + +* embedding sessionId inside projection state +* late-binding identity during replay fold + +--- + +### 3. session FSM (deterministic lifecycle rules) + +implemented deterministic finite state machine governing session lifecycle transitions. + +states: + +* `CREATED` +* `ACTIVE` +* `PAUSED` +* `COMPLETED` +* `FAILED` +* `REPLAYED` (diagnostic) + +properties: + +* deterministic transitions +* no side effects +* replay-safe behavior +* strictly event-driven evaluation + +FSM is pure function: + +```text id="s2" +(SessionStatus, SessionEvent) → SessionStatus +``` + +--- + +### 4. session event interpretation layer + +introduced mapping layer between persisted events and domain FSM events. + +components: + +* `SessionEventMapper` + +responsibilities: + +* convert `StoredEvent → SessionEvent` +* isolate domain semantics from persistence format + +properties: + +* no state mutation +* no replay logic +* stateless transformation only + +--- + +### 5. session projection engine + +implemented `SessionProjector` as domain-specific projection over generic replay infrastructure. + +responsibilities: + +* consumes ordered event stream +* applies FSM transitions +* produces deterministic session state +* tracks lifecycle metadata + +properties: + +* pure fold reducer +* stateless across executions +* deterministic replay behavior + +state evolution: + +```text id="s3" +StoredEvent → SessionEvent → FSM → SessionState +``` + +--- + +### 6. projection infrastructure alignment + +aligned session model with generic replay engine (`:core:events`). + +final integration: + +* `Projection` reused as abstraction boundary +* `EventReplayer` drives deterministic reconstruction +* `SessionProjector` plugs into generic replay system + +no duplication of replay logic inside sessions. + +--- + +### 7. session repository (identity boundary) + +introduced repository as thin orchestration facade over replay engine. + +final structure: + +```kotlin id="s4" +class DefaultSessionRepository( + private val replayer: EventReplayer +) { + + fun getSession(sessionId: String): Session = + Session( + sessionId = sessionId, + state = replayer.rebuild(sessionId) + ) +} +``` + +responsibilities: + +* binds session identity to replay result +* no projection logic +* no FSM logic +* no event interpretation + +--- + +### 8. replay integration + +session lifecycle fully integrated into event-sourced replay pipeline. + +final flow: + +```text id="s5" +EventStore + ↓ +EventReplayer + ↓ +SessionProjector + ↓ +SessionState + ↓ +Session (identity wrapper) +``` + +system guarantees: + +* full replay determinism +* identical event streams → identical session states +* ordering enforced by store layer + +--- + +### 9. test coverage completed + +implemented deterministic coverage across session lifecycle: + +* session reconstruction from event stream +* FSM transition correctness +* invalid transition detection +* replay determinism guarantees +* empty stream handling +* multi-event lifecycle correctness + +introduced fixtures: + +* `stored()` event builder for deterministic event creation + +aligned contract tests: + +* projection contract compliance +* replay contract validation +* event store contract enforcement + +--- + +### 10. architectural cleanup (refactor milestone) + +removed legacy session coupling patterns: + +* sessionId inside `SessionState` +* implicit state hydration logic +* repository-level replay duplication +* ambiguous projection initialization patterns + +removed or deprecated: + +* `SessionCounterProjection` as state-embedded counter logic (moved toward event-derived metrics approach) +* FSM leakage into repository layer + +--- + +# final architecture after Epic 2 + +```text id="s6" +EventStore + ↓ +EventReplayer + ↓ +SessionProjector + ↓ +SessionState + ↓ +Session (identity wrapper) +``` + +--- + +# major architectural outcomes + +Epic 2 established: + +* strict separation between identity and state +* deterministic FSM-driven session lifecycle +* event-sourced state reconstruction model +* reusable generic replay engine (`:core:events`) +* domain-specific projection layer (`:core:sessions`) +* repository as pure boundary adapter +* fully replay-safe session lifecycle semantics + +--- + +# what Epic 2 intentionally does NOT include + +not implemented: + +* workflow graph execution +* transition DSL +* orchestration runtime +* stage execution engine +* scheduling or async coordination +* tool execution or kernel integration + +those are explicitly deferred to Epic 3+ + +--- + +# final state + +Correx now has: + +> a deterministic, event-sourced session lifecycle system built on a generic replay engine, with strict separation between identity, state, and event interpretation, fully replay-safe and FSM-driven. diff --git a/docs/epics/epic-2.md b/docs/epics/epic-2.md new file mode 100644 index 00000000..0d4e385a --- /dev/null +++ b/docs/epics/epic-2.md @@ -0,0 +1,203 @@ +# Epic 2: Session Lifecycle (Finite State Machine + Projection Layer) + +**status:** proposed +**date:** 08.05.2026 (May) +**scope:** `:core:sessions` (depends on `:core:events`) + +--- + +## context + +With a stable event log (Epic 1 complete), Correx now needs a structured runtime abstraction that turns raw events into meaningful execution units. + +A session is the primary unit of work in the system: + +* it represents a single interactive or automated workflow +* it evolves over time through events +* it must be reconstructible from the event log (replayable state) + +Without a session layer, events remain unstructured history with no execution semantics. + +--- + +## goal + +Introduce a **deterministic session lifecycle system** built on top of events: + +> a finite state machine + projection model that reconstructs session state from the event stream + +--- + +## scope (what IS included) + +### 1. session model + +Define `Session` as a projection, not stored state: + +* `sessionId` +* `status` +* `createdAt` +* `updatedAt` +* derived metadata (optional: stage, counters, flags) + +Session state is **derived from events only**, never mutated directly. + +--- + +### 2. session states (FSM) + +Define explicit lifecycle states: + +* `CREATED` +* `ACTIVE` +* `PAUSED` +* `COMPLETED` +* `FAILED` +* `REPLAYED` (optional diagnostic state) + +Transitions are strictly event-driven. + +--- + +### 3. session events (from Epic 1) + +Introduce interpretation layer over existing events: + +* `SessionStarted` +* `SessionPaused` +* `SessionResumed` +* `SessionCompleted` +* `SessionFailed` + +These are stored in `:core:events`, not duplicated. + +--- + +### 4. session projection builder + +Implement: + +> `SessionProjector` + +Responsibilities: + +* consumes ordered `EventEnvelope` stream +* rebuilds current session state +* applies deterministic reduction logic + +Rules: + +* pure function behavior (events → state) +* no side effects +* replay-safe + +--- + +### 5. transition rules (FSM logic) + +Define strict rules: + +* invalid transitions must be rejected during projection validation +* transitions are derived from event sequences, not external mutation +* last valid state wins (replay consistency rule) + +Example: + +* `CREATED → ACTIVE` allowed +* `ACTIVE → COMPLETED` allowed +* `COMPLETED → ACTIVE` invalid (ignored or flagged) + +--- + +### 6. session repository + +Provide abstraction: + +* `getSession(sessionId): Session` +* `getAllSessions()` +* `rebuild(sessionId)` (replay from events) + +Backed entirely by `EventStore`. + +--- + +### 7. minimal runtime behavior (no orchestration yet) + +Session layer only: + +* no kernel execution +* no tool execution +* no transitions engine +* no approvals + +It is purely **state interpretation over events** + +--- + +### 8. test coverage + +* session reconstruction from event stream +* invalid transition detection +* replay determinism (same events → same session state) +* multi-event lifecycle correctness +* empty session handling + +--- + +## explicit exclusions + +Epic 2 does NOT include: + +* workflow graph / transitions engine (`:core:transitions`) +* approval system (`:core:approvals`) +* tool execution (`:core:tools`) +* inference or routing +* context compression +* orchestration kernel + +--- + +## consequences + +### positive + +* introduces first meaningful domain abstraction over events +* enables reasoning about execution lifecycle +* provides foundation for orchestration layer +* makes replay human-interpretable (not just raw logs) +* isolates state interpretation logic from persistence + +### negative + +* introduces FSM complexity early in system lifecycle +* requires strict discipline to avoid state mutation leaks +* projection correctness becomes critical dependency for all higher layers +* may require refactoring if event types evolve significantly + +--- + +## rationale + +Events alone are insufficient for system reasoning. + +A session layer is required to: + +> translate raw event history into structured execution semantics + +This is the first step from: + +* “log of facts” + to +* “interpretable system state” + +--- + +## status + +Epic 2 becomes valid only after: + +* Epic 1 is complete ✔ +* EventStore contract is stable ✔ +* replay guarantees are verified ✔ + +It is the **first domain layer built on top of the event system**, and the first step toward orchestration. diff --git a/docs/epics/epic-3-resolution.md b/docs/epics/epic-3-resolution.md new file mode 100644 index 00000000..4431fb52 --- /dev/null +++ b/docs/epics/epic-3-resolution.md @@ -0,0 +1,256 @@ +# Epic 3 — Transition Engine + +## completed deliverables + +### 1. workflow graph model + +implemented deterministic workflow topology representation. + +core structures: + +* `WorkflowGraph` +* `TransitionEdge` +* `StageId` +* `TransitionId` +* `TransitionCondition` +* `EvaluationContext` + +graph characteristics: + +* immutable +* deterministic +* replay-safe +* runtime-agnostic + +--- + +## 2. deterministic transition resolution + +implemented pure transition evaluation engine. + +properties: + +* evaluates only outgoing edges from current stage +* deterministic ordering +* sequential evaluation only +* first matching transition wins +* stable replay behavior + +core components: + +* `DefaultTransitionResolver` +* `TransitionConditionEvaluator` +* `TransitionDecision` + +--- + +## 3. cycle analysis infrastructure + +implemented read-only graph cycle analysis. + +implemented: + +* deterministic adjacency construction +* DFS-based traversal +* cycle extraction +* cycle canonicalization +* duplicate elimination + +important: + +* analysis only +* no runtime execution semantics yet +* no scheduler/orchestrator coupling + +architectural rule: + +* cycles are structurally allowed +* behavior constraints belong to future policy/runtime layers + +--- + +## 4. stage execution contract + +defined strict execution boundary between: + +* transition engine +* runtime execution + +implemented concepts: + +* stage execution request +* stage execution result +* deterministic execution semantics +* execution/event separation + +important: + +* transitions engine does NOT execute runtime logic +* transitions engine does NOT orchestrate scheduling + +--- + +## 5. transition event model + +added workflow execution events into event system. + +implemented events: + +* `StageStartedEvent` +* `StageCompletedEvent` +* `StageFailedEvent` +* `TransitionExecutedEvent` + +properties: + +* serializable +* append-only compatible +* replay-safe +* session-scoped + +--- + +## 6. event-native session integration + +reworked session architecture from: + +* FSM + lossy mapping + +into: + +* deterministic event reduction + +removed conceptual dependency on: + +* collapsed transition semantics +* imperative FSM mutation + +introduced: + +* `SessionReducer` +* `DefaultSessionReducer` + +session state now evolves via: + +```text id="e1" +StoredEvent → SessionReducer → SessionState +``` + +instead of: + +```text id="e2" +StoredEvent → Mapper → FSM → State +``` + +--- + +## 7. projection architecture alignment + +aligned sessions with true event-sourcing semantics. + +final rules: + +* event stream is single source of truth +* projections are pure deterministic folds +* no hidden mutable state +* no runtime side effects +* replay reconstructs full state deterministically + +--- + +## 8. replay integration + +integrated transition execution into replay pipeline. + +final replay flow: + +```text id="e3" +Workflow execution +→ transition events +→ EventStore +→ replay +→ SessionProjector +→ SessionState +``` + +transition execution now becomes part of durable system history. + +--- + +## 9. testing completed + +implemented coverage for: + +* reducer semantics +* deterministic replay +* projector lifecycle behavior +* transition resolution +* transition event serialization +* replay integration + +removed obsolete tests: + +* `SessionFsm` +* `SessionEventMapper` +* mapper-driven FSM semantics + +--- + +# final architecture after Epic 3 + +```text id="e4" +WorkflowGraph + ↓ +TransitionResolver + ↓ +Stage Execution + ↓ +Transition Events + ↓ +Event Store + ↓ +Replay / Projection + ↓ +SessionState +``` + +--- + +# major architectural outcomes + +Epic 3 established: + +* deterministic workflow execution +* replay-safe orchestration semantics +* event-native state derivation +* strict separation of: + + * execution + * transitions + * persistence + * projections + * runtime orchestration + +--- + +# what Epic 3 intentionally does NOT include + +not implemented yet: + +* retry policies +* bounded runtime loop enforcement +* approval gating +* tool orchestration policies +* scheduling +* runtime budgeting +* adaptive routing logic + +those belong to future epics. + +--- + +# final state + +Correx now has: + +> a deterministic, event-sourced workflow transition engine with replay-consistent session projection and no hidden state mutation. diff --git a/docs/epics/epic-3.md b/docs/epics/epic-3.md new file mode 100644 index 00000000..ca7e53c8 --- /dev/null +++ b/docs/epics/epic-3.md @@ -0,0 +1,297 @@ +# Epic 3: Transition Engine (Deterministic Workflow Graph + Event-Driven Execution) + +**status:** proposed +**date:** 09.05.2026 (May) +**scope:** `:core:transitions` (depends on `:core:events`, `:core:sessions`) + +--- + +## context + +With deterministic replay and session projection already implemented (Epic 2 complete), Correx now needs a workflow execution model capable of describing and evaluating structured agent pipelines. + +A session lifecycle alone is insufficient for orchestration semantics. + +The system now requires: + +* workflow topology +* transition rules +* deterministic execution flow +* replay-safe workflow progression + +Without a transition engine: + +* sessions cannot evolve through structured workflows +* stage progression remains implicit +* orchestration semantics cannot be reconstructed from history + +--- + +## goal + +Introduce a **deterministic workflow transition engine**: + +> a pure graph evaluation system that resolves workflow transitions and emits replay-safe execution events + +The transition engine must remain: + +* deterministic +* stateless +* replay-safe +* runtime-agnostic + +It is NOT a scheduler or orchestration kernel. + +--- + +## scope (what IS included) + +### 1. workflow graph model + +Define immutable workflow topology structures: + +* `WorkflowGraph` +* `TransitionEdge` +* `StageId` +* `TransitionId` + +Graph structure: + +```kotlin +data class WorkflowGraph( + val stages: Set, + val transitions: Set, + val start: StageId +) +``` + +Properties: + +* immutable +* deterministic +* runtime-independent +* replay-safe + +--- + +### 2. deterministic transition evaluation + +Implement deterministic transition resolution. + +Core concepts: + +* `TransitionCondition` +* `EvaluationContext` +* `TransitionConditionEvaluator` +* `TransitionDecision` +* `DefaultTransitionResolver` + +Rules: + +* evaluate only outgoing transitions from current stage +* deterministic ordering only +* sequential evaluation only +* first matching transition wins +* no implicit randomness or parallelism + +Transition evaluation must behave identically under replay. + +--- + +### 3. graph analysis and cycle detection + +Implement read-only graph validation and analysis. + +Includes: + +* deterministic adjacency construction +* DFS-based traversal +* cycle extraction +* cycle canonicalization +* duplicate cycle elimination +* unreachable node detection + +Important: + +* cycles are structurally allowed +* cycle analysis is informational in Epic 3 +* runtime loop policies are NOT part of this epic + +Cycle handling remains deterministic and replay-safe. + +--- + +### 4. stage execution contract + +Define strict execution boundary between transition evaluation and runtime execution. + +Introduce: + +* stage execution request model +* stage execution result model +* deterministic execution semantics + +Important: + +* transition engine does NOT execute runtime logic +* transition engine does NOT schedule work +* transition engine does NOT manage concurrency + +It only evaluates graph progression. + +--- + +### 5. transition execution events + +Introduce workflow execution events into the event system. + +Implemented events: + +* `StageStartedEvent` +* `StageCompletedEvent` +* `StageFailedEvent` +* `TransitionExecutedEvent` + +Properties: + +* append-only compatible +* serializable +* replay-safe +* deterministic + +Workflow progression becomes part of durable event history. + +--- + +### 6. event-native session integration + +Integrate workflow execution into session state reconstruction. + +Introduce: + +* `SessionReducer` +* `DefaultSessionReducer` + +Session state now evolves via deterministic event reduction: + +```text +StoredEvent → SessionReducer → SessionState +``` + +instead of: + +```text +StoredEvent → Mapper → FSM → State +``` + +Rules: + +* transition engine never mutates session state directly +* events are the single source of truth +* projections derive state from full semantic event stream +* no lossy event collapsing is allowed + +--- + +### 7. replay-safe projection architecture + +Align projections with true event-sourcing semantics. + +Projection rules: + +* projections are pure deterministic folds +* no side effects +* no runtime clocks +* no mutable hidden state +* replay must reconstruct identical state from identical streams + +Transition execution history becomes replayable system truth. + +--- + +### 8. deterministic testing coverage + +Implement coverage for: + +* transition resolution determinism +* graph analysis correctness +* cycle extraction stability +* transition event serialization +* replay determinism +* reducer semantics +* session reconstruction from workflow events + +End-to-end replay consistency is a required invariant. + +--- + +## explicit exclusions + +Epic 3 does NOT include: + +* scheduling +* runtime orchestration kernel +* retry policies +* runtime loop enforcement +* approval gating +* tool execution policies +* adaptive routing logic +* budget enforcement +* concurrency control +* distributed execution + +Those belong to future orchestration/runtime layers. + +--- + +## consequences + +### positive + +* introduces deterministic workflow semantics +* makes workflow progression replayable +* formalizes orchestration topology +* separates workflow evaluation from runtime execution +* enables future policy/runtime layers +* preserves strict event-sourcing guarantees + +--- + +### negative + +* introduces graph complexity into core architecture +* deterministic guarantees constrain future runtime flexibility +* workflow semantics now depend heavily on projection correctness +* cycle handling will require future runtime policy enforcement +* event semantics become significantly richer and more difficult to evolve + +--- + +## rationale + +Sessions alone describe lifecycle state, but not workflow progression. + +A transition engine is required to: + +> translate workflow topology into deterministic event-driven execution semantics + +This moves Correx from: + +* “stateful session replay” + to +* “deterministic workflow execution replay” + +Workflow behavior now becomes reconstructible from history. + +--- + +## status + +Epic 3 becomes valid only after: + +* Epic 1 is complete ✔ +* Epic 2 is complete ✔ +* replay determinism is verified ✔ +* session projection infrastructure exists ✔ + +It is the first orchestration-oriented layer in Correx and establishes the deterministic execution substrate required for future runtime and policy systems. diff --git a/docs/epics/epic-4-resolution.md b/docs/epics/epic-4-resolution.md new file mode 100644 index 00000000..d65e863b --- /dev/null +++ b/docs/epics/epic-4-resolution.md @@ -0,0 +1,181 @@ +# Epic 4 — Validation Pipeline (summary) + +## goal + +deterministic, replay-safe validation layer over workflow execution model (graph + transitions + sessions + events), producing a structured report and optional approval request, without influencing execution. + +--- + +## core idea + +Epic 4 is a **pure analysis layer**: + +* consumes: `ValidationContext` (graph + detected cycles + policies + session state) +* produces: + + * `ValidationReport` (deterministic, hierarchical) + * optional `ApprovalRequest` (boundary artifact) + +it never: + +* executes workflows +* modifies state +* triggers transitions +* enforces runtime behavior + +--- + +## module structure + +```text +core/validation +├── model +├── graph +├── transition +├── session +├── semantic +├── pipeline +└── approval +``` + +--- + +## 1. model layer + +defines shared validation contract: + +* `ValidationReport` (sections-based hierarchy) +* `ValidationSection` +* `ValidationIssue` +* `ValidationSeverity` +* `ValidationContext` + +context is a full snapshot: + +* `WorkflowGraph` +* `DetectedCycle` (from Epic 3) +* `CyclePolicyBinding` +* `SessionState` + +--- + +## 2. graph validation + +checks structural correctness only: + +* start node existence +* dangling transitions (invalid stage references) +* cycle reporting (passed-through from Epic 3, no interpretation) + +cycles are: + +* allowed +* informational only + +--- + +## 3. transition validation + +validates engine consistency: + +* transition endpoints exist in graph +* condition presence / sanity +* deterministic ordering correctness per source node + +no evaluation or execution of conditions. + +--- + +## 4. session validation + +validates projection consistency: + +* temporal consistency (`createdAt <= updatedAt`) +* session state sanity (`invalidTransitions >= 0`) +* light consistency checks against graph context + +no replay execution or event mutation logic. + +--- + +## 5. semantic validation + +configuration-level validation layer: + +* cycle-policy binding completeness (mode B: required only in execution-capable mode) +* cross-layer consistency rules +* extension point for future domain rules + +cycle identity is based on: + +* `CycleSignature(nodes only, normalized)` + +--- + +## 6. pipeline executor + +deterministic orchestration: + +* executes validators in fixed order +* aggregates `ValidationSection` +* fully replayable and stateless + +```text +Graph → Transition → Session → Semantic → Report +``` + +no branching logic, no execution semantics. + +--- + +## 7. approval trigger + +boundary component: + +* consumes `ValidationReport` +* computes `RiskSummary` +* produces optional `ApprovalRequest` + +rules: + +* errors or missing cycle policies trigger approval requirement +* does not execute or control workflow system + +--- + +## final outputs + +### validation output + +```text +ValidationReport + ├── graph + ├── transition + ├── session + └── semantic +``` + +### approval output + +```text +ApprovalRequest? (optional) +``` + +--- + +## key invariants enforced + +* deterministic execution (same input → same report) +* replay-safe evaluation +* no state mutation +* no execution coupling +* cycles allowed structurally +* policies required only as semantic governance, not structural correctness + +--- + +## architectural position + +Epic 4 is the **boundary correctness layer**: + +> it ensures the system is internally consistent and safely configurable, without deciding how it runs. diff --git a/docs/epics/epic-4.md b/docs/epics/epic-4.md new file mode 100644 index 00000000..577fbdf4 --- /dev/null +++ b/docs/epics/epic-4.md @@ -0,0 +1,315 @@ +# Epic 4: Validation Pipeline (Deterministic Analysis + Cross-Layer Consistency Engine) + +**status:** completed +**date:** 09.05.2026 (May) +**scope:** `:core:validation` (depends on `:core:events`, `:core:sessions`, `:core:transitions`, Epic 3 analysis outputs) + +--- + +## context + +With deterministic event sourcing (Epic 1), session projection (Epic 2), and workflow transition semantics (Epic 3) in place, Correx now has a complete execution and reconstruction model. + +However, there is no formal layer that ensures: + +* graph integrity +* transition consistency +* session projection correctness +* configuration validity (cycle policies, semantic rules) +* cross-layer coherence under replay + +Without a validation layer: + +* invalid workflow graphs can still be constructed +* inconsistent transitions can pass unnoticed +* session projections can diverge semantically from graph structure +* cycle governance (policy binding) is not enforceably checked +* system correctness is only implicitly trusted, not verified + +Epic 4 introduces a deterministic validation system to close this gap. + +--- + +## goal + +Introduce a **deterministic, replay-safe validation pipeline**: + +> a pure analysis engine that validates workflow structure, execution projections, and configuration consistency without influencing execution behavior + +The system must remain: + +* deterministic +* stateless +* replay-safe +* non-executing +* side-effect free + +It is NOT an orchestration engine and does NOT participate in runtime control flow. + +--- + +## scope (what IS included) + +--- + +### 1. validation model layer + +Define unified validation representation: + +* `ValidationReport` +* `ValidationSection` +* `ValidationIssue` +* `ValidationSeverity` +* `ValidationContext` + +Validation context is a full system snapshot: + +* `WorkflowGraph` +* `DetectedCycle` (Epic 3 output) +* `CyclePolicyBinding` +* `SessionState` + +Properties: + +* immutable +* replay-safe +* fully deterministic input contract + +--- + +### 2. graph validation + +Validate structural correctness of workflow topology. + +Checks: + +* start node existence +* dangling transitions (invalid stage references) +* structural consistency of graph definition +* cycle presence reporting (pass-through only) + +Important: + +* cycles are allowed by design (Epic 3) +* cycle detection is informational only +* no enforcement or interpretation of cycles occurs here + +--- + +### 3. transition validation + +Validate deterministic consistency of transition engine inputs. + +Checks: + +* transition endpoints exist in graph +* condition presence and structural validity +* deterministic ordering correctness per source node +* ambiguity detection in transition resolution ordering + +Important: + +* transition conditions are NOT executed +* no resolution logic is invoked +* no runtime decisioning occurs + +--- + +### 4. session validation + +Validate projection consistency against event-derived state. + +Checks: + +* temporal consistency (`createdAt ≤ updatedAt`) +* session state sanity (invalid transition counts) +* consistency between session state and graph structure +* lightweight anomaly detection over projection output + +Important: + +* no event replay is performed +* no projection recomputation occurs +* session state is treated as immutable derived artifact + +--- + +### 5. semantic validation layer + +Validate configuration and cross-domain consistency rules. + +Includes: + +* cycle-policy binding completeness (mode-dependent enforcement) +* configuration consistency checks +* cross-layer structural coherence rules + +Cycle semantics: + +* `CycleSignature` derived from normalized node sets +* edges are not part of identity +* policy binding is evaluated against cycle signatures only + +Important: + +* cycles remain structurally valid (Epic 3 rule preserved) +* semantic layer does NOT enforce execution behavior +* policies are treated as configuration constraints, not runtime logic + +--- + +### 6. validation pipeline executor + +Define deterministic orchestration of validation layers. + +Pipeline behavior: + +* strictly ordered execution +* no hidden parallelism +* fully deterministic evaluation order +* stateless execution model + +Execution flow: + +```text +Graph → Transition → Session → Semantic → ValidationReport +``` + +Important: + +* pipeline aggregates results only +* no control flow decisions beyond aggregation +* no mutation of input context + +--- + +### 7. approval trigger integration point + +Introduce boundary layer between validation and external decision systems. + +Responsibilities: + +* evaluate `ValidationReport` +* compute risk summary +* decide whether approval request is required +* emit `ApprovalRequest` artifact + +Important constraints: + +* no execution coupling +* no workflow control logic +* no system orchestration responsibility +* strictly output transformation layer + +Output artifacts: + +* `ApprovalRequest` +* `RiskSummary` + +Behavior: + +* errors or missing cycle-policy bindings trigger approval requirement +* acts as deterministic gating boundary, not execution controller + +--- + +### 8. deterministic testing requirements + +Validation system must be fully testable under replay constraints. + +Coverage includes: + +* graph validation determinism +* transition validation consistency +* session projection sanity validation +* semantic rule enforcement consistency +* pipeline ordering determinism +* approval trigger stability +* full system replay equivalence + +Invariant: + +> identical input context must always produce identical ValidationReport and ApprovalRequest outcome + +--- + +## explicit exclusions + +Epic 4 does NOT include: + +* workflow execution +* transition evaluation or resolution +* scheduling or orchestration +* runtime policy enforcement +* execution retry logic +* system control flow decisions +* async or distributed validation execution +* event mutation or emission + +Those belong to future runtime/orchestration layers. + +--- + +## consequences + +### positive + +* introduces explicit correctness boundary for entire system +* makes workflow + session consistency verifiable +* decouples analysis from execution +* enables safe pre-execution validation gating +* formalizes configuration governance (cycle policies) +* improves replay debugging and system observability + +--- + +### negative + +* adds another full abstraction layer over already complex system +* increases conceptual separation between “valid structure” and “valid execution” +* introduces potential duplication of logic if misused outside boundary +* requires strict discipline to avoid leaking execution semantics into validation +* makes system reasoning more layered (analysis vs execution vs policy) + +--- + +## rationale + +Epic 4 exists because deterministic execution alone is insufficient. + +Even with: + +* event sourcing (Epic 1) +* projection layer (Epic 2) +* workflow engine (Epic 3) + +the system still lacks: + +> a formal correctness boundary over structure, configuration, and derived state + +This epic ensures: + +* workflows are structurally valid before execution +* session state remains consistent with graph semantics +* configuration constraints (cycle policies) are explicitly governed +* system behavior remains fully replay-verifiable + +It completes the shift from: + +* “deterministic execution system” + to +* “deterministic + verifiable workflow system” + +--- + +## status + +Epic 4 is considered complete once: + +* validation model is defined ✔ +* graph/transition/session/semantic validators exist ✔ +* pipeline executor is deterministic ✔ +* approval trigger boundary is implemented ✔ +* replay tests confirm stability ✔ + +It is the final analysis layer before runtime orchestration and execution-control systems are introduced in future epics. diff --git a/docs/epics/epic-6-resolution.md b/docs/epics/epic-6-resolution.md new file mode 100644 index 00000000..5dc20723 --- /dev/null +++ b/docs/epics/epic-6-resolution.md @@ -0,0 +1,181 @@ +# Epic 6 — Artifact System (Contract Layer + Lifecycle Events) + +## completed deliverables + +### 1. artifact identity + +introduced `ArtifactId` as a type alias to `TypeId` in `core:events`, following the established identity type pattern. + +* lives in `core:events` alongside `SessionId`, `StageId`, and other shared vocabulary types +* available to all modules that depend on `:core:events` +* no circular dependency risk + +--- + +### 2. artifact lifecycle phase model + +introduced `ArtifactLifecyclePhase` as a serializable enum in `core:events`. + +phases (in mandatory transition order): + +```text +CREATED → VALIDATING → VALIDATED → REJECTED + ↘ SUPERSEDED → ARCHIVED +``` + +properties: + +* all six phases are mandatory by spec +* lives in `core:events` so lifecycle events can reference it with full type safety +* corrections to artifacts produce new artifacts via supersession — not in-place edits + +--- + +### 3. artifact contract layer + +introduced the `Artifact` sealed interface as the root domain abstraction for all structured model outputs. + +final structure: + +```text +Artifact (sealed interface) + ├── id (ArtifactId) + ├── sessionId (SessionId) + ├── stageId (StageId) + ├── schemaVersion (Int) + ├── createdAt (Instant) + ├── lifecyclePhase (ArtifactLifecyclePhase) + ├── lineage (ArtifactLineage) + └── metadata (Map) +``` + +properties: + +* immutable after creation +* serialization-safe via kotlinx.serialization +* no infrastructure coupling +* extensible — concrete artifact categories are deferred + +--- + +### 4. artifact relationship model + +introduced append-only relationship tracking between artifacts. + +final structures: + +* `ArtifactRelationshipType` — seven typed relationships: + * `PARENT`, `CHILD`, `SUPERSEDES`, `DERIVED_FROM` + * `VALIDATED_BY`, `APPROVED_BY`, `GENERATED_FROM` +* `ArtifactRelationship(sourceId, targetId, type)` + +properties: + +* relationships are append-only — never mutated or removed +* all relationship changes produce new events + +--- + +### 5. artifact lineage model + +introduced immutable lineage tracking for all artifacts. + +final structure: + +```text +ArtifactLineage + ├── parentIds (List) + ├── relationships (List) + └── validationResultIds (List) +``` + +properties: + +* fully serializable +* validation results referenced by ID only — no direct dependency on `core:validation` +* replayable and traceable from event log alone + +--- + +### 6. artifact serialization module + +introduced `artifactModule: SerializersModule` in `core:artifacts`. + +* polymorphic block scaffolded for concrete artifact subclasses (deferred) +* follows the same pattern as `eventModule` in `core:events` + +--- + +### 7. artifact lifecycle events + +introduced seven lifecycle events in `core:events`, covering the full artifact lifecycle and relationship tracking. + +events: + +* `ArtifactCreatedEvent(artifactId, sessionId, stageId, schemaVersion)` +* `ArtifactValidatingEvent(artifactId, sessionId, stageId)` +* `ArtifactValidatedEvent(artifactId, sessionId, stageId)` +* `ArtifactRejectedEvent(artifactId, sessionId, stageId, reason)` +* `ArtifactSupersededEvent(artifactId, supersededById, sessionId, stageId)` +* `ArtifactArchivedEvent(artifactId, sessionId, stageId)` +* `ArtifactRelationshipAddedEvent(sourceId, targetId, relationshipType, sessionId)` + +properties: + +* all registered in `eventModule` polymorphic block +* `relationshipType` carried as `String` to avoid circular dependency with `core:artifacts` +* serialization-safe, replay-ready + +--- + +# final architecture after Epic 6 + +```text +Artifact (sealed interface — root abstraction) + ↓ +ArtifactLifecyclePhase (CREATED → ... → ARCHIVED) + ↓ +ArtifactLineage (parentIds + relationships + validationResultIds) + ↓ +ArtifactRelationship (append-only, 7 typed relationships) + ↓ +Lifecycle events in core:events (7 events, all registered) + ↓ +artifactModule (SerializersModule, ready for subclass registration) +``` + +--- + +# major architectural outcomes + +Epic 6 established: + +* type-safe artifact identity integrated into the shared event vocabulary +* immutable, replayable artifact contract with full lifecycle phase tracking +* append-only relationship graph with seven typed relationship kinds +* lineage model decoupled from validation internals (reference by ID only) +* lifecycle event backbone enabling state reconstruction from events alone +* serialization module scaffolded for concrete artifact categories + +--- + +# what Epic 6 intentionally does NOT include + +not implemented: + +* concrete artifact categories (`ReasoningArtifact`, `PatchArtifact`, `PlanArtifact`, etc.) +* artifact reducer, projector, or repository (state reconstruction layer) +* persistence — artifacts are not stored independently; state rebuilds from events +* provenance metadata model (originating model, provider, generation config, tool receipts) +* semantic validation — artifact legality is validated externally by `:core:validation` +* approval integration + +those are explicitly deferred to later epics. + +--- + +# final state + +Correx now has: + +> an immutable, event-sourced artifact contract layer with typed identity, a mandatory six-phase lifecycle, append-only relationship tracking, and a full suite of lifecycle events — establishing the structured output abstraction through which models contribute semantic work to the system, with state fully reconstructable from the event log. diff --git a/docs/epics/epic-7-resolution.md b/docs/epics/epic-7-resolution.md new file mode 100644 index 00000000..56739490 --- /dev/null +++ b/docs/epics/epic-7-resolution.md @@ -0,0 +1,203 @@ +# Epic 7 — Context Engine (Deterministic Context Synthesis Layer) + +## completed deliverables + +### 1. context identity + +introduced `ContextPackId` and `ContextEntryId` as type aliases to `TypeId` in `core:events`. + +* lives alongside other shared vocabulary types (`SessionId`, `ArtifactId`, etc.) +* available to all modules depending on `:core:events` + +--- + +### 2. context lifecycle events + +introduced five context lifecycle events in `core:events`, covering the full pack building pipeline. + +events: + +* `ContextBuildingStartedEvent(sessionId, stageId)` +* `ContextPackBuiltEvent(contextPackId, sessionId, stageId, budgetUsed, budgetLimit)` +* `CompressionAppliedEvent(contextPackId, layer, entriesRemoved, strategyApplied)` +* `LayerTruncatedEvent(contextPackId, layer, entriesDropped, reason)` +* `ContextBuildingFailedEvent(sessionId, stageId, reason)` + +properties: + +* all registered in `eventModule` polymorphic block +* emitted during pack assembly — enables deterministic replay of compression decisions +* `layer` and `strategyApplied` carried as `String` — avoids circular dependency with `core:context` + +--- + +### 3. context layer model + +introduced `ContextLayer` as a serializable enum with five distinct layers. + +```text +L0 live execution [active request, current tool output, pending approval] +L1 stage-local context [artifacts and receipts from the current stage] +L2 compressed session [summaries of completed stages; kept under a token cap] +L3 project memory [persistent facts, architecture decisions, key outputs] +L4 archival history [full event log; not used for inference] +``` + +models receive a `ContextPack` built from L0–L2; L3 optionally injected if relevant. L4 is archival only. + +--- + +### 4. context pack contract types + +introduced the core domain types for context synthesis. + +final structures: + +* `ContextEntry(id, layer, content, sourceType, sourceId, tokenEstimate)` — typed reference to an event, artifact excerpt, summary, or policy snippet +* `CompressionMetadata(appliedStrategies, truncatedLayers, entriesDropped)` — audit trail of compression decisions +* `ContextPack(id, sessionId, stageId, layers, budgetUsed, budgetLimit, compressionMetadata)` — the final assembled context artifact +* `TokenBudget(limit, used)` — transient computation value with `remaining`, `consume()`, and `canFit()` helpers; intentionally not serializable + +--- + +### 5. compression pipeline + +introduced a content-type-specific, rule-based, deterministic compression pipeline. zero model calls. + +final structure: + +* `CompressionStrategy` (sealed interface, five content-type-specific implementations): + * `ToolLog` — deduplicate identical content, drop oldest when over budget + * `Conversation` — keep last N verbatim, drop oldest first under budget pressure + * `Artifact` — keep most recent entry per `sourceId` + * `SteeringNote` — always retained verbatim, never dropped + * `EventHistory` — always retained; already-compressed DecisionPoints from prior stages +* `ContextCompressor` (interface) +* `DefaultContextCompressor` — applies strategies deterministically; drop order: L2 first, then L1; L0 is never dropped + +key invariant: + +> same input always produces the same output — compression is replay-safe + +--- + +### 6. context state and projection + +introduced the standard event-sourced pattern for context state. + +final structures: + +* `ContextState(builtPackIds, buildingInProgress)` — fully rebuildable from events; no external data +* `ContextReducer` (interface) +* `DefaultContextReducer` — handles `ContextBuildingStartedEvent`, `ContextPackBuiltEvent`, `ContextBuildingFailedEvent`; passes all others through unchanged +* `ContextProjector` — implements `Projection`; delegates to reducer + +--- + +### 7. context pack builder + +introduced a pure, side-effect-free context pack assembly pipeline. + +final structures: + +* `ContextPackBuilder` (interface) +* `DefaultContextPackBuilder` — compresses entries, groups by layer, tracks budget usage and truncation metadata + +key invariant enforced: + +> entries with `sourceType` of `"steeringNote"` or `"eventHistory"` are partitioned before compression and always retained, regardless of budget pressure + +* `DecisionPointBuilder` (interface) +* `DefaultDecisionPointBuilder` — extracts only L0 and L1 entries from a pack for model inference calls + +--- + +### 8. context repository + +introduced `DefaultContextRepository` over `EventReplayer`, following the established repository pattern. + +* single method: `getContextState(sessionId): ContextState` +* delegates to `replayer.rebuild(sessionId)` +* no business logic — thin wiring layer + +--- + +### 9. context serialization module + +introduced `contextModule: SerializersModule` in `core:context`. + +* polymorphic block scaffolded for future extension +* follows the same pattern as `eventModule` and `artifactModule` + +--- + +### 10. test coverage + +implemented deterministic and projection tests covering all core behaviors. + +deterministic tests (27): + +* compression strategies: determinism, layer drop order, never-drop invariants (SteeringNote, EventHistory), artifact deduplication, tool log deduplication +* token budget: arithmetic correctness, immutability, exhaustion, canFit semantics +* pack builder: layer grouping, budget tracking, budget enforcement, decision point filtering, steering note retention under budget pressure, empty entries + +projection tests (22): + +* reducer: all three context event transitions, pass-through for unrelated events +* projector: initial state, deterministic replay + +--- + +# final architecture after Epic 7 + +```text +ContextBuildingStartedEvent → ContextPackBuiltEvent (via core:events) + ↓ +ContextState (rebuilt from events via DefaultContextReducer + ContextProjector) + ↓ +DefaultContextPackBuilder (pure function) + ├── pins: SteeringNote + EventHistory entries (never dropped) + ├── compresses: remaining entries via DefaultContextCompressor + │ └── strategy dispatch: ToolLog / Conversation / Artifact / SteeringNote / EventHistory + │ └── drop order: L2 → L1 (L0 never dropped) + └── assembles: ContextPack with layer map + budget metadata + ↓ +DefaultDecisionPointBuilder → L0 + L1 entries (sent to model for inference) +``` + +--- + +# major architectural outcomes + +Epic 7 established: + +* deterministic, rule-based context synthesis — zero model calls for compression +* five-layer context model separating live execution from compressed history +* content-type-specific compression strategies with never-drop invariants for critical entries +* token budget enforcement with correct layer eviction priority +* event-sourced context state fully rebuildable from the event log +* pack builder as a pure function — no IO, no side effects, replay-safe + +--- + +# what Epic 7 intentionally does NOT include + +not implemented: + +* policy injection into context packs (step 5 of the pipeline — deferred) +* event retrieval and projection wiring for pack building (caller provides entries) +* L3/L4 storage and retrieval (infrastructure concern) +* router context isolation (separate L2 memory for the router — deferred) +* semantic summarization — no model-assisted compression of any kind +* cross-session context leaking prevention (enforced structurally by session scoping) +* observability hooks beyond event emission + +those are explicitly deferred to later epics. + +--- + +# final state + +Correx now has: + +> a deterministic, rule-based context synthesis engine that assembles token-budgeted, layered context packs from events and artifacts — with content-type-specific compression strategies, strict never-drop invariants for steering notes and decision history, and full event-sourced state tracking — ensuring models always receive minimal, relevant, reproducible context without any model calls in the compression path. diff --git a/docs/epics/epic-8-progress.md b/docs/epics/epic-8-progress.md new file mode 100644 index 00000000..b4407567 --- /dev/null +++ b/docs/epics/epic-8-progress.md @@ -0,0 +1,46 @@ +# Epic 8 — Tool System Progress + +**Plan:** `docs/superpowers/plans/2026-05-11-core-tools.md` + +## Done + +### Task 1 — Tool shared vocabulary and lifecycle events in `core:events` ✅ +- `AnyMapSerializer.kt` — `Map` ↔ JSON bridge via JsonElement +- `ToolRequest.kt`, `ToolReceipt.kt` — shared vocabulary data classes +- `ToolInvocationId` typealias added to `IdentityTypes.kt` +- 5 lifecycle event classes: `ToolInvocationRequestedEvent`, `ToolExecutionStartedEvent`, `ToolExecutionCompletedEvent`, `ToolExecutionFailedEvent`, `ToolExecutionRejectedEvent` +- All 5 registered in `Serialization.kt` polymorphic block +- 8 tests passing including polymorphic round-trip + +### Task 2 — `core:tools` contract types and build config ✅ +- `Tool.kt` — plain `interface` (not sealed; infrastructure implements it) +- `ToolCapability.kt` — enum: FILE_READ, FILE_WRITE, NETWORK_ACCESS, SHELL_EXEC, PROCESS_SPAWN +- `ValidationResult.kt` — sealed interface: `Valid` / `Invalid(reason)` +- `build.gradle` — deps: `core:events`, `core:approvals`, `core:sessions` +- 3 tests passing + +## Remaining (nothing is done, clean plate) + +### Task 3 — `core:tools` state model +- `ToolInvocationStatus` enum: REQUESTED, STARTED, COMPLETED, FAILED, REJECTED +- `ToolInvocationRecord` data class (holds one invocation's full lifecycle) +- `ToolState` data class: `invocations: List` + +### Task 4 — `core:tools` reducer, projector, repository +- `ToolReducer` interface + `DefaultToolReducer` (applies 5 lifecycle events to ToolState) +- `ToolProjector` (implements `Projection`) +- `DefaultToolRepository` (wraps `EventReplayer`) + +### Task 5 — Mock tools in `testing:fixtures` +- `EchoTool` (T0, always Valid) +- `FailingTool` (T2, always Invalid) +- `RejectedTool` (T4, always Valid) +- `testing/fixtures/build.gradle` gains `:core:tools` dep + +## Resume Instructions + +1. Open this session in `/home/kami/Programs/correx` +2. Read `docs/superpowers/plans/2026-05-11-core-tools.md` for full task specs +3. Continue from **Task 3** +4. Model selection: Haiku for implementers, Sonnet for reviewers +5. No git repo in this project — skip all commit steps in the plan diff --git a/docs/epics/epic-8-resolution.md b/docs/epics/epic-8-resolution.md new file mode 100644 index 00000000..eb9e3c5b --- /dev/null +++ b/docs/epics/epic-8-resolution.md @@ -0,0 +1,176 @@ +# Epic 8 — Tool System (core:tools) + +## completed deliverables + +### 1. shared vocabulary in core:events + +introduced tool identity, request/receipt types, and a serialization bridge for dynamic parameters. + +final structures: + +* `ToolInvocationId` — typealias added to `IdentityTypes.kt` +* `ToolRequest` — captures invocation identity, session, stage, tool name, and arbitrary parameters +* `ToolReceipt` — captures exit code, output summary, structured output, affected entities, duration, tier, and timestamp +* `AnyMapSerializer` — `Map` ↔ JSON bridge via `JsonElement`; required because kotlinx.serialization cannot serialize `Any` directly + +key properties: + +* shared vocabulary lives in `core:events` to prevent circular dependencies +* `core:tools` depends on `core:events`, not the reverse +* parameters and structured output are fully round-trip serializable + +--- + +### 2. tool lifecycle events in core:events + +introduced five event classes covering the full execution lifecycle of a tool invocation. + +final events: + +* `ToolInvocationRequestedEvent` — invocation submitted with request and tier +* `ToolExecutionStartedEvent` — execution begun +* `ToolExecutionCompletedEvent` — execution succeeded; carries `ToolReceipt` +* `ToolExecutionFailedEvent` — execution failed; carries reason string +* `ToolExecutionRejectedEvent` — invocation rejected before execution; carries tier and reason + +all five events: + +* implement `EventPayload` +* registered in `Serialization.kt` polymorphic block +* validated via 8 round-trip serialization tests + +--- + +### 3. tool contract interface (core:tools) + +defined the `Tool` interface as the stable contract that all tool implementations must satisfy. + +final structures: + +* `Tool` — plain interface (not sealed; infrastructure implements it) +* `ToolCapability` — enum: `FILE_READ`, `FILE_WRITE`, `NETWORK_ACCESS`, `SHELL_EXEC`, `PROCESS_SPAWN` +* `ValidationResult` — sealed interface: `Valid` / `Invalid(reason)` + +interface properties: + +* `name: String` +* `tier: Tier` +* `requiredCapabilities: Set` +* `validateRequest(request: ToolRequest): ValidationResult` + +`core:tools` declares the contract; it never executes tools. Infrastructure modules provide concrete implementations. + +--- + +### 4. event-sourced state model + +introduced the state layer that tracks all tool invocations for a session. + +final structures: + +* `ToolInvocationStatus` — enum: `REQUESTED`, `STARTED`, `COMPLETED`, `FAILED`, `REJECTED` +* `ToolInvocationRecord` — holds the full lifecycle of one invocation: id, name, tier, request, status, optional receipt, requested/completed timestamps +* `ToolState` — root state: `invocations: List` + +properties: + +* fully `@Serializable` throughout +* state is always rebuilt from events; never persisted independently +* `receipt` and `completedAt` are nullable; populated only on terminal events + +--- + +### 5. reducer, projector, and repository + +implemented the standard CORREX pattern for event-sourced state reconstruction. + +final components: + +```text +ToolReducer (interface) + └── DefaultToolReducer (applies 5 lifecycle events to ToolState) + +ToolProjector (implements Projection) + └── bridges reducer to EventReplayer infrastructure + +DefaultToolRepository + └── thin wrapper over EventReplayer + └── getToolState(sessionId): ToolState +``` + +reducer behavior: + +* `ToolInvocationRequestedEvent` → appends new `REQUESTED` record +* `ToolExecutionStartedEvent` → transitions matching record to `STARTED` +* `ToolExecutionCompletedEvent` → transitions to `COMPLETED`, attaches receipt and completedAt +* `ToolExecutionFailedEvent` → transitions to `FAILED`, sets completedAt +* `ToolExecutionRejectedEvent` → transitions to `REJECTED`, sets completedAt +* unrelated events → pass through unchanged + +wiring (`DefaultEventReplayer(store, ToolProjector(DefaultToolReducer()))`) is left to infrastructure modules. + +--- + +### 6. mock tools in testing:fixtures + +introduced three deterministic tool implementations for use across test suites. + +final tools: + +* `EchoTool` — tier T0, always returns `ValidationResult.Valid` +* `FailingTool` — tier T2, always returns `ValidationResult.Invalid("simulated validation failure")` +* `RejectedTool` — tier T4, always returns `ValidationResult.Valid` + +`testing/fixtures/build.gradle` gains `:core:tools` dependency. + +tier assignment mirrors the approval tier model: T0 (auto-approve), T2 (requires approval), T4 (always reject). + +--- + +# final architecture after Epic 8 + +```text +Tool (interface — core:tools) + ↓ +ToolRequest / ToolReceipt (core:events — shared vocabulary) + ↓ +5 lifecycle EventPayload subclasses (core:events) + ↓ +DefaultToolReducer → ToolState (event-sourced projection) + ↓ +ToolProjector → EventReplayer → DefaultToolRepository +``` + +--- + +# major architectural outcomes + +Epic 8 established: + +* safe tool contract: execution is infrastructure's responsibility, not core's +* full lifecycle event coverage from request to terminal state +* deterministic, replay-safe tool invocation tracking +* tier-aware tool classification aligned with the approval system +* reusable mock tools for integration and deterministic test suites + +--- + +# what Epic 8 intentionally does NOT include + +not implemented: + +* concrete tool implementations (file I/O, shell, network — infrastructure concern) +* approval gating logic (Epic 5) +* tool registry or discovery mechanism +* execution engine or sandbox enforcement +* kernel orchestration of tool invocations + +those are explicitly deferred to infrastructure modules and later epics. + +--- + +# final state + +Correx now has: + +> a complete tool abstraction layer: a stable contract interface, a full five-event lifecycle model, event-sourced invocation state with reducer/projector/repository, and deterministic mock tools for testing — with no coupling to execution infrastructure. diff --git a/docs/epics/epic-9-resolution.md b/docs/epics/epic-9-resolution.md new file mode 100644 index 00000000..bbce058e --- /dev/null +++ b/docs/epics/epic-9-resolution.md @@ -0,0 +1,195 @@ +# Epic 9 — Inference Abstraction (:core:inference) + +## completed deliverables + +### 1. core value types + +introduced the full set of inference-domain value types as the semantic foundation of the module. + +final structures: + +* `ModelCapability` — sealed class (Coding, ToolCalling, Reasoning, Summarization, General) +* `CapabilityScore` — capability + score (0.0–1.0); used for provider selection +* `FinishReason` — sealed: Stop, Length, Timeout, Cancelled, Error(message) +* `ProviderHealth` — sealed: Healthy, Degraded(reason), Unavailable(reason) +* `CancellationReason` — sealed: UserRequested, StageTimeout, SessionCancelled, ProviderEvicted +* `InferenceTimeout` — value class wrapping Duration +* `TokenUsage` — promptTokens, completionTokens, totalTokens (derived) +* `GenerationConfig` — fully serializable; temperature, topP, maxTokens, stopSequences, seed + +key properties: + +* `GenerationConfig` and `TokenUsage` are `@Serializable` — replay requirement +* `ProviderHealth` and `CancellationReason` are runtime-only sealed classes — not serialized directly +* events carry string labels for cancellation context, not sealed class instances + +--- + +### 2. inference request / response model + +introduced the canonical data contract for all inference calls in the system. + +final structures: + +* `InferenceRequestId` — opaque value class; caller-generated, provider treats as pass-through +* `InferenceRequest` — requestId + sessionId + stageId + contextPack + generationConfig + optional timeout +* `InferenceResponse` — requestId + text + finishReason + tokensUsed + latencyMs + +both are `@Serializable` for replay correctness. + +`requestId` is always caller-generated — consistent with identity ownership across sessions, artifacts, and approvals modules. + +--- + +### 3. tokenizer interface + +introduced a provider-owned tokenizer abstraction, separated from the provider interface itself. + +final structures: + +* `Token` — value class wrapping Int +* `Tokenizer` — interface: `tokenize(text): List`, `countTokens(text): Int` + +tokenizer is a property of `InferenceProvider`, not a method — makes it clear that tokenization is a static capability of the model, not a per-call operation. two providers declaring the same `ModelCapability` may not share a tokenizer. + +--- + +### 4. provider interface and registry + +introduced the core provider abstraction and its lookup contract. + +final structures: + +* `ProviderId` — value class +* `InferenceProvider` — interface: id, name, tokenizer, infer, healthCheck, capabilities +* `ProviderRegistry` — interface: register, resolve(capability), listAll, healthCheckAll + +`resolve(capability)` returns an ordered list (score descending) — routing strategy picks from this list; registry does not select. + +--- + +### 5. routing contracts + +introduced pluggable routing as a first-class contract, not hardcoded selection logic. + +final structures: + +* `RoutingStrategy` — interface: `select(candidates, requiredCapabilities): InferenceProvider` +* `InferenceRouter` — interface: `route(stageId, requiredCapabilities): InferenceProvider` +* `NoEligibleProviderException` — typed failure; no nullable returns + +routing is pure — no I/O, operates on a snapshot of available providers. decoupling `RoutingStrategy` from `InferenceRouter` allows selection logic (best score, round-robin, fallback chain) to be swapped without touching the router contract. + +--- + +### 6. cancellation semantics + +introduced the cancellation contract and established the coroutine cooperation rules for all provider implementations. + +final structure: + +* `InferenceCancellationToken` — interface: isCancelled, cancel(reason) + +coroutine contract (documented as KDoc on the interface): + +* `infer()` must call `ensureActive()` before the socket write, after each streamed chunk, and before parsing the final response +* blocking calls must be wrapped with `withContext(Dispatchers.IO)` +* `CancellationException` must never be swallowed + +contract is intentionally documentation-only at this layer — enforcement lives in implementations (epic 11). + +--- + +### 7. inference events and serialization module + +introduced the full set of inference lifecycle events and registered them in the polymorphic serialization system. + +events: + +* `InferenceStartedEvent` — requestId, sessionId, stageId, providerId +* `InferenceCompletedEvent` — requestId, sessionId, stageId, providerId, tokensUsed, latencyMs +* `InferenceFailedEvent` — requestId, sessionId, stageId, providerId, reason (string) +* `InferenceTimeoutEvent` — requestId, sessionId, stageId, providerId, timeoutMs +* `ModelLoadedEvent` — providerId, sessionId +* `ModelUnloadedEvent` — providerId, sessionId, cancellationReason (string, optional) + +all events carry `requestId` for causation tracing back to the originating call. + +`inferenceModule: SerializersModule` registers all payloads — follows the same pattern as `eventModule`, `artifactModule`, `contextModule`. + +--- + +### 8. mock provider and contract test infrastructure + +introduced a deterministic fake provider for contract validation and test isolation. + +final structures: + +* `MockTokenizer` — character-based approximation (1 token ≈ 4 chars); deterministic; not model-accurate +* `MockInferenceProvider` — configurable: fixed response, artificial delay, forced failure; exposes `inferCallCount` for assertion +* `InferenceProviderContractTest` — abstract contract test class; same shape as `EventStoreContractTest` + +contract tests cover: + +* response carries matching requestId +* text is non-blank +* token usage is tracked and positive +* latency is non-negative +* healthCheck returns non-null +* capabilities non-empty with scores in 0.0–1.0 +* coroutine cancellation is respected +* tokenizer count is consistent with tokenize + +--- + +# final architecture after Epic 9 + +```text +InferenceRouter (capability-driven routing) + ↓ +RoutingStrategy (pluggable selection logic) + ↓ +ProviderRegistry (capability → provider resolution) + ↓ +InferenceProvider (stateless compute contract) + ├── tokenizer: Tokenizer + ├── infer(InferenceRequest): InferenceResponse + └── capabilities(): Set + ↓ +InferenceEvents → EventStore (via core:events) +``` + +--- + +# major architectural outcomes + +Epic 9 established: + +* stable inference provider abstraction — models are interchangeable, infrastructure-independent +* capability-based routing with pluggable selection strategy +* provider-owned tokenization — no shared tokenizer assumption across model families +* caller-generated request identity — consistent with system-wide identity ownership model +* cooperative cancellation contract — enforced at implementation layer, documented at contract layer +* full inference lifecycle event coverage with causation tracing via requestId +* replay-safe serialization for all request/response/config types +* mock provider and abstract contract tests for deterministic validation in all future epics + +--- + +# what Epic 9 intentionally does NOT include + +not implemented: + +* inference state projection / reducer / repository — deferred to Epic 10 where the orchestrator provides a concrete consumer +* actual provider implementations (llama.cpp, ollama) — infrastructure concern, Epic 11 +* streaming response handling — deferred; contract is text-final only for now +* GPU residency scheduling — infrastructure concern, Epic 11 +* router context isolation (separate L2 memory per provider) — deferred to Epic 13 + +--- + +# final state + +`:core:inference` provides: + +> a fully specified, infrastructure-independent inference abstraction layer with capability-based routing, cooperative cancellation semantics, replay-safe request/response contracts, and a deterministic mock provider — ready to be composed by the orchestration kernel in Epic 10. \ No newline at end of file diff --git a/docs/epics/epics.md b/docs/epics/epics.md new file mode 100644 index 00000000..3dfe5a72 --- /dev/null +++ b/docs/epics/epics.md @@ -0,0 +1,331 @@ +below is a cleaned, dependency-aware epic structure aligned with your invariants ADR. order matters; this is a build sequence, not just grouping. + +--- + +# EPIC 0: Core contracts & identity system (foundation layer) + +goal: define all shared primitives used across the system + +deliverables: + +* `Event` +* `Artifact` +* `SessionId`, `EventId`, `CorrelationId`, `CausationId` +* `StageId`, `ToolId` +* base envelope types (`EventEnvelope`, `Command`, `Result`) +* versioning model for all serialized data +* error model (typed, replay-safe) + +dependencies: none + +why first: everything else depends on these invariants being stable + +--- + +# EPIC 1: Event system (:core:events) + +goal: deterministic append-only event backbone + +deliverables: + +* event store interface +* in-memory store (test) +* sqlite store (infra later) +* ordering guarantees per session +* causality enforcement +* serialization layer (kotlinx.serialization) +* idempotency rules + +dependencies: epic 0 + +--- + +# EPIC 2: Session lifecycle (:core:sessions) + +goal: deterministic session FSM + projection + +deliverables: + +* session states + transitions +* session projection model +* recovery from event stream +* pause/resume/cancel semantics +* session event reducers + +dependencies: + +* epic 0 +* epic 1 + +--- + +# EPIC 3: Transition engine (:core:transitions) + +goal: workflow graph execution engine + +deliverables: + +* transition DSL +* graph model +* cycle/unreachable detection +* deterministic evaluation rules +* stage execution contract + +dependencies: + +* epic 0 +* epic 1 + +--- + +# EPIC 4: Validation pipeline (:core:validation) + +goal: deterministic multi-layer validation system + +deliverables: + +* routing validation +* schema validation +* semantic validation hooks +* pipeline executor +* approval trigger integration point (no execution coupling) + +dependencies: + +* epic 0 +* epic 1 +* epic 3 + +--- + +# EPIC 5: Approval system (:core:approvals) + +goal: tiered, replay-safe authorization layer + +deliverables: + +* tier model (T0–T4) +* session/stage/project grants +* approval lifecycle events +* timeout semantics +* diff/preview contract interface +* steering injection model + +dependencies: + +* epic 0 +* epic 1 +* epic 4 + +--- + +# EPIC 6: Artifact system (:core:artifacts) + +goal: immutable outputs with lineage tracking + +deliverables: + +* artifact schema +* lineage graph model +* structured summary fields (non-authoritative) +* validation results attachment +* serialization rules + +dependencies: + +* epic 0 +* epic 1 + +--- + +# EPIC 7: Context engine (:core:context) + +goal: deterministic context synthesis layer + +deliverables: + +* context layers (L0–L3) +* compression pipeline (rule-based only) +* token budgeting +* decision point builder (from events + artifacts) +* context pack generator + +dependencies: + +* epic 0 +* epic 1 +* epic 6 + +--- + +# EPIC 8: Tool system (:core:tools) + +goal: safe tool execution abstraction + +deliverables: + +* tool interface contract +* execution lifecycle model +* receipt schema (mandatory structured output) +* sandbox contract definition +* tier assignment per tool +* mock tools for testing + +dependencies: + +* epic 0 +* epic 1 +* epic 5 + +--- + +# EPIC 9: Inference abstraction (:core:inference) + +goal: pluggable model execution layer + +deliverables: + +* provider interface +* request/response model +* capability registry +* routing logic (model selection) +* cancellation/timeout semantics +* mock provider + +dependencies: + +* epic 0 +* epic 1 +* epic 7 + +--- + +# EPIC 10: Kernel orchestration (:core:kernel) + +goal: deterministic execution engine (system brain) + +deliverables: + +* SessionOrchestrator +* WorkflowCoordinator +* ExecutionScheduler +* main event loop +* integration of: + + * validation + * approvals + * inference + * tools + * transitions +* replay driver + +dependencies: + +* everything above (epics 0–9) + +--- + +# EPIC 11: Infrastructure layer + +goal: real-world adapters for persistence and execution + +deliverables: + +* sqlite event store +* snapshot store +* inference providers (llama.cpp, ollama, etc.) +* tool implementations (shell/git/fs) +* sandboxing layer +* telemetry exporters + +dependencies: + +* epic 1 +* epic 8 +* epic 9 + +--- +# EPIC 12: Technical debt and refactoring + +goal: fix the known problems and bugs, close the discovered gaps + +deliverables: + +* no known technical debt is present +* 95% of classes are refactored with concrete rules applied +* opened bugs are closed and verified with appropriate tests + +dependencies: +* all previous epics + +--- +# EPIC 13: Interfaces (CLI + API) + +goal: user interaction layer + +deliverables: + +* CLI (Clikt) +* Ktor API server +* websocket event stream +* session control endpoints +* approval interaction endpoints + +dependencies: + +* epic 10 +* epic 11 + +--- + +# EPIC 14: Router (:core:router) + +goal: user-facing orchestration façade + +deliverables: + +* conversational memory layer (L2/L3 bridge) +* summarization rules (non-authoritative) +* steering interpretation +* UX-level session framing + +dependencies: + +* epic 7 +* epic 10 + +--- + +# EPIC 15: Observability & replay tooling + +goal: debugging + deterministic inspection system + +deliverables: + +* structured logging with correlation +* event stream inspector +* full session replay tool +* deterministic test harness +* fuzz testing for transitions/approvals +* trace visualization (optional later) + +dependencies: + +* epic 1 +* epic 10 +* epic 11 + +--- + +## ordering summary (critical path) + +```text id="epic-order" +0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 15 +``` + +--- + +if you want next step, the useful move is not more design, but: + +* defining **event schemas for EPIC 1 + EPIC 2 together** +* because that’s where most systems like this silently break consistency later diff --git a/docs/epics/progress.json b/docs/epics/progress.json new file mode 100644 index 00000000..31c48101 --- /dev/null +++ b/docs/epics/progress.json @@ -0,0 +1,21 @@ +{ + "active": 13, + "completed": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ], + "current_task": "", + "done_tasks": [], + "next_tasks": [], + "deferred": [] +} \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..2724fd71 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,75 @@ +--- +name: "Correx Documentation" +description: "Home of the Correx deterministic LLM orchestration runtime" +depth: 1 +links: + - "./architecture/overview.md" + - "./design/spec-v0.1.md" + - "./decisions/adr-0000-invariants.md" + - "./modules/core-module-spec.md" + - "./epics/epics.md" +--- + +# Correx Documentation + +Correx is a **local‑first, event‑sourced orchestration kernel** for structured LLM workflows. +It wraps probabilistic language models in a deterministic shell: models propose, the harness validates, approves, and records everything as an immutable event log. + +## Navigate by Section + +### Architecture +- [System Overview](./architecture/overview.md) +- [Event Model](./architecture/event-model.md) +- [Replay Model](./architecture/replay-model.md) +- [Context Layers](./architecture/context-layers.md) +- [Security Boundaries](./architecture/security-boundaries.md) + +### Design Sketches +- [Spec v0.1](./design/spec-v0.1.md) +- [Design Document v0.1](./design/des-doc-v0.1.md) +- [Missing Pieces & Language/Stack](./design/lang-framewok-missing-pieces.md) +- [Potential Future Semantic Rules](./design/funure-semantic-rules.md) +- [Project Structure](./design/structure.md) + +### Architecture Decision Records +- [ADR‑0000: Invariants](./decisions/adr-0000-invariants.md) +- [ADR‑0001: Event Sourcing](./decisions/adr-0001-event-sourcing.md) +- [ADR‑0002: Kotlin Choice](./decisions/adr-0002-kotlin-choice.md) +- [ADR‑0003: Compression Strategy](./decisions/adr-0003-compression-strategy.md) +- [ADR‑0004: Approval Tier Design](./decisions/adr-0004-approval-tier-design.md) +- [ADR‑0005: Persistence Choice](./decisions/adr-0005-persistence-choice.md) +- [ADR‑0006: Event Store Append‑Only](./decisions/adr-0006-event-store-append-only.md) +- [ADR‑0007: Projection & Replay Determinism](./decisions/adr-0007-projection-replay.md) +- [ADR‑0008: Contract Testing Enforcement](./decisions/adr-0008-contract-enforcment-with-tests.md) +- [ADR‑0009: Replay Engine & Projection Separation](./decisions/adr-0009-replay-engine-projection-separation.md) + +### Module Specifications +- [Core Module (overview)](./modules/core-module-spec.md) +- [Events (`:core:events`)](./modules/core-events-submodule-spec.md) +- [Sessions (`:core:sessions`)](./modules/core-sessions-submodule-spec.md) +- [Transitions (`:core:transitions`)](./modules/core-transitions-submodule-spec.md) +- [Validation (`:core:validation`)](./modules/core-validation-submodule-spec.md) +- [Approvals (`:core:approvals`)](./modules/core-approvals-submodule-spec.md) +- [Artifacts (`:core:artifacts`)](./modules/core-artifacts-submodule-spec.md) +- [Context (`:core:context`)](./modules/core-context-submodule-spec.md) +- [Tools (`:core:tools`)](./modules/core-tools-submodule-spec.md) +- [Inference (`:core:inference`)](./modules/core-inference-submodule-spec.md) +- [Orchestration (`:core:orchestration`)](./modules/core-orchestration-submodule-spec.md) +- [Module Organisation Guide](modules/modules-and-spec.md) + +### Epics (Build Plan & Resolutions) +- [Epic Overview & Ordering](./epics/epics.md) +- [Epic 1: Event System](./epics/epic-1.md) +- [Epic 1 Resolution](./epics/epic-1-resolution.md) +- [Epic 1.5: Store Hardening](./epics/epic-1.5.md) +- [Epic 1.5 Resolution](./epics/epic-1.5-resolution.md) +- [Epic 2: Session Lifecycle](./epics/epic-2.md) +- [Epic 2 Resolution](./epics/epic-2-resolution.md) +- [Epic 3: Transition Engine](./epics/epic-3.md) +- [Epic 3 Resolution](./epics/epic-3-resolution.md) +- [Epic 4: Validation Pipeline](./epics/epic-4.md) +- [Epic 4 Resolution](./epics/epic-4-resolution.md) + +--- + +*Every `.md` file in this documentation carries YAML front‑matter with a `name`, `description`, `depth`, and explicit `links` for machine‑readable navigation.* \ No newline at end of file diff --git a/docs/modules/core-approvals-submodule-spec.md b/docs/modules/core-approvals-submodule-spec.md new file mode 100644 index 00000000..4f90f7cf --- /dev/null +++ b/docs/modules/core-approvals-submodule-spec.md @@ -0,0 +1,234 @@ +--- +name: "Core Approvals Submodule Spec" +description: "Specification for :core:approvals – tiered approval system" +depth: 2 +links: ["../index.md", "../decisions/adr-0004-approval-tier-design.md", "./core-validation-submodule-spec.md"] +--- + +# :core:approvals module specification + +version: 0.1-draft +status: foundational specification + +--- + +# 1. purpose + +`:core:approvals` defines the risk-aware approval system that decides whether operations beyond pre‑configured thresholds may proceed. + +It is the authoritative subsystem for: + +* approval tier semantics +* approval mode enforcement +* approval request/decision lifecycle +* escalation rules +* steering injection +* approval history (immutable audit trail) + +Approvals are the primary human‑in‑the‑loop safety mechanism. They are not a simple pop‑up; they gate execution by checking tiers and policies, and they record every decision. + +--- + +# 2. responsibilities + +`:core:approvals` owns: + +* approval tier definitions (T0–T4) +* approval mode configurations (prompt, auto, deny, yolo) +* approval event contracts +* escalation rules +* session‑scope approval grants +* steering‑aware approval injection +* approval part of validation pipeline integration +* immutable approval ledger + +--- + +# 3. non-responsibilities + +`:core:approvals` MUST NOT own: + +* the validation pipeline itself (that's `:core:validation`) +* artifact generation +* transition execution +* policy enforcement (policies may be evaluated, but the engine is separate) +* UI rendering of approval prompts +* persistence implementations + +--- + +# 4. architectural role + +`:core:approvals` acts as: + +* human/system checkpoint authority +* risk boundary for mutable operations +* audit trail for sensitive decisions + +All tool or stage escalation that exceeds the configured auto‑approved tier passes through this subsystem. + +--- + +# 5. design principles + +## 5.1 tiers enforce risk boundaries + +Every tool and operation has a declared tier. By default, only T0 and T1 may be auto‑approved. Higher tiers require explicit human approval unless the session is in an elevated mode. + +## 5.2 approvals are immutable + +An approval decision, once recorded, cannot be changed. Corrections require a new decision that supersedes the previous one within the audit log. + +## 5.3 steering is first‑class + +Approval decisions may include steering instructions: natural language suggestions injected into the next context pack. This bridges human guidance into the execution loop without breaking replay. + +## 5.4 replay-safe + +Approval events are replayed deterministically. In replay, approval decisions are re‑applied from history, not re‑requested from the user. + +--- + +# 6. tier model + +``` +T0 – inference only (read model outputs) +T1 – read‑only tool access +T2 – reversible mutation (e.g., git commit, file create) +T3 – external/network access +T4 – destructive (e.g., rm -rf, database drop) +``` + +Each tool, stage, or even artifact category may declare a tier requirement. + +--- + +# 7. approval modes + +Mode can be changed per session or per approval gate. + +* **prompt** – always ask user +* **auto** – approve automatically if tier ≤ configured threshold +* **deny** – automatically reject +* **yolo** – bypass all checks (logging only); for sandboxed experimentation + +--- + +# 8. approval request lifecycle + +1. An artifact or tool invocation triggers an `ApprovalRequired` event from the validation pipeline. +2. `:core:approvals` evaluates: + - tier vs. current session mode + - any explicit denials + - session‑scoped grants (e.g., “auto‑approve all T2 for this session”) +3. If decision can be made automatically, emit `ApprovalGranted/Automatic`. +4. If human input needed, pause orchestration and emit `ApprovalPending`. +5. User supplies decision (approve/reject/steer). Decision is recorded, orchestration resumes. + +--- + +# 9. steering injection + +When user approves with a steering message, the approval event carries that text. The context processor later picks it up and injects it as additional instruction for the next stage, with proper delimiting (e.g., “User guidance: …”). + +--- + +# 10. event ownership + +`:core:approvals` owns: + +* `ApprovalRequired` +* `ApprovalPending` +* `ApprovalGranted` / `ApprovalGrantedAuto` +* `ApprovalRejected` +* `ApprovalEscalated` +* `ApprovalSessionGrantAdded` +* `ApprovalModeChanged` + +All carry causation back to the originating artifact or tool event. + +--- + +# 11. consumed events + +Consumes: + +* `ArtifactValidated` (when approval layer decides escalation) +* `ToolAboutToExecute` (for tool-level approvals) +* `UserInput` (approval decision) + +--- + +# 12. invariants + +* Approval history is append-only. +* No operation above its tier may execute without a matching `ApprovalGranted` event. +* Session grants are ephemeral and must be replay‑reconstructed from events. +* Steering text is preserved verbatim. + +--- + +# 13. replay semantics + +During replay, `:core:approvals` replays decisions from the event log. No user interaction occurs. If a decision is missing (e.g., corrupted log), replay fails explicitly. + +--- + +# 14. threading/concurrency + +Approval handling is sequential within a session. The orchestration coroutine suspends until a decision is available. Timeout mechanisms exist for approval expiration. + +--- + +# 15. failure semantics + +If an approval request times out or the user refuses, the corresponding artifact or tool execution is rejected, and a failure event is emitted. The session may transition to a recovery or failed state according to transition rules. + +--- + +# 16. observable requirements + +Must expose: + +* approval request frequency +* approval response latency +* rejection rates per tier +* active session grants + +--- + +# 17. security boundaries + +Approvals are a trust boundary. Untrusted plugins cannot circumvent approval checks. The approval engine runs inside core with no external dependencies. + +--- + +# 18. extension model + +Plugins cannot alter tier definitions but may add custom escalation logic or additional constraints (e.g., “never approve network access after 10pm”). + +--- + +# 19. forbidden patterns + +* silent escalation without event +* mutable approval records +* approval decisions that bypass tier checks +* replay that re‑prompts the user +* granting broad permissions without session scope + +--- + +# 20. testing requirements + +* tier enforcement across modes +* auto‑approve/deny logic +* steering injection hygiene +* replay of approval sequences +* timeout and cancellation behavior + +--- + +# 21. philosophy + +Approvals translate bounded autonomy into practice. They are the harness’s way of saying “you may go this far without me, but not further”, with a clear audit trail and the ability to inject human judgment exactly where it’s needed. \ No newline at end of file diff --git a/docs/modules/core-artifacts-submodule-spec.md b/docs/modules/core-artifacts-submodule-spec.md new file mode 100644 index 00000000..59f366a6 --- /dev/null +++ b/docs/modules/core-artifacts-submodule-spec.md @@ -0,0 +1,489 @@ +--- +name: "Core Artifacts Submodule Spec" +description: "Specification for :core:artifacts – structured outputs with lineage" +depth: 2 +links: ["../index.md", "./core-module-spec.md", "./core-context-submodule-spec.md"] +--- + +# :core:artifacts module specification + +version: 0.1-draft +status: foundational specification + +--- + +# 1. purpose + +`:core:artifacts` defines the canonical structured output model for correx. + +It is the authoritative subsystem for: + +* artifact contracts +* artifact lifecycle semantics +* schema ownership +* lineage tracking +* artifact immutability +* artifact validation boundaries +* artifact serialization contracts + +Artifacts represent: + +* structured outputs produced during orchestration +* machine-validated workflow state contributions +* replayable execution products + +Artifacts are the primary mechanism through which models contribute semantic work to the harness. + +--- + +# 2. responsibilities + +`:core:artifacts` owns: + +* base artifact contracts +* artifact schema contracts +* artifact lifecycle semantics +* lineage semantics +* artifact identity semantics +* immutability guarantees +* serialization contracts +* artifact metadata contracts +* artifact relationship semantics +* artifact provenance semantics + +--- + +# 3. non-responsibilities + +`:core:artifacts` MUST NOT own: + +* artifact persistence implementations +* semantic validation logic +* transition evaluation +* orchestration progression +* model execution +* tool execution +* UI rendering +* websocket serialization transport + +Artifact legality is validated externally. + +--- + +# 4. architectural role + +`:core:artifacts` acts as: + +* structured semantic output authority +* workflow data contract authority +* lineage authority +* execution provenance authority + +All meaningful workflow progression MUST operate on artifacts rather than freeform model text. + +--- + +# 5. design principles + +## 5.1 artifacts are immutable + +Artifacts MUST NEVER be mutated after creation. + +Corrections MUST produce: + +* new artifacts +* superseding relationships +* compensating events + +--- + +## 5.2 artifacts are structured + +Artifacts MUST conform to explicit schemas. + +Freeform orchestration state is forbidden. + +--- + +## 5.3 artifacts are replayable + +Artifacts MUST remain: + +* serializable +* deterministic +* reconstructable +* versioned + +Replay MUST reproduce identical artifact history. + +--- + +## 5.4 artifacts are proposals + +Artifacts represent proposed semantic state. + +Artifacts gain workflow authority only after: + +* validation +* approvals +* transition acceptance + +--- + +# 6. artifact model + +## base artifact contract + +```kotlin id="zskldn" +sealed interface Artifact { + val id: ArtifactId + val sessionId: SessionId + val stageId: StageId + val schemaVersion: Int + val createdAt: Instant + val lineage: ArtifactLineage + val metadata: ArtifactMetadata +} +``` + +--- + +# 7. artifact identity semantics + +Artifact identifiers MUST be: + +* globally unique +* immutable +* replay-stable + +Artifact identity MUST NOT depend on: + +* provider internals +* runtime memory +* mutable projections + +--- + +# 8. artifact lifecycle model + +Mandatory lifecycle phases: + +```text id="oztzsu" +CREATED +VALIDATING +VALIDATED +REJECTED +SUPERSEDED +ARCHIVED +``` + +Lifecycle changes MUST emit events. + +--- + +# 9. schema model + +Artifacts MUST conform to explicit schemas. + +Schemas MAY be: + +* built-in +* config-defined +* plugin-defined + +Recommended schema model: + +* kotlinx.serialization +* pydantic-compatible external definitions +* explicit versioning + +--- + +## schema guarantees + +Schemas MUST support: + +* deterministic validation +* version compatibility +* explicit field typing +* replay-safe deserialization + +--- + +# 10. artifact lineage model + +All artifacts MUST support lineage tracking. + +Lineage MUST include: + +* parent artifacts +* originating events +* originating stage +* tool receipts +* validation history +* approval history + +--- + +## lineage guarantees + +Lineage MUST remain: + +* immutable +* replayable +* traceable + +--- + +# 11. provenance model + +Artifacts MUST record provenance metadata for: + +* originating model +* provider +* stage runtime +* generation config +* tool interactions +* context pack references + +This metadata exists for: + +* replay +* diagnostics +* auditability +* evaluation + +--- + +# 12. artifact relationships + +Supported relationships: + +```text id="1wz8ny" +PARENT +CHILD +SUPERSEDES +DERIVED_FROM +VALIDATED_BY +APPROVED_BY +GENERATED_FROM +``` + +Relationships MUST remain append-only. + +--- + +# 13. serialization requirements + +Artifacts MUST support: + +* deterministic serialization +* schema versioning +* replay-safe decoding +* portable encoding + +Recommended: + +* kotlinx.serialization + +Forbidden: + +* provider-specific formats +* reflection-dependent serialization +* mutable serialization contracts + +--- + +# 14. validation boundaries + +`:core:artifacts` defines structure only. + +Validation responsibilities belong to: + +* `:core:validation` +* `:core:approvals` +* `:core:policies` + +Artifacts themselves MUST remain validation-agnostic. + +--- + +# 15. artifact categories + +Recommended top-level categories: + +```text id="k93gb0" +ReasoningArtifact +PatchArtifact +PlanArtifact +SummaryArtifact +CommandArtifact +AnalysisArtifact +ToolResultArtifact +ContextArtifact +ApprovalArtifact +RecoveryArtifact +``` + +Additional categories MAY be plugin-defined. + +--- + +# 16. artifact storage expectations + +Artifacts MUST remain: + +* append-only +* immutable +* replay-safe + +Storage implementations belong to infrastructure modules. + +--- + +# 17. replay guarantees + +Replay MUST reconstruct: + +* artifact history +* artifact lineage +* artifact relationships +* supersession chains +* validation history + +Replay MUST NOT require: + +* original models +* provider access +* live tool execution + +--- + +# 18. supersession semantics + +Corrections MUST occur through: + +* replacement artifacts +* supersession relationships + +Historical artifacts MUST remain preserved. + +Example: + +```text id="vgzfh7" +Artifact B + SUPERSEDES +Artifact A +``` + +Mutation-in-place is forbidden. + +--- + +# 19. observability requirements + +`:core:artifacts` MUST expose metadata hooks for: + +* lineage tracing +* artifact provenance +* supersession chains +* validation outcomes +* approval history +* schema evolution +* replay diagnostics + +--- + +# 20. security boundaries + +Artifacts are considered: + +* untrusted semantic proposals + +Artifacts MUST NOT gain execution authority directly. + +Execution authority requires: + +* validation +* policy approval +* orchestration approval + +Sensitive payload handling MUST support: + +* redaction +* isolation +* audit-safe serialization + +--- + +# 21. extension model + +Extensions MAY define: + +* custom schemas +* custom artifact categories +* custom metadata +* custom lineage relationships + +Extensions MUST NOT: + +* mutate historical artifacts +* bypass validation +* bypass replay guarantees +* introduce hidden mutable state + +--- + +# 22. forbidden patterns + +Forbidden: + +* mutable artifacts +* freeform orchestration state +* schema-less execution artifacts +* artifact-owned workflow state +* hidden lineage mutation +* in-place correction +* replay-dependent artifact interpretation + +--- + +# 23. persistence expectations + +Persistence implementations MUST support: + +* immutable storage +* lineage reconstruction +* version-safe retrieval +* replay-safe deserialization + +Persistence belongs to infrastructure modules. + +--- + +# 24. testing requirements + +`:core:artifacts` MUST support deterministic testing for: + +* schema validation +* serialization consistency +* lineage reconstruction +* supersession handling +* replay reconstruction +* version compatibility + +--- + +# 25. philosophy summary + +`:core:artifacts` exists to transform probabilistic model outputs into structured replayable workflow objects. + +Reliability emerges from: + +* immutable schemas +* explicit lineage +* deterministic serialization +* append-only history +* validation boundaries + +not from trusting raw model text directly. diff --git a/docs/modules/core-context-submodule-spec.md b/docs/modules/core-context-submodule-spec.md new file mode 100644 index 00000000..2af811f3 --- /dev/null +++ b/docs/modules/core-context-submodule-spec.md @@ -0,0 +1,251 @@ +--- +name: "Core Context Submodule Spec" +description: "Specification for :core:context – context synthesis and compression" +depth: 2 +links: ["../index.md", "../architecture/context-layers.md", "./core-artifacts-submodule-spec.md"] +--- + +# :core:context module specification + +version: 0.1-draft +status: foundational specification + +--- + +# 1. purpose + +`:core:context` is responsible for synthesizing minimal, relevant, token‑efficient context packs that are fed to models before each inference call. + +It owns: + +* context layer architecture (L0–L4) +* compression strategies +* token budgeting +* relevance ranking +* deduplication +* context pack building +* context event ownership + +Context synthesis is the primary defense against context window overflow and degradation of model performance due to irrelevant history. + +--- + +# 2. responsibilities + +`:core:context` owns: + +* context layer definitions +* compression strategy contracts +* token budget model +* relevance ranking algorithms +* deduplication logic +* context pack structure +* rebuild triggers +* context synthesis events +* router context management (separate L2 for router) +* session‑level context cap enforcement + +--- + +# 3. non-responsibilities + +`:core:context` MUST NOT own: + +* long‑term memory persistence (that’s infrastructure) +* model execution +* tool execution +* approval decisions +* transition logic +* archiving policy (archival layer L4 is defined, but storage mechanics are external) + +--- + +# 4. architectural role + +`:core:context` acts as: + +* the information bottleneck +* a stateless synthesizer that transforms raw event history into a compressed, actionable model prompt +* the owner of what the model “sees” + +--- + +# 5. design principles + +## 5.1 context is synthesized, not accumulated + +Raw events are never fed to models. They are filtered, ranked, compressed, and bounded. + +## 5.2 layers separate freshness from history + +* L0 – live execution stream (current stage inputs/outputs) +* L1 – stage‑local context (artifacts from the current stage) +* L2 – compressed session memory (summaries of completed stages) +* L3 – durable project memory (key decisions, architecture, persistent facts) +* L4 – archival history (full event log, used only for deep replay) + +Only L0–L2 are included in the context pack for a typical inference. + +## 5.3 token budgets are enforced + +Every context pack has a hard token limit. The budget is configurable per model and per stage. The context processor must trim aggressively. + +## 5.4 router context is separate + +The router has its own L2 memory, rebuilt from summaries after each stage. It never sees raw execution context unless needed for a specific user query. + +--- + +# 6. context pack model + +```kotlin +data class ContextPack( + val layers: Map>, + val budgetUsed: Int, + val budgetLimit: Int, + val compressionMetadata: CompressionMetadata +) +``` + +Context entries are structured records pointing to artifact excerpts, summaries, policy hints, and steering notes. + +--- + +# 7. synthesis pipeline + +Pipeline steps (executed in order): + +1. Retrieve relevant events from projection store (cursor‑based). +2. Deduplicate – remove repeated identical tool outputs, duplicate artifact versions. +3. Rank – score entries by relevance to current stage goals (may use a quick heuristic or a tiny model). +4. Compress – apply layer‑specific strategies (summarize tool logs, keep only latest valid artifact, semantic conversation compression). +5. Inject policies – insert active constraints relevant to the stage. +6. Tokenize and trim to budget. +7. Assemble final `ContextPack`. + +--- + +# 8. compression strategies + +Config‑driven, per data type: + +| data type | default strategy | +|-----------|------------------| +| tool output | summarize (extract errors, key values) | +| artifacts | latest valid only | +| events | deduplicate + temporal decay | +| conversation | semantic summary (L2 only) | +| approvals | retain all (small) | +| steering notes | retain verbatim | + +Semantic compression may use a dedicated small local model, but it must be deterministic‑enough (same model, same prompt → same compressed output). The harness treats compression as an inference call of its own, recorded as a `CompressionEvent`. + +--- + +# 9. token budget model + +Budgets are defined per model and per stage, with enforcement: + +* hard limit on total context pack tokens +* reservation for system prompt/policies +* dynamic overflow: if compression cannot fit within limit, oldest L2 entries are dropped first. + +--- + +# 10. session‑level token cap + +A session‑wide total token cap exists for L2 memory. When exceeded, older L2 entries are merged into L3 project memory, and L2 is truncated. This prevents silent memory bloat in long sessions. + +--- + +# 11. event ownership + +`:core:context` emits: + +* `ContextBuildingStarted` +* `ContextPackBuilt` +* `CompressionApplied` +* `LayerTruncated` + +These events carry causation from the preceding stage schedule event. + +--- + +# 12. consumed events + +Consumes: + +* `StageScheduled` (trigger) +* All domain events that feed the projection needed for context retrieval. + +--- + +# 13. replay semantics + +During replay, context compressions are either replayed from compression events (if available) or re‑executed deterministically. For inference‑skipping replay, context packs are still built to validate budget logic but not fed to models. + +--- + +# 14. threading/concurrency + +Context building is a CPU‑bound operation that runs within the orchestration coroutine. It must be cancellable and should not block the event loop for extreme histories (use batching/sampling if needed). + +--- + +# 15. failure semantics + +If context building fails (e.g., compression model unavailable), the stage cannot proceed. Event `ContextBuildingFailed` is emitted, and the transition engine may retry or fail the session. + +--- + +# 16. observability requirements + +* token usage per stage +* compression effectiveness (e.g., compression ratio) +* L2 memory size over time +* context building duration +* deduplication hit rate + +--- + +# 17. security boundaries + +Context packs may contain sensitive data. The module must support redaction/secret scrubbing before delivery to models (plugin point). No model should see raw secrets. + +--- + +# 18. extension model + +Extensions may provide: + +* custom rankers +* custom compressors +* additional context layers (if needed) + +All extensions must produce valid `ContextEntry` types and respect the token budget. + +--- + +# 19. forbidden patterns + +* raw event accumulation in context pack +* unbounded context growth +* mutable context state (context packs are ephemeral, built per stage) +* cross‑session context leaking +* hidden injection of unauthorized data + +--- + +# 20. testing requirements + +* determinism of deduction/ranking (given fixed input) +* token budget compliance +* compression quality metrics +* replay of context building +* router context isolation + +--- + +# 21. philosophy + +The context processor is the harness’s memory manager. It ensures that models never drown in their own history, and that every inference receives a sharp, purposeful slice of the past—nothing more, nothing less. \ No newline at end of file diff --git a/docs/modules/core-events-submodule-spec.md b/docs/modules/core-events-submodule-spec.md new file mode 100644 index 00000000..d8efd390 --- /dev/null +++ b/docs/modules/core-events-submodule-spec.md @@ -0,0 +1,566 @@ +--- +name: "Core Events Submodule Spec" +description: "Specification for :core:events – event sourcing backbone" +depth: 2 +links: ["../index.md", "../architecture/event-model.md", "./core-module-spec.md"] +--- + +# :core:events module specification + +version: 0.1-draft +status: foundational specification + +--- + +# 1. purpose + +`:core:events` defines the canonical event sourcing model for correx. + +It is the authoritative subsystem for: + +* immutable event contracts +* event lifecycle semantics +* causation/correlation tracking +* replay semantics +* projection contracts +* snapshot contracts +* event ordering guarantees + +`:core:events` is the foundational source of truth for all system state. + +All persistent workflow state MUST originate from events defined by this module. + +--- + +# 2. responsibilities + +`:core:events` owns: + +* base event contracts +* event category contracts +* event metadata semantics +* event ordering semantics +* replay contracts +* projection contracts +* snapshot contracts +* event versioning semantics +* causation/correlation semantics +* append-only guarantees +* event serialization contracts + +--- + +# 3. non-responsibilities + +`:core:events` MUST NOT own: + +* database implementations +* sqlite/postgres access +* projection persistence +* websocket/event streaming transport +* telemetry exporters +* UI event rendering +* provider-specific events +* business workflow orchestration + +Implementations belong to infrastructure modules. + +--- + +# 4. architectural role + +`:core:events` acts as: + +* canonical state authority +* replay foundation +* audit foundation +* projection foundation +* execution history authority + +All mutable runtime state MUST ultimately derive from events. + +--- + +# 5. design principles + +## 5.1 events are truth + +Events are the only authoritative persistent state. + +Everything else is derived. + +--- + +## 5.2 append-only model + +Events are immutable. + +Existing events MUST NEVER be: + +* modified +* deleted +* reordered +* rewritten + +Corrections occur through compensating events only. + +--- + +## 5.3 replay-first architecture + +All workflows MUST be reconstructable exclusively from: + +* event streams +* snapshots +* configs + +Replayability is a mandatory architectural guarantee. + +--- + +## 5.4 deterministic reconstruction + +Given: + +* identical events +* identical configs +* identical replay strategy + +projection rebuild MUST produce identical results. + +--- + +# 6. event model + +## base event contract + +All events MUST contain: + +```kotlin +sealed interface Event { + val id: EventId + val sessionId: SessionId + val timestamp: Instant + val type: EventType + val payload: EventPayload + val causationId: EventId? + val correlationId: CorrelationId? + val sequence: Long + val version: Int +} +``` + +--- + +## field semantics + +### id + +Globally unique immutable identifier. + +--- + +### sessionId + +Logical execution session ownership. + +--- + +### timestamp + +Creation time only. + +Never mutated during replay. + +--- + +### causationId + +Direct parent event. + +Represents: +"what caused this event to exist?" + +--- + +### correlationId + +Shared execution lineage identifier. + +Used for: + +* tracing +* replay grouping +* workflow reconstruction +* observability + +--- + +### sequence + +Strict append ordering within session scope. + +Sequence gaps are forbidden. + +--- + +### version + +Schema evolution support. + +--- + +# 7. event categories + +Mandatory top-level categories: + +```text +DomainEvents +LifecycleEvents +InferenceEvents +ToolEvents +ValidationEvents +ApprovalEvents +CompressionEvents +TransitionEvents +ProjectionEvents +SystemEvents +``` + +--- + +# 8. canonical event flow + +Minimum execution flow: + +```text +UserInputReceived + ↓ +SessionCreated + ↓ +StageScheduled + ↓ +ContextBuilt + ↓ +InferenceStarted + ↓ +ArtifactProduced + ↓ +ArtifactValidated + ↓ +ApprovalRequested + ↓ +TransitionExecuted + ↓ +SessionCompleted +``` + +Real workflows may branch but MUST remain replayable. + +--- + +# 9. ordering guarantees + +## required guarantees + +Within a session: + +* ordering MUST be deterministic +* append order MUST be preserved +* replay order MUST match append order + +--- + +## forbidden behavior + +Forbidden: + +* out-of-order mutation +* concurrent sequence conflicts +* nondeterministic replay ordering + +--- + +# 10. event ownership rules + +Every event MUST have exactly one: + +* producer +* ownership boundary + +Example: + +```text +ToolEvents + owned by :core:tools + +ApprovalEvents + owned by :core:approvals +``` + +Cross-module event mutation is forbidden. + +--- + +# 11. replay model + +Supported replay modes: + +* full replay +* replay from cursor +* replay until condition +* deterministic simulation +* projection-only replay +* inference-skipping replay + +Replay MUST function without: + +* live models +* live tools +* provider access + +--- + +# 12. projection model + +## projection definition + +Projection: +deterministic derived state built from events. + +--- + +## projection guarantees + +Projections MUST be: + +* disposable +* rebuildable +* deterministic +* side-effect free + +--- + +## projection restrictions + +Projections MUST NOT: + +* mutate events +* emit side effects +* perform orchestration +* own workflow authority + +--- + +# 13. snapshot model + +Snapshots exist only to optimize replay. + +Snapshots are: + +* optimization artifacts +* rebuildable +* disposable + +Snapshots MUST NEVER become authoritative state. + +--- + +## snapshot guarantees + +Snapshots MUST contain: + +* originating event sequence +* projection version +* snapshot timestamp + +--- + +# 14. versioning model + +Event schemas MUST support forward evolution. + +Rules: + +* old events remain replayable +* incompatible mutations forbidden +* event meaning immutable after release + +Schema migrations MUST occur through: + +* versioned deserialization +* compensating events +* projection migration logic + +Never through historical mutation. + +--- + +# 15. serialization requirements + +Events MUST support deterministic serialization. + +Requirements: + +* stable field ordering +* explicit schema versions +* portable encoding +* replay-safe decoding + +Recommended: + +* kotlinx.serialization + +Forbidden: + +* reflection-dependent serialization +* provider-specific encoding + +--- + +# 16. concurrency model + +Event append semantics MUST remain: + +* atomic +* ordered +* idempotent + +Concurrent appends MUST NOT create: + +* duplicate sequence numbers +* replay ambiguity +* partial visibility + +--- + +# 17. idempotency guarantees + +Replay MUST be idempotent. + +Reapplying identical events MUST produce: + +* identical projections +* identical state transitions + +Duplicate event processing MUST be detectable. + +--- + +# 18. observability requirements + +`:core:events` MUST expose tracing metadata for: + +* causation chains +* correlation chains +* replay timelines +* projection rebuild timing +* event append latency +* snapshot timing + +--- + +# 19. failure semantics + +Event append failures MUST: + +* fail atomically +* emit structured failures +* never partially commit + +Projection rebuild failures MUST: + +* preserve original events +* isolate projection corruption +* support rebuild retries + +--- + +# 20. security boundaries + +Events are trusted as historical records but not as semantic truth. + +Semantic correctness MUST still be validated externally. + +Sensitive payload handling MUST support: + +* redaction policies +* secret isolation +* audit-safe serialization + +--- + +# 21. extension model + +Extensions MAY: + +* introduce new event types +* introduce projections +* introduce replay consumers + +Extensions MUST NOT: + +* mutate existing events +* rewrite history +* bypass append ordering +* bypass replay semantics + +--- + +# 22. forbidden patterns + +Forbidden: + +* mutable events +* in-place updates +* hidden side-channel state +* projection-owned truth +* replay-dependent side effects +* event deletion +* unordered append semantics +* timestamp-based replay ordering + +--- + +# 23. persistence expectations + +Persistence implementations MUST support: + +* append-only writes +* ordered reads +* replay scans +* snapshot storage +* cursor-based replay +* optimistic concurrency + +Storage engines are infrastructure concerns. + +--- + +# 24. testing requirements + +`:core:events` MUST support deterministic testing for: + +* replay correctness +* ordering guarantees +* idempotency +* snapshot rebuild +* projection rebuild +* sequence integrity +* version compatibility + +--- + +# 25. philosophy summary + +`:core:events` exists to externalize all workflow state into immutable observable history. + +Reliability emerges from: + +* append-only history +* deterministic replay +* rebuildable projections +* explicit causality +* immutable execution lineage + +not from mutable runtime memory. diff --git a/docs/modules/core-inference-submodule-spec.md b/docs/modules/core-inference-submodule-spec.md new file mode 100644 index 00000000..c49c04da --- /dev/null +++ b/docs/modules/core-inference-submodule-spec.md @@ -0,0 +1,234 @@ +--- +name: "Core Inference Submodule Spec" +description: "Specification for :core:inference – model provider abstraction" +depth: 2 +links: ["../index.md", "./core-module-spec.md", "./core-context-submodule-spec.md"] +--- + +# :core:inference module specification + +version: 0.1-draft +status: foundational specification + +--- + +# 1. purpose + +`:core:inference` abstracts model execution behind a stable contract. It owns: + +* inference provider interfaces +* inference request/response model +* model capability model +* model lifecycle management contracts +* GPU residency hints +* inference isolation +* retry/detokenization handling + +Models are treated as stateless compute engines; this module ensures they are interchangeable, locally or remotely, without affecting orchestration. + +--- + +# 2. responsibilities + +`:core:inference` owns: + +* `InferenceProvider` contract +* model capability definitions +* inference request schema (context pack + generation config) +* inference response schema (raw text + finish reason) +* provider registry interface +* model scheduling (which model for which stage) +* inference event definitions +* token accounting (prompt/completion tokens) +* timeout and cancellation contracts + +--- + +# 3. non-responsibilities + +`:core:inference` MUST NOT own: + +* actual provider implementations (llama.cpp, ollama, etc.) +* context pack construction +* artifact parsing (that's orchestration/validation) +* model weight management (infrastructure handles storage) +* UI rendering of inference progress + +--- + +# 4. architectural role + +`:core:inference` acts as: + +* the only point of contact for models +* the provider abstraction layer +* the enforcer of concurrency limits and GPU residency policies + +--- + +# 5. design principles + +## 5.1 models are stateless + +Every inference call receives a full context pack. No state is retained on the model side between calls. + +## 5.2 capability‑based routing + +Stages request capabilities (e.g., coding, tool_calling). The inference module selects the best available model from the registry, considering local GPU budget and fallback providers. + +## 5.3 deterministic‑enough configuration + +Inference parameters (temperature, top_p, etc.) are part of the stage config and must be reproduced identically for replay. + +## 5.4 isolation + +Inference runs in a separate coroutine with strict time limits. Models may be forcibly unloaded after a swap timeout. + +--- + +# 6. model provider contract + +```kotlin +interface InferenceProvider { + val name: String + suspend fun infer(request: InferenceRequest): InferenceResponse + suspend fun healthCheck(): ProviderHealth + fun capabilities(): Set + fun tokenize(text: String): List +} +``` + +--- + +# 7. inference request/response + +```kotlin +data class InferenceRequest( + val contextPack: ContextPack, + val generationConfig: GenerationConfig, + val stageId: StageId, + val sessionId: SessionId +) + +data class InferenceResponse( + val text: String, + val finishReason: FinishReason, + val tokensUsed: TokenUsage, + val latencyMs: Long +) +``` + +--- + +# 8. capability negotiation + +Models declare capabilities with optional scores. The inference module uses a configurable strategy (e.g., highest score first, then fallback to remote if local below threshold) to select the model for a stage. + +--- + +# 9. model lifecycle management + +Inference module defines contracts for: + +* loading a model (provider specific) +* unloading +* health checks +* swap scheduling (ephemeral, dynamic, persistent residency modes) + +It does not implement these; it exposes interfaces implemented by infrastructure providers. + +--- + +# 10. inference events + +`:core:inference` emits: + +* `InferenceStarted` +* `InferenceCompleted` +* `InferenceFailed` +* `InferenceTimeout` +* `ModelLoaded` / `ModelUnloaded` + +All carry causation back to the stage scheduling event. + +--- + +# 11. consumed events + +Consumes: + +* `StageScheduled` (to know which capability to invoke) +* `ContextPackBuilt` (to receive the actual pack) +* system resource events (GPU availability) + +--- + +# 12. invariants + +* Every inference call is idempotent from the harness perspective (same context → same response… eventually, but replay uses recorded artifacts). +* Token usage always tracked. +* No cross‑session model state leakage. + +--- + +# 13. replay semantics + +During replay, inference can be skipped. Recorded `InferenceCompleted` events containing the artifact text are used instead. If inference is re‑played (e.g., for evaluation), the same config and context pack must be used. + +--- + +# 14. threading/concurrency + +Inference calls are suspending and may run on a dedicated dispatcher. Concurrency is limited by model-level `max_parallel_sessions`; the inference module enforces this limit using semaphores. + +--- + +# 15. failure semantics + +Inference failures (timeout, model crash) emit `InferenceFailed` with a typed reason. The transition engine may then retry with a different provider or fail the stage. + +--- + +# 16. observable requirements + +* inference latency +* token consumption +* model residency time +* fallback event count +* GPU utilization (via provider) + +--- + +# 17. security boundaries + +Models are untrusted. Inference runs sandboxed: no filesystem access, no network access unless explicitly configured. Artifacts are treated as untrusted text. + +--- + +# 18. extension model + +New providers are added by implementing `InferenceProvider` and registering with the provider registry. Plugins cannot alter the core inference contract. + +--- + +# 19. forbidden patterns + +* direct model state reuse between calls +* capability bypass (using a model directly without capability check) +* hidden fallback that circumvents audit +* inference without context pack + +--- + +# 20. testing requirements + +* mock provider for deterministic tests +* capability routing logic +* cancel/timeout behavior +* replay with recorded artifacts + +--- + +# 21. philosophy + +Inference is a utility, not a partner. This module treats language models like an accelerator card: it submits a well‑formed job, waits for a result, and never assumes the result is anything but a candidate. \ No newline at end of file diff --git a/docs/modules/core-module-spec.md b/docs/modules/core-module-spec.md new file mode 100644 index 00000000..6ea681e8 --- /dev/null +++ b/docs/modules/core-module-spec.md @@ -0,0 +1,521 @@ +--- +name: "Core Module Spec" +description: "Overall :core module responsibilities and boundaries" +depth: 2 +links: ["../index.md", "../architecture/overview.md", "./core-events-submodule-spec.md", "./core-sessions-submodule-spec.md"] +--- + +# :core module specification + +version: 0.1-draft +status: foundational specification + +--- + +# 1. purpose + +`:core` is the deterministic orchestration and domain foundation of correx. + +It defines: + +* domain contracts +* orchestration primitives +* workflow semantics +* event ownership rules +* validation boundaries +* execution abstractions +* replay guarantees + +`:core` contains the canonical execution model of the system. + +It is the authoritative source of: + +* workflow state semantics +* event semantics +* transition semantics +* artifact semantics +* approval semantics + +`:core` MUST remain infrastructure-independent. + +--- + +# 2. responsibilities + +`:core` owns: + +* orchestration contracts +* event contracts +* transition semantics +* session lifecycle semantics +* artifact contracts +* validation contracts +* context synthesis contracts +* approval semantics +* policy contracts +* tool contracts +* inference contracts +* replay semantics +* deterministic workflow behavior + +`:core` defines: + +* what may happen +* what is valid +* what transitions are legal +* what state means + +--- + +# 3. non-responsibilities + +`:core` MUST NOT own: + +* persistence implementation +* sqlite/postgres integration +* websocket transport +* REST APIs +* shell execution +* filesystem access +* model process management +* llama.cpp integration +* provider-specific logic +* frontend/UI concerns +* CLI rendering +* telemetry exporters + +`:core` defines contracts only. + +Implementations belong to infrastructure modules. + +--- + +# 4. architectural role + +`:core` acts as: + +* deterministic orchestration kernel +* domain model authority +* replay authority +* execution policy authority + +All external systems interact with correx through contracts defined by `:core`. + +--- + +# 5. submodules + +## mandatory submodules + +```text +:core:events +:core:context +:core:validation +:core:transitions +:core:orchestration +:core:artifacts +:core:sessions +:core:approvals +:core:policies +:core:tools +:core:inference +``` + +--- + +# 6. architectural principles + +## 6.1 event sourcing mandatory + +All state MUST be reconstructable from immutable events. + +Projections are disposable derived state. + +Events are the sole source of truth. + +--- + +## 6.2 append-only semantics + +The following are immutable: + +* events +* artifacts +* approvals +* tool receipts +* summaries + +Mutation occurs only through new events. + +--- + +## 6.3 deterministic orchestration + +`:core` MUST behave deterministically given: + +* identical event stream +* identical config +* identical transition graph + +Inference nondeterminism MUST remain externalized. + +--- + +## 6.4 infrastructure independence + +`:core` MUST NOT depend on: + +* infrastructure modules +* interfaces modules +* apps modules + +Dependency direction is strictly inward. + +--- + +## 6.5 explicit state ownership + +Every state transition MUST have: + +* originating event +* causation id +* correlation id + +Hidden mutable state is forbidden. + +--- + +# 7. dependency rules + +## allowed dependencies + +`:core:*` modules MAY depend on: + +* kotlin stdlib +* kotlinx.coroutines +* kotlinx.serialization +* other lower-level `:core:*` modules + +--- + +## forbidden dependencies + +`:core:*` modules MUST NEVER depend on: + +* `:infrastructure:*` +* `:interfaces:*` +* `:apps:*` +* frontend code +* provider implementations + +--- + +# 8. threading and concurrency model + +`:core` uses structured concurrency exclusively. + +Requirements: + +* coroutine-based execution +* explicit cancellation propagation +* bounded execution scopes +* deterministic lifecycle ownership + +Forbidden: + +* global mutable state +* unmanaged thread pools +* detached background tasks + +--- + +# 9. state model + +## authoritative state + +Authoritative state exists only as: + +* immutable event streams + +--- + +## derived state + +Derived state exists as: + +* projections +* summaries +* context packs +* metrics + +Derived state MUST be rebuildable. + +--- + +# 10. replay guarantees + +`:core` MUST support: + +* full replay +* replay from cursor +* inference-skipping replay +* deterministic projection rebuild +* transition tracing + +Replay MUST NOT require: + +* original model availability +* original tool availability +* external provider access + +--- + +# 11. orchestration guarantees + +`:core` guarantees: + +* explicit workflow transitions +* bounded retries +* approval-aware execution +* validation-first progression +* deterministic transition evaluation + +`:core` MUST reject: + +* invalid transitions +* invalid artifacts +* policy violations +* unauthorized escalations + +--- + +# 12. validation guarantees + +No artifact may advance workflow state unless: + +1. routing validation passes +2. schema validation passes +3. semantic validation passes +4. approval validation passes + +Validation failures MUST emit events. + +--- + +# 13. approval guarantees + +All risky operations MUST be classified by approval tier. + +Approval semantics MUST remain: + +* explicit +* replayable +* auditable +* append-only + +Approval bypasses MUST emit events. + +--- + +# 14. tool guarantees + +`:core` defines: + +* tool contracts +* capability contracts +* receipt contracts +* isolation expectations + +Tools MUST NOT mutate workflow state directly. + +All side effects MUST be represented through receipts and events. + +--- + +# 15. inference guarantees + +Models are treated as: + +* stateless semantic processors + +Models MUST NOT own: + +* memory +* permissions +* workflow state +* transition authority + +Inference outputs are proposals only. + +Harness validation determines legality. + +--- + +# 16. context guarantees + +Raw context accumulation is forbidden. + +All context MUST be: + +* filtered +* deduplicated +* compressed +* relevance-ranked +* token-budgeted + +Context packs are ephemeral synthesized views. + +--- + +# 17. observability requirements + +`:core` MUST expose structured observability hooks for: + +* event tracing +* transition tracing +* replay diagnostics +* token accounting +* stage timing +* approval history +* artifact lineage + +--- + +# 18. security boundaries + +`:core` defines trust boundaries for: + +* models +* tools +* providers +* plugins +* user steering +* remote execution + +`:core` assumes: + +* models may hallucinate +* tools may fail +* providers may become unavailable +* plugins may be untrusted + +Validation and approvals are mandatory security boundaries. + +--- + +# 19. extension model + +`:core` MUST support extension through contracts/interfaces only. + +Extension points include: + +* validators +* compressors +* providers +* tools +* transition conditions +* policies + +Extensions MUST NOT bypass: + +* validation pipeline +* approval system +* event sourcing + +--- + +# 20. persistence expectations + +`:core` defines persistence contracts but not implementations. + +Persistence layer MUST support: + +* append-only event storage +* snapshot storage +* projection rebuild +* artifact storage +* approval audit history + +--- + +# 21. lifecycle expectations + +All runtime components MUST have explicit lifecycle ownership. + +Required lifecycle semantics: + +* initialization +* active execution +* cancellation +* teardown +* recovery + +Zombie execution is forbidden. + +--- + +# 22. failure semantics + +Failures MUST be explicit and evented. + +No silent recovery allowed. + +Required failure categories: + +* validation failure +* transition failure +* inference failure +* tool failure +* policy failure +* provider failure +* replay failure + +All failures MUST emit structured events. + +--- + +# 23. plugin boundaries + +Plugins interact with correx only through: + +* stable contracts +* DTOs +* extension interfaces + +Plugins MUST NOT: + +* mutate internal state directly +* bypass orchestration +* access projections unsafely + +--- + +# 24. anti-goals + +`:core` intentionally avoids: + +* hidden memory +* implicit orchestration +* unrestricted autonomy +* mutable projections +* provider-specific logic +* agent personalities +* recursive uncontrolled execution +* conversationally-driven state mutation + +--- + +# 25. philosophy summary + +`:core` exists to provide deterministic orchestration around probabilistic cognition. + +Reliability emerges from: + +* event sourcing +* validation +* replayability +* constrained execution +* explicit approvals +* synthesized context + +not from trusting model reasoning itself. diff --git a/docs/modules/core-orchestration-submodule-spec.md b/docs/modules/core-orchestration-submodule-spec.md new file mode 100644 index 00000000..656b696c --- /dev/null +++ b/docs/modules/core-orchestration-submodule-spec.md @@ -0,0 +1,227 @@ +--- +name: "Core Orchestration Submodule Spec" +description: "Specification for :core:orchestration – execution kernel" +depth: 2 +links: ["../index.md", "./core-module-spec.md", "./core-sessions-submodule-spec.md", "./core-transitions-submodule-spec.md"] +--- + +# :core:orchestration module specification + +version: 0.1-draft +status: foundational specification + +--- + +# 1. purpose + +`:core:orchestration` is the execution kernel of correx. It coordinates the entire workflow lifecycle by composing: + +* session management +* stage scheduling +* context building +* inference execution +* validation +* approvals +* transitions + +It is the "brain" that wires together all other core modules and ensures work progresses deterministically according to the configured graph. + +--- + +# 2. responsibilities + +`:core:orchestration` owns: + +* workflow execution loop +* stage scheduling and sequencing +* orchestration state machine (separate from session state) +* retry coordination +* cancellation propagation +* concurrency control within a session +* orchestration event aggregation (orchestration-level events) +* replay orchestration (decides which components to re-invoke) +* service‑level observability (the "big picture" metrics) + +--- + +# 3. non-responsibilities + +`:core:orchestration` MUST NOT own: + +* domain logic (that’s in events, transitions, validation, etc.) +* persistence +* inference provider implementation +* user interaction (router handles that) +* plugin execution +* UI updates + +It coordinates; it does not implement low‑level mechanics. + +--- + +# 4. architectural role + +`:core:orchestration` acts as: + +* the top‑level orchestrator +* the only component that directly calls other core modules in a defined sequence +* the guardian of workflow integrity + +It is the closest thing to a “main loop” of the harness. + +--- + +# 5. design principles + +## 5.1 stateless orchestration + +The orchestrator itself holds no persistent state. All state lives in events and projections. The orchestrator loads current projection at start, executes steps, emits events, and exits. + +## 5.2 deterministic coordination + +Given the same initial projection and the same event history, the orchestration sequence (which stage to run next, when to retry) is fully deterministic. + +## 5.3 explicit composition + +Every step (context, inference, validation, transition) is called explicitly and synchronously within a coroutine scope. No hidden side‑effects. + +## 5.4 cancellation ownership + +The orchestrator owns the cancellation token for a session and ensures it propagates to all child jobs (inference, tools, etc.). + +--- + +# 6. execution loop + +``` +while session is active: + determine next stage (from transition engine + current projection) + build context pack + schedule inference + wait for artifact + validate artifact + if approval needed: wait for decision + evaluate transition rules + emit TransitionExecuted event + update projection +``` + +Each iteration is a transaction in the event stream. + +--- + +# 7. orchestration events + +`:core:orchestration` emits high‑level events: + +* `WorkflowStarted` +* `WorkflowCompleted` +* `WorkflowFailed` +* `OrchestrationPaused` +* `OrchestrationResumed` +* `RetryAttempted` + +It does not own domain events but links them via correlation. + +--- + +# 8. consumed events + +Orchestration listens to all domain events through a projection, but specifically reacts to: + +* `SessionCreated` (trigger start) +* `UserInput` (steering, approvals) +* `ArtifactValidated` / `ArtifactRejected` +* `ApprovalGranted` +* system events for health/timeouts + +--- + +# 9. state model + +The orchestration loop tracks an ephemeral `OrchestrationState` (in‑memory during execution) that holds: + +* current stage +* retry count +* pause reason +* pending approval flag + +This state is never persisted directly; it is rebuilt from events. + +--- + +# 10. retry coordination + +Orchestration evaluates retry policies after failures (validation, inference). It may: + +* re‑execute the same stage with adjusted context (failure reason injected) +* branch to a recovery stage +* terminate + +Retries are bounded and emit `RetryAttempted`. + +--- + +# 11. replay mode support + +During replay, the orchestrator runs the same loop but can skip inference (using recorded artifacts) or skip validation (trusting recorded outcomes). It follows a replay strategy configured per session. + +--- + +# 12. concurrency model + +All orchestration for a session runs in a single coroutine scope. No parallel stage execution (v1). Concurrency is limited to inference and tool calls within a stage being asynchronous but serialized from the orchestrator’s viewpoint. + +--- + +# 13. failure semantics + +If orchestration encounters an unrecoverable error, it emits `WorkflowFailed` and terminates the session safely. Partial state is preserved via events. + +--- + +# 14. observable requirements + +Must expose: + +* workflow duration +* stage‑level timing +* retry frequency +* bottlenecks (idle time in approval) +* cancellation triggers + +--- + +# 15. security boundaries + +Orchestration does not directly access external resources. It receives validated data only. It enforces no security beyond coordination: all gates are handled by validation/approvals/policies. + +--- + +# 16. extension model + +Orchestration logic is not pluggable in v1 (to preserve determinism), but hook points for custom stage scheduling strategies may be added later. + +--- + +# 17. forbidden patterns + +* direct manipulation of projections +* skipping validation steps +* hidden retry loops +* mutable orchestration state persisted outside events + +--- + +# 18. testing requirements + +* full workflow simulation with mock inference +* retry path coverage +* replay consistency +* cancellation during each phase + +--- + +# 19. philosophy + +Orchestration is the conductor, not the musician. It knows the score, calls in each section at the right time, and ensures the performance follows the plan—even when the musicians occasionally improvise. \ No newline at end of file diff --git a/docs/modules/core-sessions-submodule-spec.md b/docs/modules/core-sessions-submodule-spec.md new file mode 100644 index 00000000..7dfee00a --- /dev/null +++ b/docs/modules/core-sessions-submodule-spec.md @@ -0,0 +1,491 @@ +--- +name: "Core Sessions Submodule Spec" +description: "Specification for :core:sessions – lifecycle FSM and projection" +depth: 2 +links: ["../index.md", "../architecture/replay-model.md", "./core-module-spec.md"] +--- + +# :core:sessions module specification + +version: 0.1-draft +status: foundational specification + +--- + +# 1. purpose + +`:core:sessions` defines the canonical execution lifecycle model for correx sessions. + +It is the authoritative subsystem for: + +* session lifecycle semantics +* execution ownership +* runtime state transitions +* cancellation semantics +* recovery semantics +* session-scoped coordination +* execution isolation boundaries + +A session represents: + +* one bounded orchestration lifecycle +* one authoritative execution timeline +* one isolated event stream scope + +--- + +# 2. responsibilities + +`:core:sessions` owns: + +* session lifecycle FSM +* session identity contracts +* session state semantics +* execution ownership rules +* cancellation semantics +* pause/resume semantics +* recovery semantics +* session projections +* session-scoped concurrency guarantees +* session termination semantics + +--- + +# 3. non-responsibilities + +`:core:sessions` MUST NOT own: + +* orchestration execution logic +* transition evaluation +* persistence implementations +* websocket session management +* user authentication +* provider lifecycle management +* model execution +* UI session rendering + +Implementations belong to other modules. + +--- + +# 4. architectural role + +`:core:sessions` acts as: + +* execution boundary authority +* lifecycle authority +* session isolation authority +* runtime ownership authority + +All workflow execution MUST occur inside a valid session lifecycle. + +--- + +# 5. design principles + +## 5.1 sessions are bounded + +Sessions are finite execution scopes. + +Sessions MUST: + +* begin explicitly +* terminate explicitly +* remain replayable +* remain isolated + +Infinite implicit execution is forbidden. + +--- + +## 5.2 sessions own orchestration scope + +Everything occurring during workflow execution MUST belong to: + +* exactly one session + +Cross-session mutation is forbidden. + +--- + +## 5.3 lifecycle is deterministic + +Session state transitions MUST be: + +* explicit +* evented +* replayable +* validated + +Hidden lifecycle mutation is forbidden. + +--- + +## 5.4 cancellation is first-class + +Cancellation MUST propagate deterministically through: + +* orchestration +* inference +* tools +* transitions +* approvals + +Zombie execution is forbidden. + +--- + +# 6. session model + +## base session contract + +```kotlin +sealed interface Session { + val id: SessionId + val state: SessionState + val createdAt: Instant + val updatedAt: Instant + val correlationId: CorrelationId +} +``` + +--- + +# 7. session states + +Mandatory lifecycle states: + +```text +CREATED +INITIALIZING +ACTIVE +PAUSED +AWAITING_APPROVAL +CANCELLING +CANCELLED +FAILED +COMPLETED +RECOVERING +``` + +--- + +# 8. lifecycle guarantees + +## required guarantees + +Sessions MUST: + +* have exactly one active lifecycle state +* emit events for all state transitions +* terminate deterministically +* preserve replay consistency + +--- + +## forbidden behavior + +Forbidden: + +* silent state mutation +* implicit recovery +* orphaned execution +* detached execution scopes +* state mutation without events + +--- + +# 9. lifecycle transition rules + +Example lifecycle graph: + +```text +CREATED + ↓ +INITIALIZING + ↓ +ACTIVE + ├──→ PAUSED + ├──→ AWAITING_APPROVAL + ├──→ FAILED + ├──→ CANCELLING + └──→ COMPLETED +``` + +Recovery paths MUST be explicit. + +Invalid transitions MUST fail validation. + +--- + +# 10. session ownership model + +A session owns: + +* execution scope +* orchestration scope +* workflow scope +* event stream scope +* approval scope +* context synthesis scope + +Everything executed within a session MUST reference: + +* sessionId +* correlationId + +--- + +# 11. session projections + +Mandatory projections: + +```text +SessionStateProjection +SessionLifecycleProjection +SessionExecutionProjection +SessionApprovalProjection +SessionFailureProjection +``` + +Projections MUST remain: + +* rebuildable +* disposable +* deterministic + +--- + +# 12. concurrency model + +## required guarantees + +Within a session: + +* execution ordering MUST remain deterministic +* cancellation MUST propagate transitively +* lifecycle transitions MUST be atomic + +--- + +## session isolation + +Sessions MUST remain isolated from each other. + +Forbidden: + +* shared mutable orchestration state +* cross-session context mutation +* shared execution ownership + +--- + +# 13. cancellation semantics + +Cancellation MUST: + +* emit events +* propagate recursively +* terminate child execution scopes +* interrupt pending orchestration safely + +Cancellation MUST support: + +* graceful cancellation +* forced termination +* timeout escalation + +--- + +# 14. pause/resume semantics + +Paused sessions MUST: + +* preserve replay integrity +* preserve event ordering +* suspend active execution safely + +Resuming MUST emit explicit lifecycle events. + +--- + +# 15. approval suspension semantics + +When awaiting approval: + +* execution MUST suspend +* transition scheduling MUST pause +* inference MUST stop + +Only approval decisions may resume execution. + +--- + +# 16. recovery semantics + +Recovery MUST be event-driven. + +Recovery MAY occur after: + +* crash +* restart +* provider failure +* infrastructure interruption + +Recovery MUST NOT require: + +* original process state +* in-memory orchestration state +* active model residency + +--- + +# 17. replay guarantees + +Session replay MUST support: + +* lifecycle reconstruction +* transition reconstruction +* cancellation reconstruction +* approval reconstruction +* failure reconstruction + +Replay MUST deterministically reproduce: + +* session state +* projections +* orchestration decisions + +--- + +# 18. observability requirements + +`:core:sessions` MUST expose telemetry hooks for: + +* lifecycle transitions +* session duration +* cancellation propagation +* failure timelines +* replay reconstruction +* approval waiting time +* execution suspension timing + +--- + +# 19. failure semantics + +Session failures MUST: + +* emit structured failure events +* preserve historical integrity +* preserve replayability +* preserve partial execution history + +Failures MUST NOT: + +* corrupt event streams +* bypass lifecycle transitions +* silently terminate execution + +--- + +# 20. timeout semantics + +Sessions MAY define: + +* execution timeout +* inactivity timeout +* approval timeout +* recovery timeout + +Timeout expiration MUST emit lifecycle events. + +--- + +# 21. security boundaries + +Sessions define execution isolation boundaries. + +Session isolation MUST apply to: + +* orchestration +* context synthesis +* approvals +* tools +* inference execution + +Unauthorized cross-session access is forbidden. + +--- + +# 22. extension model + +Extensions MAY: + +* define additional session metadata +* define custom projections +* define lifecycle observers + +Extensions MUST NOT: + +* bypass lifecycle validation +* mutate session state directly +* bypass cancellation semantics +* introduce hidden execution state + +--- + +# 23. forbidden patterns + +Forbidden: + +* global orchestration state +* implicit session resurrection +* detached execution +* hidden lifecycle mutation +* orphaned child scopes +* mutable session history +* replay-dependent lifecycle behavior + +--- + +# 24. persistence expectations + +Persistence implementations MUST support: + +* durable session lifecycle history +* replay-safe reconstruction +* snapshot-compatible recovery +* deterministic session rebuild + +Persistence mechanics belong to infrastructure modules. + +--- + +# 25. testing requirements + +`:core:sessions` MUST support deterministic testing for: + +* lifecycle transitions +* cancellation propagation +* pause/resume behavior +* timeout handling +* recovery flows +* replay reconstruction +* concurrency isolation + +--- + +# 26. philosophy summary + +`:core:sessions` exists to provide deterministic execution boundaries around orchestration workflows. + +Reliability emerges from: + +* explicit lifecycle ownership +* bounded execution +* deterministic cancellation +* replayable state transitions +* isolated execution scopes + +not from long-lived mutable runtime state. diff --git a/docs/modules/core-tools-submodule-spec.md b/docs/modules/core-tools-submodule-spec.md new file mode 100644 index 00000000..bdfa2381 --- /dev/null +++ b/docs/modules/core-tools-submodule-spec.md @@ -0,0 +1,235 @@ +--- +name: "Core Tools Submodule Spec" +description: "Specification for :core:tools – external side‑effect execution" +depth: 2 +links: ["../index.md", "./core-module-spec.md", "./core-approvals-submodule-spec.md"] +--- + +# :core:tools module specification + +version: 0.1-draft +status: foundational specification + +--- + +# 1. purpose + +`:core:tools` defines the contract and orchestration model for external side‑effect execution (shell, git, filesystem, network, etc.). + +It owns: + +* tool contracts +* tool execution lifecycle +* tool receipt model +* sandboxing expectations +* tool capability declarations +* tool‑related events + +All external side effects required by a stage are channelled through this module, ensuring they are auditable, replayable, and governed by approval tiers. + +--- + +# 2. responsibilities + +`:core:tools` owns: + +* `Tool` interface +* tool execution lifecycle (prepare, execute, validate, receipt) +* tool receipt schema (structured output of tool runs) +* tool registration/capability mapping +* sandboxing contracts +* tool event definitions +* deterministic replay guidance for tools + +--- + +# 3. non-responsibilities + +`:core:tools` MUST NOT own: + +* actual tool implementations (these live in infrastructure or plugins) +* approval decisions (uses `:core:approvals`) +* artifact generation directly (tools produce receipts, which may be wrapped into artifacts later) +* UI for tool output viewing +* persistence of receipts (events store does that) + +--- + +# 4. architectural role + +`:core:tools` acts as: + +* the gatekeeper for all side‑effects +* the translator between model intentions ("run pytest") and validated, isolated executions +* the owner of the tool audit trail + +--- + +# 5. design principles + +## 5.1 structured receipts only + +Tool outputs must be typed. Raw text output is not allowed as a first‑class result. A receipt must include structured data (e.g., exit code, affected files, key metrics) in addition to a summary. + +## 5.2 tier assignment + +Every tool call has a tier (T0–T4). The tool itself declares its tier, and the approval subsystem checks it. + +## 5.3 side‑effects are externalized + +Tools mutate the outside world. The harness records what was done (receipt) and what was requested (invocation event), but never implies the world state changed exactly as intended—semantic validation compares receipts against expected changes later. + +## 5.4 replay with simulated tools + +During replay, actual tool execution is skipped; recorded receipts and events are replayed. For full replay or testing, deterministic mock tools can be used. + +--- + +# 6. tool contract + +```kotlin +interface Tool { + val name: String + val tier: ApprovalTier + val requiredCapabilities: Set + suspend fun execute(request: ToolRequest): ToolReceipt + fun validateRequest(request: ToolRequest): ValidationResult +} +``` + +--- + +# 7. tool invocation lifecycle + +1. Model output (or stage config) contains a proposed `ToolInvocation`. +2. `:core:tools` validates the invocation against the tool contract. +3. Approval check (tier vs. session mode). +4. If approved, tool executes in a sandboxed environment. +5. A `ToolReceipt` is produced. +6. Receipt is wrapped into a `ToolResultArtifact` for further validation. + +--- + +# 8. tool receipt model + +```kotlin +data class ToolReceipt( + val invocationId: String, + val toolName: String, + val exitCode: Int, + val outputSummary: String, + val structuredOutput: JsonObject?, + val affectedEntities: List, + val durationMs: Long, + val tier: ApprovalTier, + val timestamp: Instant +) +``` + +Structured output is mandatory for common tools but may be minimal for user‑defined tools. + +--- + +# 9. event ownership + +`:core:tools` emits: + +* `ToolInvocationRequested` +* `ToolExecutionStarted` +* `ToolExecutionCompleted` (with receipt) +* `ToolExecutionFailed` +* `ToolExecutionRejected` + +All linked to the parent artifact or stage event. + +--- + +# 10. consumed events + +Consumes: + +* `ArtifactProduced` (may contain suggested tool calls) +* `ApprovalGranted` (for tool execution) +* `StageScheduled` (for pre‑defined tool sequences) + +--- + +# 11. invariants + +* Receipts are immutable and append‑only. +* No tool may execute without an approval event matching its tier. +* Every tool execution is scoped to a session and stage. +* Receipts are replayable without re‑execution. + +--- + +# 12. replay semantics + +During replay, tool execution events are replayed from history. If a tool is marked as non‑deterministic, its receipt is simply reused. Deterministic simulation may replay tool logic with the same inputs and compare receipts. + +--- + +# 13. threading/concurrency + +Tool execution is suspending and may use dedicated dispatchers. Long‑running tools are cancellable via the orchestration token. Concurrent tool executions within a stage are allowed only if explicitly configured and still serialized in event order. + +--- + +# 14. failure semantics + +Tool failures are captured in `ToolExecutionFailed` with structured error. They contribute to stage failure and trigger retry/recovery paths. + +--- + +# 15. observable requirements + +* tool invocation counts +* success/failure rates +* execution duration +* tier escalation frequencies +* sandbox escape attempts (detected by policy) + +--- + +# 16. security boundaries + +Tools are the most dangerous part of the system. The module ensures: + +* sandboxing (via infrastructure) is enforced +* network access is gated by tier +* filesystem mutations are allowlist‑controlled +* secrets are never exposed to tool input + +All tool requests are inspected before execution against active policies. + +--- + +# 17. extension model + +New tools can be added as plugins implementing the `Tool` interface. They register their name, tier, capabilities, and execution logic. Plugins are sandboxed as much as possible. + +--- + +# 18. forbidden patterns + +* tools that mutate event store directly +* bypassing approval tier via hidden tool code +* mutable receipts +* tools that keep persistent state across invocations +* tool output that is free‑form text without structured data + +--- + +# 19. testing requirements + +* mock tool execution +* tier enforcement +* receipt validation +* replay with recorded receipts +* cancellation/timeout handling + +--- + +# 20. philosophy + +Tools are the harness’s hands, but the hands are clumsy and dangerous. This module ensures every grasp is planned, logged, and bounded, turning blunt instruments into precise, auditable actions. \ No newline at end of file diff --git a/docs/modules/core-transitions-submodule-spec.md b/docs/modules/core-transitions-submodule-spec.md new file mode 100644 index 00000000..84b51993 --- /dev/null +++ b/docs/modules/core-transitions-submodule-spec.md @@ -0,0 +1,503 @@ +--- +name: "Core Transitions Submodule Spec" +description: "Specification for :core:transitions – workflow graph engine" +depth: 2 +links: ["../index.md", "./core-module-spec.md", "./core-validation-submodule-spec.md"] +--- + +# :core:transitions module specification + +version: 0.1-draft +status: foundational specification + +--- + +# 1. purpose + +`:core:transitions` defines the deterministic workflow transition engine for correx. + +It is the authoritative subsystem for: + +* workflow graph semantics +* transition evaluation +* stage progression +* condition evaluation +* retry semantics +* branching semantics +* deadlock/cycle detection +* execution progression guarantees + +`:core:transitions` determines: + +* what execution path is legal +* when workflow state may advance +* how failures propagate +* how retries are controlled + +--- + +# 2. responsibilities + +`:core:transitions` owns: + +* workflow graph contracts +* transition contracts +* transition evaluation semantics +* condition evaluation +* retry policies +* branching semantics +* terminal state semantics +* cycle detection +* deadlock detection +* transition replay semantics +* execution progression guarantees + +--- + +# 3. non-responsibilities + +`:core:transitions` MUST NOT own: + +* model inference +* tool execution +* persistence implementations +* websocket streaming +* projection persistence +* approval implementation +* context synthesis +* provider management + +`:core:transitions` decides legality of progression only. + +--- + +# 4. architectural role + +`:core:transitions` acts as: + +* deterministic workflow state machine +* execution progression authority +* orchestration legality validator +* workflow graph evaluator + +All workflow progression MUST pass through this subsystem. + +--- + +# 5. design principles + +## 5.1 transitions are deterministic + +Transition evaluation MUST depend only on: + +* current projections +* current workflow config +* current event history + +Transition evaluation MUST NOT depend on: + +* hidden runtime state +* provider internals +* model memory +* nondeterministic mutable state + +--- + +## 5.2 workflow graphs are explicit + +All execution paths MUST be explicitly declared. + +Implicit execution flow is forbidden. + +--- + +## 5.3 transitions are validated + +All transitions MUST be validated before execution. + +Invalid transitions MUST fail explicitly. + +--- + +## 5.4 retries are state-aware + +Retries MUST evaluate: + +* current projections +* side effects +* prior failures +* retry policies + +Blind retries are forbidden. + +--- + +# 6. workflow graph model + +## graph definition + +A workflow graph consists of: + +```text id="3d3shm" +Stages +Transitions +Conditions +Terminal states +Retry rules +Failure rules +Approval gates +``` + +--- + +## graph guarantees + +Workflow graphs MUST be: + +* deterministic +* acyclic unless explicitly declared cyclic +* statically validated +* replay-safe + +--- + +# 7. stage model + +## stage definition + +A stage represents: + +* one bounded execution unit +* one orchestration checkpoint +* one validation boundary + +Stages MUST have: + +* unique identifier +* declared inputs +* declared outputs +* declared transition rules + +--- + +# 8. transition model + +## transition definition + +A transition represents: + +* legal movement between stages + +Transitions MUST define: + +* source stage +* target stage +* condition set +* retry behavior +* failure behavior + +--- + +## transition guarantees + +Transitions MUST: + +* emit events +* remain replayable +* remain deterministic +* remain validation-aware + +--- + +# 9. condition evaluation model + +Conditions MAY evaluate: + +* artifact fields +* projections +* approvals +* validation outcomes +* policy outcomes +* retry counters + +--- + +## condition guarantees + +Condition evaluation MUST be: + +* side-effect free +* deterministic +* replay-safe + +--- + +## forbidden condition behavior + +Forbidden: + +* network access +* filesystem mutation +* model execution +* tool execution +* hidden mutable state access + +--- + +# 10. transition DSL requirements + +Transition definitions MUST support: + +```yaml id="6dqarf" +when: + all: + - artifact.status == "valid" + - projection.open_risks < 2 + - approval.tier <= T2 +``` + +Required features: + +* boolean composition +* projection queries +* artifact queries +* retry conditions +* approval conditions + +--- + +# 11. retry model + +Retries MUST be: + +* bounded +* explicit +* evented +* replayable + +Retries MUST evaluate: + +* prior failures +* side effects +* policy restrictions +* retry budget + +--- + +## retry guarantees + +Retries MUST NOT: + +* loop infinitely +* ignore state mutation +* bypass validation +* bypass approvals + +--- + +# 12. failure propagation model + +Failures MAY: + +* retry current stage +* branch to recovery stage +* escalate approval +* terminate session +* pause workflow + +Failure behavior MUST be explicitly declared. + +--- + +# 13. branching semantics + +Supported branching: + +* linear progression +* conditional branching +* recovery branching +* retry branching +* terminal branching + +Parallel execution MAY be introduced later but is not required initially. + +--- + +# 14. terminal state semantics + +Mandatory terminal outcomes: + +```text id="avqvsv" +COMPLETED +FAILED +CANCELLED +BLOCKED +``` + +Terminal states MUST: + +* stop progression +* emit lifecycle events +* preserve replay integrity + +--- + +# 15. cycle detection + +Workflow validation MUST detect: + +* unintended cycles +* unreachable stages +* dead transitions +* infinite retry loops + +Explicit cycles MUST require: + +* explicit configuration +* bounded termination conditions + +--- + +# 16. deadlock detection + +Transition validation MUST detect: + +* approval deadlocks +* retry deadlocks +* dependency deadlocks +* unreachable terminal states + +Invalid graphs MUST fail at config load time. + +--- + +# 17. transition execution guarantees + +Transitions MUST: + +* occur atomically +* emit events +* preserve ordering +* preserve replay consistency + +Partial transitions are forbidden. + +--- + +# 18. replay guarantees + +Transition replay MUST reproduce: + +* stage progression +* branching decisions +* retry decisions +* terminal outcomes + +Replay MUST NOT require: + +* live models +* live tools +* provider access + +--- + +# 19. observability requirements + +`:core:transitions` MUST expose telemetry hooks for: + +* transition evaluation +* branching decisions +* retry timelines +* deadlock detection +* graph traversal +* stage duration +* failure propagation + +--- + +# 20. security boundaries + +Transition logic defines execution legality boundaries. + +Transitions MUST NOT: + +* bypass approvals +* bypass validation +* bypass policies +* bypass session lifecycle constraints + +Untrusted plugins MUST NOT gain direct transition authority. + +--- + +# 21. extension model + +Extensions MAY define: + +* custom conditions +* custom retry policies +* custom transition evaluators +* custom branching strategies + +Extensions MUST remain: + +* deterministic +* replay-safe +* side-effect free + +--- + +# 22. forbidden patterns + +Forbidden: + +* implicit workflow progression +* hidden branching +* infinite retries +* nondeterministic evaluation +* transition mutation during execution +* replay-dependent branching +* provider-dependent transition legality + +--- + +# 23. persistence expectations + +Transition persistence MUST support: + +* transition history +* retry history +* branching history +* replay reconstruction + +Persistence implementations belong to infrastructure modules. + +--- + +# 24. testing requirements + +`:core:transitions` MUST support deterministic testing for: + +* graph validation +* condition evaluation +* retry handling +* deadlock detection +* cycle detection +* branching correctness +* replay reconstruction + +--- + +# 25. philosophy summary + +`:core:transitions` exists to constrain orchestration into deterministic legal workflow progression. + +Reliability emerges from: + +* explicit graphs +* deterministic conditions +* bounded retries +* validated branching +* replayable execution flow + +not from trusting model autonomy. diff --git a/docs/modules/core-validation-submodule-spec.md b/docs/modules/core-validation-submodule-spec.md new file mode 100644 index 00000000..be29e4a4 --- /dev/null +++ b/docs/modules/core-validation-submodule-spec.md @@ -0,0 +1,299 @@ +--- +name: "Core Validation Submodule Spec" +description: "Specification for :core:validation – layered validation pipeline" +depth: 2 +links: ["../index.md", "./core-module-spec.md", "./core-approvals-submodule-spec.md"] +--- + +# :core:validation module specification + +version: 0.1-draft +status: foundational specification + +--- + +# 1. purpose + +`:core:validation` defines the layered validation pipeline responsible for determining whether an artifact may advance workflow state. + +It is the authoritative subsystem for: + +* validation pipeline composition +* routing validation semantics +* schema validation rules +* semantic validation contracts +* approval validation integration +* validation event ownership +* deterministic rejection rules + +All artifacts produced by models or tools are considered untrusted proposals until they pass all configured validation layers. + +--- + +# 2. responsibilities + +`:core:validation` owns: + +* validation pipeline contracts +* validation stage definitions +* validation outcome model +* layer composition rules +* validation event definitions +* rejection reason semantics +* pipeline execution ordering +* deterministic validation guarantees +* integration points for semantic validators +* validation telemetry hooks + +--- + +# 3. non-responsibilities + +`:core:validation` MUST NOT own: + +* actual semantic validation implementations (those belong to configured plugins or infrastructure) +* model execution +* approval decisions +* artifact persistence +* transition execution +* context synthesis +* UI rendering +* policy enforcement (but it invokes policy checks) + +--- + +# 4. architectural role + +`:core:validation` acts as: + +* hard gatekeeper for all workflow progression +* deterministic validation pipeline executor +* shared contract for all validation layers + +No artifact may become active workflow state without passing through this subsystem. + +--- + +# 5. design principles + +## 5.1 validation is mandatory + +Every artifact must be validated before it can trigger a transition. + +## 5.2 validation is layered + +Validation occurs in a fixed order: + +1. routing validation +2. payload validation +3. semantic validation +4. approval validation + +If any layer rejects, subsequent layers are skipped and the artifact is rejected. + +## 5.3 validation is deterministic at the pipeline level + +The pipeline itself (ordering, short-circuiting, rejection semantics) is deterministic and replay-safe. Individual semantic validators may be nondeterministic, but their outcomes are recorded as events, so the pipeline decision becomes deterministic from history. + +## 5.4 validation failures are events + +Every validation outcome (pass/reject/needs_approval) emits a corresponding event, enabling replay and audit. + +--- + +# 6. validation pipeline model + +The pipeline is defined as an ordered sequence of `ValidationStage` instances. + +``` +sealed interface ValidationStage { + val name: String + suspend fun evaluate(context: ValidationContext): ValidationOutcome +} +``` + +Pipeline execution: + +1. Build `ValidationContext` containing artifact, session, policies, current projection, event stream cursor. +2. Execute each stage in order. +3. On first rejection, stop and emit `ArtifactRejected`. +4. If all pass, emit `ArtifactValidated`. +5. If any stage requires approval escalation, emit `ApprovalRequired` and pause. + +--- + +# 7. validation context + +```kotlin +data class ValidationContext( + val artifact: Artifact, + val sessionId: SessionId, + val stageId: StageId, + val currentProjection: ProjectionSnapshot, + val policies: ActivePolicies, + val eventHistory: Sequence, + val replayMode: Boolean = false +) +``` + +--- + +# 8. layer definitions + +### routing validation + +Checks: + +* artifact belongs to a valid stage in the current workflow +* transition from current stage to subsequent stage is allowed +* required capabilities are available +* no policy routing violations + +Owner: `:core:transitions` + `:core:validation` together; routing validation is implemented here but queries the transition graph. + +### payload validation + +Validates artifact structure against its declared schema using pydantic/kotlinx.serialization. + +* field completeness +* type correctness +* version compatibility + +Failure is always deterministic. + +### semantic validation + +Examines artifact content for consistency, safety, and correctness: + +* hallucinated file paths +* contradictory claims +* unsafe commands +* policy violations beyond structure + +Semantic validators are pluggable. They may use small deterministic checkers or even other models, but their outputs are captured as events and become part of the replayable judgment. + +### approval validation + +Checks: + +* does the artifact fall under an auto-approved tier? +* does it require user approval? +* has the user already granted session-level auto-approve? +* are there escalate/deny policies? + +If approval is required, the pipeline suspends and emits `ApprovalRequested`. Only after a positive approval decision does validation succeed. + +--- + +# 9. validation outcomes + +```kotlin +sealed interface ValidationOutcome { + data object Passed : ValidationOutcome + data class Rejected(val reasons: List) : ValidationOutcome + data class NeedsApproval(val tier: ApprovalTier, val message: String) : ValidationOutcome +} +``` + +--- + +# 10. event ownership + +`:core:validation` emits: + +* `ArtifactValidationStarted` +* `ArtifactValidated` +* `ArtifactRejected` +* `ApprovalRequired` (delegated to approval subsystem, but emitted here) + +All validation events carry causation from the artifact event. + +--- + +# 11. consumed events + +`:core:validation` consumes: + +* `ArtifactProduced` (to start validation) +* `ApprovalDecision` (to resume from approval wait) + +--- + +# 12. invariants + +* Validation pipeline ordering is immutable for a given session config. +* Validation outcomes are idempotent given identical inputs. +* Rejected artifacts may never be used for state progression. +* All validation decisions are recorded as events before any transition. + +--- + +# 13. replay semantics + +During replay, validation outcomes are read from events, not re-executed, ensuring determinism. + +If replay mode is `inference-skipping`, semantic validators are not invoked; instead, previously recorded outcomes are applied. + +Validation events themselves must be replayable. + +--- + +# 14. threading/concurrency + +The validation pipeline runs within the orchestration coroutine scope of the current session. No concurrent validation of the same artifact is allowed. Pipeline execution must be cancellable. + +--- + +# 15. failure semantics + +If the pipeline itself fails (e.g., a bug in a validator), the artifact is rejected with a system error. The session enters a failed or recovery state. No partial acceptance is allowed. + +--- + +# 16. observable requirements + +Must expose: + +* validation duration per layer +* rejection reasons +* approval wait times +* semantic validator invocations +* pipeline completions + +--- + +# 17. security boundaries + +Validation is a security boundary. Validators, especially semantic ones, may be untrusted plugins. They must not mutate state or access external services directly. They operate in a sandboxed context with read-only access to the artifact and a frozen projection. + +--- + +# 18. extension model + +Extensions may register custom semantic validators or even additional validation layers via the plugin system. Extensions must abide by the deterministic pipeline contract and produce `ValidationOutcome`. + +--- + +# 19. forbidden patterns + +* bypassing validation by direct artifact propagation +* validation that depends on mutable global state +* ordering inversion +* silent acceptance of invalid artifacts +* replay that re-executes approval decisions + +--- + +# 20. testing requirements + +* pipeline ordering determinism +* layer short-circuit behavior +* replay consistency (with and without inference) +* approval escalation/rejection paths +* integration with all artifact types + +--- + +# 21. philosophy + +Validation is the harness’s immune system. It does not trust any model output; it verifies structure, safety, and policy compliance before allowing state to advance. The pipeline turns probabilistic model suggestions into trustworthy workflow building blocks. \ No newline at end of file diff --git a/docs/modules/modules-and-spec.md b/docs/modules/modules-and-spec.md new file mode 100644 index 00000000..be6b3b5d --- /dev/null +++ b/docs/modules/modules-and-spec.md @@ -0,0 +1,321 @@ +--- +name: "Modules And Spec" +description: "Guide to module organisation and documentation standards" +depth: 2 +links: ["../index.md", "./core-module-spec.md", "../design/structure.md"] +--- + +yes. and good catch — consistency matters here because the structure itself becomes architecture documentation. + +i’d standardize on nested gradle modules only: + +```text id="bllgjv" +:apps +:apps:cli +:apps:server + +:core +:core:events +:core:context +:core:validation +:core:transitions +:core:orchestration +:core:artifacts +:core:sessions +:core:approvals +:core:policies +:core:tools +:core:inference + +:infrastructure +:infrastructure:persistence +:infrastructure:providers +:infrastructure:tools + +:interfaces +:interfaces:api +:interfaces:cli +``` + +i would add: + +* `:core:orchestration` +* `:core:artifacts` +* `:core:sessions` + +immediately. + +because those are foundational. +without them, responsibilities leak everywhere. + +and yes — writing specs/design docs module-by-module is the correct approach. + +BUT: +you need two document types per module: + +1. contract/spec + defines: + +* responsibilities +* invariants +* boundaries +* public interfaces +* forbidden dependencies +* event ownership +* lifecycle guarantees + +2. implementation/design + defines: + +* concrete architecture +* classes +* flow +* persistence +* threading +* algorithms +* data structures + +this separation matters a lot. + +recommended documentation structure + +```text id="3ktksg" +docs/ +├── architecture/ +│ ├── system-overview.md +│ ├── event-model.md +│ ├── replay-model.md +│ ├── context-layers.md +│ └── security-boundaries.md +│ +├── modules/ +│ ├── core-events/ +│ │ ├── spec.md +│ │ └── design.md +│ │ +│ ├── core-context/ +│ │ ├── spec.md +│ │ └── design.md +│ │ +│ ├── core-validation/ +│ │ ├── spec.md +│ │ └── design.md +│ │ +│ └── ... +│ +└── decisions/ + ├── adr-0001-event-sourcing.md + ├── adr-0002-kotlin-choice.md + └── ... +``` + +VERY important: +use ADRs from the beginning. + +Architecture Decision Records + +because you WILL forget later: + +* why replay works this way +* why approvals are append-only +* why projections are disposable +* why router is isolated + +future-you will otherwise rewrite old mistakes. + +recommended order for specs + +this order minimizes architectural drift: + +1. + +`:core:events` + +first because: +everything depends on event semantics. + +must define: + +* event contracts +* causation/correlation ids +* append guarantees +* replay guarantees +* snapshotting +* versioning + +2. + +`:core:sessions` + +defines: + +* lifecycle FSM +* ownership boundaries +* session projections +* cancellation semantics + +3. + +`:core:transitions` + +defines: + +* graph execution +* rule evaluation +* deadlock/cycle handling +* retry semantics + +4. + +`:core:artifacts` + +defines: + +* artifact contracts +* lineage +* validation ownership +* immutability guarantees + +5. + +`:core:validation` + +defines: + +* layered validation pipeline +* semantic validators +* policy interaction +* approval interaction + +6. + +`:core:context` + +probably hardest subsystem after validation. + +must define: + +* layering +* token budgeting +* compression contracts +* dedup semantics +* rebuild rules + +7. + +`:core:approvals` + +must be isolated and heavily specified. + +8. + +`:core:orchestration` + +only AFTER the primitives exist. + +because orchestration composes: + +* events +* transitions +* approvals +* context +* inference + +9. + +`:core:inference` + +provider abstraction. + +10. + +`:core:tools` + +tool contracts + receipts. + +11. + +infrastructure modules + +ONLY after contracts stabilize. + +this is important: +do NOT start with providers/tools/ui. + +that’s how orchestration architectures rot early. + +for each module spec, define these sections + +mandatory template: + +```text id="n9z2y6" +1. purpose +2. responsibilities +3. non-responsibilities +4. invariants +5. public contracts +6. owned events +7. consumed events +8. state model +9. threading/concurrency model +10. failure semantics +11. replay semantics +12. persistence requirements +13. observability requirements +14. security boundaries +15. extension points +16. forbidden dependencies +17. open questions +``` + +this will massively reduce ambiguity later. + +especially: +“non-responsibilities” +and +“forbidden dependencies” + +those prevent architecture erosion. + +example: + +```text id="a1qoqm" +:core:context + +non-responsibilities: +- model execution +- persistence +- tool execution + +forbidden dependencies: +- infrastructure modules +- websocket interfaces +- provider implementations +``` + +that sounds bureaucratic now. +it becomes life-saving later. + +one more recommendation: +define module dependency rules EARLY. + +example: + +```text id="2vwe0o" +core modules may depend on: +- contracts +- lower-level core modules + +core modules may never depend on: +- infrastructure +- interfaces +- apps +``` + +enforce this automatically eventually with: + +* archunit +* detekt custom rules +* dependency analysis plugin + +because modular architectures silently decay otherwise. diff --git a/docs/reference/example.yaml b/docs/reference/example.yaml new file mode 100644 index 00000000..d74ab210 --- /dev/null +++ b/docs/reference/example.yaml @@ -0,0 +1,115 @@ +state_schema: + # fixed, system-owned + artifacts: + discovery_result: {} + plan: {} + execution_result: {} + critic_decision: {} + + signals: + tests: { status: "unknown" } # pass | fail | unknown + failure: "none" # none | schema_fail | test_fail | hallucination | timeout + complexity: 0.0 # 0..1 (heuristic) + retries: 0 + iteration: 0 + + pointers: + current_agent: null + last_agent: null + last_artifact: null + +limits: + max_total_steps: 12 + max_retries_per_agent: 3 + +agents: + discovery: + models: [small] + prompt: prompts/discovery.md + needs: [] + produces: [discovery_result] + + planner: + models: [small, medium] + prompt: prompts/planner.md + needs: [discovery_result] + produces: [plan] + + executor: + models: [medium] + prompt: prompts/executor.md + needs: [plan] + produces: [execution_result] + + critic: + models: [small] + prompt: prompts/critic_struct.md + needs: [execution_result] + produces: [critic_decision] + + fixer_minimal: + models: [medium, large] + prompt: prompts/fix_minimal.md + needs: [execution_result] + produces: [execution_result] + +selection: + default: smallest_sufficient + + overrides: + - when: complexity > 0.6 + pick: medium + + - when: failure == "hallucination" + pick: large + + - when: retries >= 2 + pick: large + +routing: + start: discovery + + rules: + # initial flow + - when: !has(discovery_result) + next: discovery + + - when: has(discovery_result) && !has(plan) + next: planner + + - when: has(plan) && !has(execution_result) + next: executor + + - when: has(execution_result) && !has(critic_decision) + next: critic + + # success exit + - when: critic_decision.decision == "pass" || tests.status == "pass" + next: done + + # retry loops + - when: critic_decision.decision == "retry" && failure == "test_fail" + next: executor + mutate: + - inject_failure_reason + - increment_retries + + - when: critic_decision.decision == "retry" && failure == "schema_fail" + next: executor + mutate: + - tighten_constraints + - increment_retries + + - when: critic_decision.decision == "retry" && failure == "hallucination" + next: fixer_minimal + mutate: + - restrict_to_provided_context + - increment_retries + + # escalation + - when: retries >= 2 && tests.status == "fail" + next: fixer_minimal + + # fallback + - when: iteration >= 10 + next: done \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..8d5eb9c5 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx4G -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..d997cfc6 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df97d72b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..f640dbce --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob//platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/infrastructure/build.gradle b/infrastructure/build.gradle new file mode 100644 index 00000000..ce1c3139 --- /dev/null +++ b/infrastructure/build.gradle @@ -0,0 +1,23 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation project(":core:tools") + implementation project(":core:events") + implementation project(":core:inference") + implementation project(":core:approvals") + implementation project(":core:sessions") + implementation project(":infrastructure:inference") + implementation project(":infrastructure:inference:commons") + implementation project(":infrastructure:inference:llama_cpp") + implementation project(":infrastructure:persistence") + implementation project(":infrastructure:tools") + implementation project(":infrastructure:tools:filesystem") + 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" +} diff --git a/infrastructure/inference/build.gradle b/infrastructure/inference/build.gradle new file mode 100644 index 00000000..805e7fed --- /dev/null +++ b/infrastructure/inference/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation project(':core:inference') + implementation project(':core:events') + testImplementation "org.junit.jupiter:junit-jupiter" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test" +} diff --git a/infrastructure/inference/commons/build.gradle b/infrastructure/inference/commons/build.gradle new file mode 100644 index 00000000..14dac07e --- /dev/null +++ b/infrastructure/inference/commons/build.gradle @@ -0,0 +1,19 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +ext { + ktor_version = '3.0.3' +} + +dependencies { + implementation project(':core:inference') + implementation project(':core:events') + implementation project(':core:context') + 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" +} diff --git a/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ManagedInferenceProvider.kt b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ManagedInferenceProvider.kt new file mode 100644 index 00000000..04cc52cf --- /dev/null +++ b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ManagedInferenceProvider.kt @@ -0,0 +1,26 @@ +package com.correx.infrastructure.inference.commons + +import com.correx.core.events.types.ProviderId +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.Tokenizer + +/** + * A wrapper around [InferenceProvider] that tracks model lifecycle. + * Ensures the provider is tied to a specific [ModelManager] instance and [ModelDescriptor]. + */ +interface ManagedInferenceProvider : InferenceProvider { + /** + * Unload the managed model. + */ + suspend fun unload() + + /** + * Check if the underlying model is still loaded. + */ + fun isLoaded(): Boolean + + /** + * Load a different model, replacing this one. + */ + suspend fun load(newDescriptor: ModelDescriptor): ManagedInferenceProvider +} diff --git a/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelDescriptor.kt b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelDescriptor.kt new file mode 100644 index 00000000..ddea85a2 --- /dev/null +++ b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelDescriptor.kt @@ -0,0 +1,15 @@ +package com.correx.infrastructure.inference.commons + +import com.correx.core.inference.CapabilityScore + +/** + * Descriptor for a model instance with its configuration. + */ +data class ModelDescriptor( + val modelId: String, + val modelPath: String, + val residencyMode: ResidencyMode, + val idleTimeoutMs: Long = 60_000L, + val contextSize: Int = 8192, + val capabilities: Set = emptySet(), +) diff --git a/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelLoadException.kt b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelLoadException.kt new file mode 100644 index 00000000..d32764d5 --- /dev/null +++ b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelLoadException.kt @@ -0,0 +1,11 @@ +package com.correx.infrastructure.inference.commons + +/** + * Thrown when model loading fails during ModelManager.load(). + * No event is emitted on failure per event sourcing principles. + */ +class ModelLoadException( + override val message: String, + val modelId: String, + override val cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelManager.kt b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelManager.kt new file mode 100644 index 00000000..03020324 --- /dev/null +++ b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelManager.kt @@ -0,0 +1,36 @@ +package com.correx.infrastructure.inference.commons + +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.ProviderHealth + +/** + * Manages the lifecycle of models. + */ +interface ModelManager { + /** + * Loads a model and returns an InferenceProvider instance. + * + * @param descriptor Model configuration + * @return An InferenceProvider for the loaded model + * @throws ModelLoadException if a different model is already loaded + */ + suspend fun load(descriptor: ModelDescriptor): InferenceProvider + + /** + * Unloads the currently loaded model. + * + * @param modelId The model ID to unload + * @throws ModelLoadException if no model is currently loaded + */ + suspend fun unload(modelId: String) + + /** + * Returns the descriptor of the currently loaded model, or null if none. + */ + fun currentModel(): ModelDescriptor? + + /** + * Health check for the current model. + */ + fun healthCheck(): ProviderHealth +} diff --git a/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ResidencyMode.kt b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ResidencyMode.kt new file mode 100644 index 00000000..49d3d36b --- /dev/null +++ b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ResidencyMode.kt @@ -0,0 +1,10 @@ +package com.correx.infrastructure.inference.commons + +/** + * Residency modes for model lifecycle management. + */ +enum class ResidencyMode { + PERSISTENT, // never unload + DYNAMIC, // unload after idle timeout + EPHEMERAL, // unload immediately after infer completes +} diff --git a/infrastructure/inference/llama_cpp/build.gradle b/infrastructure/inference/llama_cpp/build.gradle new file mode 100644 index 00000000..adf1af14 --- /dev/null +++ b/infrastructure/inference/llama_cpp/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +ext { + ktor_version = '3.0.3' + // Ensure parent version is available + ext.ktor_version = '3.0.3' +} + +dependencies { + implementation project(':core:inference') + implementation project(':core:events') + implementation project(':core:context') + implementation project(':infrastructure:inference:commons') + 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" +} diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/DefaultModelManager.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/DefaultModelManager.kt new file mode 100644 index 00000000..93839e61 --- /dev/null +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/DefaultModelManager.kt @@ -0,0 +1,208 @@ +package com.correx.infrastructure.inference.llama.cpp + +import com.correx.core.events.EventDispatcher +import com.correx.core.events.events.ModelLoadedEvent +import com.correx.core.events.events.ModelUnloadedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.SessionId +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.ProviderHealth +import com.correx.core.inference.Tokenizer +import com.correx.infrastructure.inference.commons.ManagedInferenceProvider +import com.correx.infrastructure.inference.commons.ModelDescriptor +import com.correx.infrastructure.inference.commons.ModelLoadException +import com.correx.infrastructure.inference.commons.ModelManager +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import java.io.File + +/** + * Implementation of ModelManager that manages llama.cpp server process lifecycle. + * + * Enforces single-model-at-a-time policy via Mutex. + * Model swaps are performed via process restart (kill existing, spawn new with --model flag). + * + * Thread-safe: all public methods use Mutex for synchronization. + */ +@Suppress("LongParameterList", "TooGenericExceptionCaught", "UnusedPrivateProperty") +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, + private val httpClient: HttpClient, + private val eventDispatcher: EventDispatcher, +) : ModelManager { + + private var currentProcess: LlamaProcess? = null + private var currentDescriptor: ModelDescriptor? = null + private val mutex = Mutex() + + private val logDirPath = System.getProperty("user.home") + "/.local/share/correx/logs" + + override suspend fun load(descriptor: ModelDescriptor): InferenceProvider = mutex.withLock { + // If same model already loaded, return existing provider + if (currentDescriptor?.modelId == descriptor.modelId) { + return@withLock createProvider(descriptor) + } + + // Kill existing process if running + stopCurrentModelInternal() + + // Spawn new process + val process = try { + val logFile = File(logDirPath, "llama-server.log") + val command = listOf( + llamaServerBin, + "--model", descriptor.modelPath, + "--ctx-size", descriptor.contextSize.toString(), + "--host", host, + "--port", port.toString(), + ) + val lp = LlamaProcess(command, logFile) + lp.start() + lp + } catch (e: Exception) { + throw ModelLoadException("Failed to start llama-server: ${e.message}", descriptor.modelId, e) + } + + // Health check polling + if (!waitForHealthy(process)) { + process.stop() + throw ModelLoadException( + "Health check timeout after ${healthTimeoutMs}ms", + descriptor.modelId, + ) + } + + // Set state + currentProcess = process + currentDescriptor = descriptor + + // Emit event + val providerId = ProviderId("llama-cpp:${descriptor.modelId}") + eventDispatcher.emit( + payload = ModelLoadedEvent( + modelId = descriptor.modelId, + providerId = providerId, + sessionId = SessionId("correx:model-manager"), + ), + sessionId = SessionId("correx:model-manager") + ) + + // Return provider + createProvider(descriptor) + } + + override suspend fun unload(modelId: String) = mutex.withLock { + val process = currentProcess ?: throw ModelLoadException("No model currently loaded", modelId) + val descriptor = currentDescriptor ?: throw ModelLoadException("No model currently loaded", modelId) + + if (descriptor.modelId != modelId) { + throw ModelLoadException("Model ID mismatch: expected ${descriptor.modelId}, got $modelId", modelId) + } + + process.stop() + currentProcess = null + currentDescriptor = null + + // Emit event + val providerId = ProviderId("llama-cpp") + eventDispatcher.emit( + payload = ModelUnloadedEvent( + modelId = modelId, + providerId = providerId, + sessionId = SessionId("correx:model-manager"), + cancellationReason = null, + ), + sessionId = SessionId("correx:model-manager") + ) + } + + override fun currentModel(): ModelDescriptor? = currentDescriptor + + override fun healthCheck(): ProviderHealth { + val process = currentProcess ?: return ProviderHealth.Unavailable("No model loaded") + return if (process.isAlive) { + ProviderHealth.Healthy + } else { + ProviderHealth.Unavailable("Process not running") + } + } + + private suspend fun stopCurrentModelInternal() { + currentProcess?.stop() + currentProcess = null + currentDescriptor = null + } + + @Suppress("UnusedParameter") + private suspend fun waitForHealthy(process: LlamaProcess): Boolean { + val endTime = Clock.System.now().toEpochMilliseconds() + healthTimeoutMs + while (Clock.System.now().toEpochMilliseconds() < endTime) { + try { + val response = httpClient.get("http://$host:$port/health").body() + if (response.contains("\"status\":\"healthy\"") || response.contains("ok")) { + return true + } + } catch (_: Exception) { + // Continue polling + } + delay(POLL_INTERVAL) + } + return false + } + + private fun createProvider(descriptor: ModelDescriptor): ManagedInferenceProvider { + val providerId = ProviderId("llama-cpp:${descriptor.modelId}") + val baseUrl = "http://$host:$port" + + val delegate = LlamaCppInferenceProvider( + descriptor = descriptor, + baseUrl = baseUrl, + httpClient = httpClient, + ) + + return DefaultManagedInferenceProvider(delegate, this, descriptor, providerId) + } + + private companion object { + private const val POLL_INTERVAL = 1000L + } +} + +/** + * Implementation of [ManagedInferenceProvider] that tracks model lifecycle. + * Ensures the provider is tied to a specific [ModelManager] instance and [ModelDescriptor]. + */ +class DefaultManagedInferenceProvider( + private val delegate: InferenceProvider, + private val manager: ModelManager, + private val descriptor: ModelDescriptor, + override val id: ProviderId, +) : ManagedInferenceProvider, InferenceProvider by delegate { + + override val name: String = "Managed(${delegate.name})" + override val tokenizer: Tokenizer = delegate.tokenizer + + override suspend fun unload() { + manager.unload(descriptor.modelId) + } + + override fun isLoaded(): Boolean { + return manager.currentModel()?.modelId == descriptor.modelId + } + + override suspend fun load(newDescriptor: ModelDescriptor): ManagedInferenceProvider { + val provider = manager.load(newDescriptor) as? ManagedInferenceProvider + ?: throw ModelLoadException("Failed to load new model", newDescriptor.modelId) + return provider + } +} diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppApiModels.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppApiModels.kt new file mode 100644 index 00000000..6b116d4b --- /dev/null +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppApiModels.kt @@ -0,0 +1,45 @@ +package com.correx.infrastructure.inference.llama.cpp + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@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, +) + +@Serializable +data class TokenizeRequest(val tokens: List) + +@Serializable +data class TokenizeResponse(val tokens: List) diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt new file mode 100644 index 00000000..78c1d4c5 --- /dev/null +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt @@ -0,0 +1,120 @@ +package com.correx.infrastructure.inference.llama.cpp + +import com.correx.core.context.model.ContextLayer +import com.correx.core.context.model.ContextPack +import com.correx.core.events.types.ProviderId +import com.correx.core.inference.CapabilityScore +import com.correx.core.inference.FinishReason +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRequest +import com.correx.core.inference.InferenceResponse +import com.correx.core.inference.ProviderHealth +import com.correx.core.inference.TokenUsage +import com.correx.core.inference.Tokenizer +import com.correx.infrastructure.inference.commons.ModelDescriptor +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +private fun defaultHttpClient(): HttpClient = HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + }, + ) + } +} + +@Suppress("TooGenericExceptionCaught", "MagicNumber") +class LlamaCppInferenceProvider( + private val descriptor: ModelDescriptor, + private val baseUrl: String = "http://127.0.0.1:10000", + private val httpClient: HttpClient = defaultHttpClient(), +) : InferenceProvider { + + private val modelId: String = descriptor.modelId + + override val id: ProviderId = ProviderId("llama-cpp:$modelId") + override val name: String = "LlamaCpp ($modelId)" + override val tokenizer: Tokenizer = LlamaCppTokenizer(baseUrl, httpClient) + + override suspend fun infer(request: InferenceRequest): InferenceResponse { + val startTime = System.currentTimeMillis() + + val messages = buildMessages(request.contextPack) + + val body = ChatCompletionRequest( + model = modelId, + messages = messages, + temperature = request.generationConfig.temperature, + topP = request.generationConfig.topP, + maxTokens = request.generationConfig.maxTokens, + stopSequences = request.generationConfig.stopSequences, + seed = request.generationConfig.seed, + stream = false, + ) + + val response = httpClient.post("$baseUrl/v1/chat/completions") { + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(body) + }.body() + + val finishReason = response.choices.first().finishReason.lowercase().let { + if (it == "length") FinishReason.Length else FinishReason.Stop + } + + return InferenceResponse( + requestId = request.requestId, + text = response.choices.first().message.content, + finishReason = finishReason, + tokensUsed = TokenUsage( + promptTokens = response.usage.promptTokens, + completionTokens = response.usage.completionTokens, + ), + latencyMs = System.currentTimeMillis() - startTime, + ) + } + + override suspend fun healthCheck(): ProviderHealth = try { + val response = httpClient.get("$baseUrl/health") + if (response.status.value in 200..299) { + ProviderHealth.Healthy + } else { + ProviderHealth.Unavailable("Health check returned non-2xx status: ${response.status}") + } + } catch (e: Exception) { + ProviderHealth.Unavailable("Health check failed: ${e.message}") + } + + override fun capabilities(): Set = descriptor.capabilities + + private fun buildMessages(contextPack: ContextPack): List { + val messages = contextPack.layers.flatMap { (layer, entries) -> + when (layer) { + ContextLayer.L0 -> entries.map { ChatMessage("system", it.content) } + ContextLayer.L1 -> entries.map { ChatMessage("user", it.content) } + ContextLayer.L2 -> entries.map { + ChatMessage( + role = if (it.sourceType == "assistant") "assistant" else "user", + content = it.content + ) + } + else -> emptyList() + } + } + return messages.ifEmpty { listOf(ChatMessage("user", "")) } + } +} diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppTokenizer.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppTokenizer.kt new file mode 100644 index 00000000..c1ff9d1d --- /dev/null +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppTokenizer.kt @@ -0,0 +1,26 @@ +package com.correx.infrastructure.inference.llama.cpp + +import com.correx.core.inference.Token +import com.correx.core.inference.Tokenizer +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.accept +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType + +class LlamaCppTokenizer( + private val baseUrl: String = "http://127.0.0.1:10000", + private val httpClient: HttpClient, +) : Tokenizer { + + override suspend fun tokenize(text: String): List = + httpClient.post("$baseUrl/tokenize") { + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(TokenizeRequest(tokens = listOf(text))) + }.body().tokens.map { Token(it) } + + override suspend fun countTokens(text: String): Int = tokenize(text).size +} diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaProcess.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaProcess.kt new file mode 100644 index 00000000..b6359a0e --- /dev/null +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaProcess.kt @@ -0,0 +1,39 @@ +package com.correx.infrastructure.inference.llama.cpp + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Manages the lifecycle of a llama.cpp server process. + */ +class LlamaProcess( + private val command: List, + private val logFile: File +) { + private var process: Process? = null + + val isAlive: Boolean + get() = process?.isAlive ?: false + + suspend fun start() = withContext(Dispatchers.IO) { + if (isAlive) return@withContext + + logFile.parentFile?.mkdirs() + + process = ProcessBuilder(command) + .redirectOutput(logFile) + .redirectErrorStream(true) + .start() + } + + suspend fun stop() = withContext(Dispatchers.IO) { + process?.destroyForcibly() + try { + process?.waitFor() + } catch (_: Exception) { + // Process already dead or timeout + } + process = null + } +} diff --git a/infrastructure/inference/src/main/kotlin/com/correx/infrastructure/inference/DefaultProviderRegistry.kt b/infrastructure/inference/src/main/kotlin/com/correx/infrastructure/inference/DefaultProviderRegistry.kt new file mode 100644 index 00000000..4207b4eb --- /dev/null +++ b/infrastructure/inference/src/main/kotlin/com/correx/infrastructure/inference/DefaultProviderRegistry.kt @@ -0,0 +1,38 @@ +package com.correx.infrastructure.inference + +import com.correx.core.events.types.ProviderId +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.ModelCapability +import com.correx.core.inference.ProviderHealth +import com.correx.core.inference.ProviderRegistry +import java.util.concurrent.CopyOnWriteArrayList + +/** + * In-memory [ProviderRegistry] backed by a [CopyOnWriteArrayList] for safe concurrent reads. + * + * Providers supplied at construction are registered immediately. + * Additional providers may be registered at any time via [register]. + */ +class DefaultProviderRegistry( + initialProviders: List = emptyList(), +) : ProviderRegistry { + + private val providers: CopyOnWriteArrayList = + CopyOnWriteArrayList(initialProviders) + + override fun register(provider: InferenceProvider) { + providers.add(provider) + } + + override fun resolve(capability: ModelCapability): List = + providers + .filter { p -> p.capabilities().any { it.capability == capability } } + .sortedByDescending { p -> + p.capabilities().firstOrNull { it.capability == capability }?.score ?: 0.0 + } + + override fun listAll(): List = providers.toList() + + override suspend fun healthCheckAll(): Map = + providers.associate { it.id to it.healthCheck() } +} diff --git a/infrastructure/inference/src/main/kotlin/com/correx/infrastructure/inference/FirstAvailableRoutingStrategy.kt b/infrastructure/inference/src/main/kotlin/com/correx/infrastructure/inference/FirstAvailableRoutingStrategy.kt new file mode 100644 index 00000000..c1ee5552 --- /dev/null +++ b/infrastructure/inference/src/main/kotlin/com/correx/infrastructure/inference/FirstAvailableRoutingStrategy.kt @@ -0,0 +1,32 @@ +package com.correx.infrastructure.inference + +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.ModelCapability +import com.correx.core.inference.NoEligibleProviderException +import com.correx.core.inference.RoutingStrategy +import com.correx.core.events.types.StageId + +/** + * Selects the first [InferenceProvider] from [candidates] whose declared capabilities + * cover all [requiredCapabilities]. + * + * "First" means the list order as supplied by [ProviderRegistry.resolve] — callers that + * want score-ordered selection should pass a score-sorted list. + * + * @throws NoEligibleProviderException if no candidate satisfies all required capabilities. + */ +class FirstAvailableRoutingStrategy : RoutingStrategy { + override fun select( + candidates: List, + requiredCapabilities: Set, + ): InferenceProvider { + val declared = candidates.firstOrNull { provider -> + val providerCapabilities = provider.capabilities().map { it.capability }.toSet() + providerCapabilities.containsAll(requiredCapabilities) + } + return declared ?: throw NoEligibleProviderException( + StageId("routing"), + requiredCapabilities, + ) + } +} diff --git a/infrastructure/inference/src/main/kotlin/com/correx/infrastructure/inference/Module.kt b/infrastructure/inference/src/main/kotlin/com/correx/infrastructure/inference/Module.kt new file mode 100644 index 00000000..6ad3ebe4 --- /dev/null +++ b/infrastructure/inference/src/main/kotlin/com/correx/infrastructure/inference/Module.kt @@ -0,0 +1,3 @@ +package com.correx.infrastructure.inference + +object Module diff --git a/infrastructure/inference/src/test/kotlin/com/correx/infrastructure/inference/DefaultProviderRegistryTest.kt b/infrastructure/inference/src/test/kotlin/com/correx/infrastructure/inference/DefaultProviderRegistryTest.kt new file mode 100644 index 00000000..468a32c6 --- /dev/null +++ b/infrastructure/inference/src/test/kotlin/com/correx/infrastructure/inference/DefaultProviderRegistryTest.kt @@ -0,0 +1,80 @@ +package com.correx.infrastructure.inference + +import com.correx.core.events.types.ProviderId +import com.correx.core.inference.CapabilityScore +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRequest +import com.correx.core.inference.InferenceResponse +import com.correx.core.inference.ModelCapability +import com.correx.core.inference.ProviderHealth +import com.correx.core.inference.Tokenizer +import com.correx.core.inference.Token +import com.correx.core.utils.TypeId +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DefaultProviderRegistryTest { + + private fun fakeProvider( + id: String, + vararg caps: Pair, + ): InferenceProvider = object : InferenceProvider { + override val id: ProviderId = TypeId(id) + override val name: String = id + override val tokenizer: Tokenizer = object : Tokenizer { + override suspend fun tokenize(text: String): List = emptyList() + override suspend fun countTokens(text: String): Int = 0 + } + override suspend fun infer(request: InferenceRequest): InferenceResponse = + error("not implemented in test") + override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy + override fun capabilities(): Set = + caps.map { (cap, score) -> CapabilityScore(cap, score) }.toSet() + } + + @Test + fun `listAll returns all registered providers`() { + val p1 = fakeProvider("p1", ModelCapability.Coding to 0.9) + val p2 = fakeProvider("p2", ModelCapability.General to 0.8) + val registry = DefaultProviderRegistry(listOf(p1, p2)) + assertEquals(2, registry.listAll().size) + } + + @Test + fun `register adds provider`() { + val registry = DefaultProviderRegistry() + val p = fakeProvider("p1", ModelCapability.Coding to 0.9) + registry.register(p) + assertEquals(1, registry.listAll().size) + } + + @Test + fun `resolve returns providers with matching capability ordered by score desc`() { + val low = fakeProvider("low", ModelCapability.Coding to 0.5) + val high = fakeProvider("high", ModelCapability.Coding to 0.9) + val other = fakeProvider("other", ModelCapability.General to 0.8) + val registry = DefaultProviderRegistry(listOf(low, high, other)) + + val resolved = registry.resolve(ModelCapability.Coding) + assertEquals(2, resolved.size) + assertEquals("high", resolved.first().id.value) + } + + @Test + fun `resolve returns empty list when no provider matches`() { + val registry = DefaultProviderRegistry(listOf(fakeProvider("p", ModelCapability.General to 1.0))) + assertTrue(registry.resolve(ModelCapability.Reasoning).isEmpty()) + } + + @Test + fun `healthCheckAll returns health for all providers`() = runTest { + val p1 = fakeProvider("p1", ModelCapability.Coding to 1.0) + val p2 = fakeProvider("p2", ModelCapability.General to 1.0) + val registry = DefaultProviderRegistry(listOf(p1, p2)) + val health = registry.healthCheckAll() + assertEquals(2, health.size) + assertTrue(health.values.all { it is ProviderHealth.Healthy }) + } +} diff --git a/infrastructure/inference/src/test/kotlin/com/correx/infrastructure/inference/FirstAvailableRoutingStrategyTest.kt b/infrastructure/inference/src/test/kotlin/com/correx/infrastructure/inference/FirstAvailableRoutingStrategyTest.kt new file mode 100644 index 00000000..d32fbabd --- /dev/null +++ b/infrastructure/inference/src/test/kotlin/com/correx/infrastructure/inference/FirstAvailableRoutingStrategyTest.kt @@ -0,0 +1,77 @@ +package com.correx.infrastructure.inference + +import com.correx.core.events.types.ProviderId +import com.correx.core.inference.CapabilityScore +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRequest +import com.correx.core.inference.InferenceResponse +import com.correx.core.inference.ModelCapability +import com.correx.core.inference.NoEligibleProviderException +import com.correx.core.inference.ProviderHealth +import com.correx.core.inference.Token +import com.correx.core.inference.Tokenizer +import com.correx.core.utils.TypeId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class FirstAvailableRoutingStrategyTest { + + private val strategy = FirstAvailableRoutingStrategy() + + private fun fakeProvider(id: String, vararg caps: ModelCapability): InferenceProvider = + object : InferenceProvider { + override val id: ProviderId = TypeId(id) + override val name: String = id + override val tokenizer: Tokenizer = object : Tokenizer { + override suspend fun tokenize(text: String): List = emptyList() + override suspend fun countTokens(text: String): Int = 0 + } + override suspend fun infer(request: InferenceRequest): InferenceResponse = + error("not implemented in test") + override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy + override fun capabilities(): Set = + caps.map { CapabilityScore(it, 1.0) }.toSet() + } + + @Test + fun `selects first provider satisfying all required capabilities`() { + val coding = fakeProvider("codingOnly", ModelCapability.Coding) + val general = fakeProvider("generalAndCoding", ModelCapability.General, ModelCapability.Coding) + val required = setOf(ModelCapability.Coding, ModelCapability.General) + + val selected = strategy.select(listOf(coding, general), required) + assertEquals("generalAndCoding", selected.id.value) + } + + @Test + fun `selects first matching provider when multiple satisfy requirements`() { + val p1 = fakeProvider("p1", ModelCapability.Coding) + val p2 = fakeProvider("p2", ModelCapability.Coding) + + val selected = strategy.select(listOf(p1, p2), setOf(ModelCapability.Coding)) + assertEquals("p1", selected.id.value) + } + + @Test + fun `throws NoEligibleProviderException when no candidate satisfies required capabilities`() { + val general = fakeProvider("general", ModelCapability.General) + assertThrows { + strategy.select(listOf(general), setOf(ModelCapability.Coding)) + } + } + + @Test + fun `throws NoEligibleProviderException when candidates list is empty`() { + assertThrows { + strategy.select(emptyList(), setOf(ModelCapability.Coding)) + } + } + + @Test + fun `selects any provider when required capabilities is empty`() { + val p = fakeProvider("p", ModelCapability.General) + val selected = strategy.select(listOf(p), emptySet()) + assertEquals("p", selected.id.value) + } +} diff --git a/infrastructure/persistence/build.gradle b/infrastructure/persistence/build.gradle new file mode 100644 index 00000000..6c271c8c --- /dev/null +++ b/infrastructure/persistence/build.gradle @@ -0,0 +1,13 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + implementation(project(":core:sessions")) + implementation "org.xerial:sqlite-jdbc" + testImplementation(testFixtures(project(":testing:contracts"))) + testImplementation "org.junit.jupiter:junit-jupiter" +} diff --git a/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/InMemoryEventStore.kt b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/InMemoryEventStore.kt new file mode 100644 index 00000000..f369a27e --- /dev/null +++ b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/InMemoryEventStore.kt @@ -0,0 +1,68 @@ +package com.correx.infrastructure.persistence + +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import java.util.concurrent.* +import java.util.concurrent.atomic.* + +class InMemoryEventStore : EventStore { + private val streams = ConcurrentHashMap>() + private val sequences = ConcurrentHashMap() + private val seenEventIds = ConcurrentHashMap.newKeySet() + + override fun append(event: NewEvent): StoredEvent { + val stream = streams.computeIfAbsent(event.metadata.sessionId) { mutableListOf() } + + synchronized(stream) { + if (!seenEventIds.add(event.metadata.eventId)) { + error("duplicate event_id: ${event.metadata.eventId}") + } + return doAppend(event, stream) + } + } + + override fun appendAll(events: List): List { + if (events.isEmpty()) return emptyList() + + val sessionId = events.first().metadata.sessionId + val stream = streams.computeIfAbsent(sessionId) { mutableListOf() } + + synchronized(stream) { + return events.map { doAppend(it, stream) } + } + } + + override fun read(sessionId: SessionId): List { + return streams[sessionId] + ?.sortedBy { it.sequence } + ?: emptyList() + } + + override fun readFrom(sessionId: SessionId, fromSequence: Long): List { + return streams[sessionId] + ?.filter { it.sequence > fromSequence } + ?.toList() + ?: emptyList() + } + + override fun lastSequence(sessionId: SessionId): Long? { + return sequences[sessionId]?.get() + } + + private fun doAppend(event: NewEvent, stream: MutableList): StoredEvent { + val seq = sequences.computeIfAbsent(event.metadata.sessionId) { AtomicLong(0) } + .incrementAndGet() + + val stored = StoredEvent(metadata = event.metadata, sequence = seq, payload = event.payload) + // safety: enforce ordering invariant + if (stream.isNotEmpty() && stream.last().sequence >= stored.sequence) { + error("sequence violation for session ${event.metadata.sessionId}") + } + stream.add(stored) + + return stored + } +} \ No newline at end of file diff --git a/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt new file mode 100644 index 00000000..7357763d --- /dev/null +++ b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt @@ -0,0 +1,215 @@ +package com.correx.infrastructure.persistence + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.serialization.JsonEventSerializer +import com.correx.core.events.serialization.eventJson +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.CausationId +import com.correx.core.events.types.CorrelationId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.infrastructure.persistence.util.JDBCHelper.transaction +import kotlinx.datetime.Instant +import java.sql.Connection +import java.sql.ResultSet + +class SqliteEventStore( + private val connection: Connection, + private val jsonSerializer: JsonEventSerializer = JsonEventSerializer(eventJson) +) : EventStore { + + init { + connection.createStatement().use { stmt -> + stmt.execute( + """ + CREATE TABLE IF NOT EXISTS events ( + event_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + timestamp TEXT NOT NULL, + schema_version INTEGER NOT NULL, + causation_id TEXT, + correlation_id TEXT, + payload TEXT NOT NULL + ); + """.trimIndent() + ) + } + } + + override fun append(event: NewEvent): StoredEvent { + return connection.transaction { + val existing = findByEventId(event.metadata.eventId) + check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } + + val seq = nextSequence(event.metadata.sessionId) + + val stored = StoredEvent( + metadata = event.metadata, + sequence = seq, + payload = event.payload + ) + + insert(stored) + + stored + } + } + + override fun appendAll(events: List): List { + if (events.isEmpty()) return emptyList() + + val sessionId = events.first().metadata.sessionId + + return connection.transaction { + events.map { event -> + val existing = findByEventId(event.metadata.eventId) + check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } + + val seq = nextSequence(sessionId) + + val stored = StoredEvent( + metadata = event.metadata, + sequence = seq, + payload = event.payload + ) + + insert(stored) + + stored + } + } + } + + override fun read(sessionId: SessionId): List = + connection.prepareStatement( + """ + SELECT * FROM events + WHERE session_id = ? + ORDER BY sequence ASC + """.trimIndent() + ).use { ps -> + ps.setString(1, sessionId.value) + + ps.executeQuery().use { rs -> + buildList { + while (rs.next()) { + add(map(rs)) + } + } + } + } + + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = + connection.prepareStatement( + """ + SELECT * FROM events + WHERE session_id = ? + AND sequence > ? + ORDER BY sequence ASC + """.trimIndent() + ).use { ps -> + ps.setString(1, sessionId.value) + ps.setLong(2, fromSequence) + + ps.executeQuery().use { rs -> + buildList { + while (rs.next()) { + add(map(rs)) + } + } + } + } + + override fun lastSequence(sessionId: SessionId): Long? = + connection.prepareStatement( + """ + SELECT MAX(sequence) + FROM events + WHERE session_id = ? + """.trimIndent() + ).use { ps -> + ps.setString(1, sessionId.value) + + ps.executeQuery().use { rs -> + if (rs.next()) rs.getLong(1).takeIf { !rs.wasNull() } + else null + } + } + + // ---------- helpers ---------- + + private fun nextSequence(sessionId: SessionId): Long = + connection.prepareStatement( + """ + SELECT COALESCE(MAX(sequence), 0) + FROM events + WHERE session_id = ? + """.trimIndent() + ).use { ps -> + ps.setString(1, sessionId.value) + ps.executeQuery().use { rs -> + rs.next() + rs.getLong(1) + 1 + } + } + + @Suppress("MagicNumber") + private fun insert(event: StoredEvent) { + connection.prepareStatement( + """ + INSERT INTO events ( + event_id, + session_id, + sequence, + timestamp, + schema_version, + causation_id, + correlation_id, + payload + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + ).use { ps -> + ps.setString(1, event.metadata.eventId.value) + ps.setString(2, event.metadata.sessionId.value) + ps.setLong(3, event.sequence) + ps.setString(4, event.metadata.timestamp.toString()) + ps.setInt(5, event.metadata.schemaVersion) + ps.setString(6, event.metadata.causationId?.value) + ps.setString(7, event.metadata.correlationId?.value) + ps.setString(8, jsonSerializer.serialize(event.payload)) + + ps.executeUpdate() + } + } + + private fun findByEventId(eventId: EventId): StoredEvent? = + connection.prepareStatement( + """ + SELECT * FROM events + WHERE event_id = ? + """.trimIndent() + ).use { ps -> + ps.setString(1, eventId.value) + + ps.executeQuery().use { rs -> + if (rs.next()) map(rs) else null + } + } + + private fun map(rs: ResultSet): StoredEvent = + StoredEvent( + metadata = EventMetadata( + eventId = EventId(rs.getString("event_id")), + sessionId = SessionId(rs.getString("session_id")), + timestamp = Instant.parse(rs.getString("timestamp")), + schemaVersion = rs.getInt("schema_version"), + causationId = rs.getString("causation_id")?.let { CausationId(it) }, + correlationId = rs.getString("correlation_id")?.let { CorrelationId(it) } + ), + sequence = rs.getLong("sequence"), + payload = jsonSerializer.deserialize(rs.getString("payload")) + ) +} \ No newline at end of file diff --git a/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/util/JDBCHelper.kt b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/util/JDBCHelper.kt new file mode 100644 index 00000000..f2a0fd0d --- /dev/null +++ b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/util/JDBCHelper.kt @@ -0,0 +1,23 @@ +package com.correx.infrastructure.persistence.util + +import java.sql.Connection +import java.sql.SQLException + +object JDBCHelper { + inline fun Connection.transaction(block: () -> T): T { + val oldAutoCommit = autoCommit + try { + autoCommit = false + + val result = block() + + commit() + return result + } catch (e: SQLException) { + rollback() + throw e + } finally { + autoCommit = oldAutoCommit + } + } +} \ No newline at end of file diff --git a/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/InMemoryEventStoreTest.kt b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/InMemoryEventStoreTest.kt new file mode 100644 index 00000000..ce965321 --- /dev/null +++ b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/InMemoryEventStoreTest.kt @@ -0,0 +1,8 @@ +package com.correx.infrastructure.persistence + +import com.correx.testing.contracts.fixtures.events.store.EventStoreContractTest +import com.correx.core.events.stores.EventStore + +class InMemoryEventStoreTest : EventStoreContractTest() { + override fun store(): EventStore = InMemoryEventStore() +} \ No newline at end of file diff --git a/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/InMemoryReplayTest.kt b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/InMemoryReplayTest.kt new file mode 100644 index 00000000..120d8c96 --- /dev/null +++ b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/InMemoryReplayTest.kt @@ -0,0 +1,23 @@ +package com.correx.infrastructure.persistence + +import com.correx.core.events.stores.EventStore +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.core.sessions.SessionCounterProjection +import com.correx.core.sessions.SessionCounterState +import com.correx.testing.contracts.fixtures.projections.SessionReplayContractTest + +class InMemoryReplayTest : SessionReplayContractTest() { + + override fun store(): EventStore = + InMemoryEventStore() + + override fun replayer( + store: EventStore + ): EventReplayer { + return DefaultEventReplayer( + store, + SessionCounterProjection("s1") + ) + } +} \ No newline at end of file diff --git a/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteEventStoreTest.kt b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteEventStoreTest.kt new file mode 100644 index 00000000..92fbd0ec --- /dev/null +++ b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteEventStoreTest.kt @@ -0,0 +1,12 @@ +package com.correx.infrastructure.persistence + +import com.correx.testing.contracts.fixtures.events.store.EventStoreContractTest +import com.correx.core.events.stores.EventStore +import java.sql.DriverManager + +class SqliteEventStoreTest : EventStoreContractTest() { + override fun store(): EventStore { + val conn = DriverManager.getConnection("jdbc:sqlite::memory:") + return SqliteEventStore(conn) + } +} \ No newline at end of file diff --git a/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteReplayTest.kt b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteReplayTest.kt new file mode 100644 index 00000000..14a584f2 --- /dev/null +++ b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteReplayTest.kt @@ -0,0 +1,28 @@ +package com.correx.infrastructure.persistence + +import com.correx.core.events.stores.EventStore +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.core.sessions.SessionCounterProjection +import com.correx.core.sessions.SessionCounterState +import com.correx.testing.contracts.fixtures.projections.SessionReplayContractTest +import java.sql.DriverManager + +class SqliteReplayTest : SessionReplayContractTest() { + + override fun store(): EventStore { + val connection = + DriverManager.getConnection("jdbc:sqlite::memory:") + + return SqliteEventStore(connection) + } + + override fun replayer( + store: EventStore + ): EventReplayer { + return DefaultEventReplayer( + store, + SessionCounterProjection("s1") + ) + } +} \ No newline at end of file diff --git a/infrastructure/scheduler/build.gradle b/infrastructure/scheduler/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/infrastructure/scheduler/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/infrastructure/scheduler/src/main/kotlin/com/correx/infrastructure/scheduler/Module.kt b/infrastructure/scheduler/src/main/kotlin/com/correx/infrastructure/scheduler/Module.kt new file mode 100644 index 00000000..4390fdc9 --- /dev/null +++ b/infrastructure/scheduler/src/main/kotlin/com/correx/infrastructure/scheduler/Module.kt @@ -0,0 +1,3 @@ +package com.correx.infrastructure.scheduler + +object Module diff --git a/infrastructure/security/build.gradle b/infrastructure/security/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/infrastructure/security/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/infrastructure/security/src/main/kotlin/com/correx/infrastructure/security/Module.kt b/infrastructure/security/src/main/kotlin/com/correx/infrastructure/security/Module.kt new file mode 100644 index 00000000..f668be80 --- /dev/null +++ b/infrastructure/security/src/main/kotlin/com/correx/infrastructure/security/Module.kt @@ -0,0 +1,3 @@ +package com.correx.infrastructure.security + +object Module diff --git a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt new file mode 100644 index 00000000..17236ed1 --- /dev/null +++ b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt @@ -0,0 +1,68 @@ +package com.correx.infrastructure + +import com.correx.core.approvals.domain.ApprovalEngine +import com.correx.core.events.EventDispatcher +import com.correx.core.events.stores.EventStore +import com.correx.core.inference.DefaultInferenceRouter +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRouter +import com.correx.core.inference.ProviderRegistry +import com.correx.core.inference.RoutingStrategy +import com.correx.infrastructure.inference.DefaultProviderRegistry +import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.registry.ToolRegistry +import com.correx.infrastructure.inference.commons.ModelManager +import com.correx.infrastructure.inference.llama.cpp.DefaultModelManager +import com.correx.infrastructure.persistence.SqliteEventStore +import com.correx.infrastructure.tools.DefaultToolRegistry +import com.correx.infrastructure.tools.DispatchingToolExecutor +import com.correx.infrastructure.tools.SandboxedToolExecutor +import com.correx.infrastructure.tools.ToolConfig +import com.correx.infrastructure.tools.buildTools +import com.correx.infrastructure.tools.filesystem.FileEditTool +import com.correx.infrastructure.tools.filesystem.FileReadTool +import com.correx.infrastructure.tools.filesystem.FileWriteTool +import com.correx.infrastructure.tools.shell.ShellTool +import io.ktor.client.HttpClient +import java.nio.file.Path +import java.sql.DriverManager + +object InfrastructureModule { + fun createEventStore(dbPath: String): EventStore = + SqliteEventStore(DriverManager.getConnection("jdbc:sqlite:$dbPath")) + + fun createModelManager( + eventStore: EventStore, + httpClient: HttpClient, + eventDispatcher: EventDispatcher, + ): ModelManager = DefaultModelManager( + eventStore = eventStore, + httpClient = httpClient, + eventDispatcher = eventDispatcher, + ) + + fun createToolRegistry(config: ToolConfig): ToolRegistry = + DefaultToolRegistry.build(config.buildTools()) + + fun createInferenceRouter( + providers: List = emptyList(), + registry: ProviderRegistry = DefaultProviderRegistry(providers), + strategy: RoutingStrategy = FirstAvailableRoutingStrategy(), + ): InferenceRouter = DefaultInferenceRouter(registry, strategy) + + fun createToolExecutor( + registry: ToolRegistry, + approvalEngine: ApprovalEngine, + eventStore: EventStore, + eventDispatcher: EventDispatcher, + workDir: Path, + ): ToolExecutor = SandboxedToolExecutor( + delegate = DispatchingToolExecutor(registry), + registry = registry, + approvalEngine = approvalEngine, + eventStore = eventStore, + eventDispatcher = eventDispatcher, + workDir = workDir, + ) +} \ No newline at end of file diff --git a/infrastructure/telemetry/build.gradle b/infrastructure/telemetry/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/infrastructure/telemetry/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/infrastructure/telemetry/src/main/kotlin/com/correx/infrastructure/telemetry/Module.kt b/infrastructure/telemetry/src/main/kotlin/com/correx/infrastructure/telemetry/Module.kt new file mode 100644 index 00000000..17c21994 --- /dev/null +++ b/infrastructure/telemetry/src/main/kotlin/com/correx/infrastructure/telemetry/Module.kt @@ -0,0 +1,3 @@ +package com.correx.infrastructure.telemetry + +object Module diff --git a/infrastructure/tools/build.gradle b/infrastructure/tools/build.gradle new file mode 100644 index 00000000..47f43e5b --- /dev/null +++ b/infrastructure/tools/build.gradle @@ -0,0 +1,13 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation project(":core:tools") + implementation project(":core:events") + implementation project(":core:approvals") + implementation project(":core:sessions") + implementation project(":infrastructure:tools:filesystem") +} diff --git a/infrastructure/tools/filesystem/build.gradle b/infrastructure/tools/filesystem/build.gradle new file mode 100644 index 00000000..ec31807c --- /dev/null +++ b/infrastructure/tools/filesystem/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation project(':core:tools') + implementation project(':core:events') + implementation project(':core:approvals') +} diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt new file mode 100644 index 00000000..11d33cc0 --- /dev/null +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt @@ -0,0 +1,231 @@ +package com.correx.infrastructure.tools.filesystem + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.contract.FileAffectingTool +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardOpenOption + +@Suppress("TooManyFunctions") +class FileEditTool( + allowedPaths: Set = emptySet(), +) : Tool, FileAffectingTool, ToolExecutor { + private val normalizedAllowedPaths: Set = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet() + + 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 { + val pathString = request.parameters["path"] as? String ?: return emptySet() + return setOf(Paths.get(pathString).normalize().toAbsolutePath()) + } + + override fun validateRequest(request: ToolRequest): ValidationResult { + val operation = request.parameters["operation"] as? String + val pathString = request.parameters["path"] as? String + + return when { + operation == null -> + ValidationResult.Invalid("Missing 'operation' parameter. Expected 'append', 'replace', or 'patch'.") + + pathString == null -> + ValidationResult.Invalid("Missing 'path' parameter. Expected String.") + + else -> runCatching { + val path = Paths.get(pathString).normalize().toAbsolutePath() + checkPathAllowed(path, pathString, operation, request) + }.getOrElse { e -> + mapExceptionToValidationResult(e) + } + } + } + + private fun checkPathAllowed( + path: Path, + pathString: String, + operation: String, + request: ToolRequest, + ): ValidationResult { + return when { + normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.") + !isPathAllowed(path) -> + ValidationResult.Invalid("Path '$pathString' is not in the allowed list.") + + !Files.exists(path) -> + ValidationResult.Invalid("File not found: $pathString") + + operation == "append" && !request.parameters.containsKey("content") -> + ValidationResult.Invalid("Missing 'content' parameter for 'append' operation.") + + operation == "replace" && ( + !request.parameters.containsKey("target") || + !request.parameters.containsKey("replacement") + ) -> + ValidationResult.Invalid("Missing 'target' or 'replacement' parameter for 'replace' operation.") + + operation == "patch" && !request.parameters.containsKey("patch") -> + ValidationResult.Invalid("Missing 'patch' parameter for 'patch' operation.") + + operation !in listOf("append", "replace", "patch") -> + ValidationResult.Invalid("Unknown operation: $operation") + + else -> ValidationResult.Valid + } + } + + private fun isPathAllowed(path: Path): Boolean = + normalizedAllowedPaths.any { allowedPath -> + path.startsWith(allowedPath) + } + + private fun mapExceptionToValidationResult(e: Throwable): ValidationResult = + when (e) { + is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}") + is IOException -> ValidationResult.Invalid("IO error: ${e.message}") + is SecurityException -> ValidationResult.Invalid("Security error: ${e.message}") + else -> ValidationResult.Invalid(e.message ?: "Unknown error occurred") + } + + override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) { + val validation = validateRequest(request) + if (validation is ValidationResult.Invalid) { + return@withContext ToolResult.Failure( + invocationId = request.invocationId, + reason = validation.reason, + recoverable = false, + ) + } + + val operation = request.parameters["operation"] as String + val pathString = request.parameters["path"] as String + val path = Paths.get(pathString).normalize().toAbsolutePath() + + runCatching { + performOperation(operation, path, pathString, request) + }.getOrElse { e -> + handleExecutionException(e, request.invocationId, pathString) + } + } + + private fun performOperation( + operation: String, + path: Path, + pathString: String, + request: ToolRequest, + ): ToolResult = when (operation) { + "append" -> append(path, request) + "replace" -> replace(path, pathString, request) + "patch" -> patch(path, pathString, request) + else -> ToolResult.Failure( + invocationId = request.invocationId, + reason = "Unknown operation: $operation", + recoverable = false, + ) + } + + private fun append(path: Path, request: ToolRequest): ToolResult { + val content = request.parameters["content"] as String + Files.writeString(path, content, StandardOpenOption.APPEND) + return ToolResult.Success( + invocationId = request.invocationId, + output = "Content appended to ${path.toAbsolutePath()}", + ) + } + + private fun replace(path: Path, pathString: String, request: ToolRequest): ToolResult { + val target = request.parameters["target"] as String + val replacement = request.parameters["replacement"] as String + val currentContent = Files.readString(path) + + val occurrences = currentContent.split(target).size - 1 + return if (occurrences == 1) { + val newContent = currentContent.replace(target, replacement) + Files.writeString(path, newContent) + ToolResult.Success( + invocationId = request.invocationId, + output = "Target replaced in $pathString", + ) + } else { + ToolResult.Failure( + invocationId = request.invocationId, + reason = if (occurrences == 0) { + "Target '$target' not found in $pathString" + } else { + "Target '$target' found $occurrences times, expected exactly once" + }, + recoverable = false, + ) + } + } + + private fun patch(path: Path, pathString: String, request: ToolRequest): ToolResult { + val patchContent = request.parameters["patch"] as String + val parentDir = path.parent ?: Paths.get(".") + val fileName = path.fileName.toString() + + val process = ProcessBuilder("patch", "-p1", fileName) + .directory(parentDir.toFile()) + .redirectErrorStream(true) + .start() + + return try { + process.outputStream.use { it.write(patchContent.toByteArray()) } + val output = process.inputStream.bufferedReader().use { it.readText() } + val exitCode = process.waitFor() + + if (exitCode == 0) { + ToolResult.Success( + invocationId = request.invocationId, + output = "Patch applied successfully to $pathString: $output", + ) + } else { + ToolResult.Failure( + invocationId = request.invocationId, + reason = "Patch failed with exit code $exitCode: $output", + recoverable = false, + ) + } + } finally { + process.destroy() + } + } + + private fun handleExecutionException( + e: Throwable, + invocationId: ToolInvocationId, + pathString: String, + ): ToolResult = when (e) { + is CancellationException -> throw e + is IOException -> ToolResult.Failure( + invocationId = invocationId, + reason = "IO error: ${e.message}", + recoverable = false, + ) + + is SecurityException -> ToolResult.Failure( + invocationId = invocationId, + reason = "Access denied: $pathString, ${e.message}", + recoverable = false, + ) + + else -> ToolResult.Failure( + invocationId = invocationId, + reason = e.message ?: "Unknown error occurred", + recoverable = false, + ) + } +} diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt new file mode 100644 index 00000000..42257f1e --- /dev/null +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt @@ -0,0 +1,97 @@ +package com.correx.infrastructure.tools.filesystem + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path +import java.nio.file.Paths + +class FileReadTool( + allowedPaths: Set = emptySet(), +) : Tool, ToolExecutor { + private val normalizedAllowedPaths: Set = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet() + + 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 = + (request.parameters["path"] as? String)?.let { pathString -> + runCatching { + val path = Paths.get(pathString).normalize().toAbsolutePath() + val isAllowed = normalizedAllowedPaths.isEmpty() || path in normalizedAllowedPaths + || normalizedAllowedPaths.any { path.startsWith(it) } + + ValidationResult.Valid.takeIf { isAllowed } + ?: ValidationResult.Invalid("Path '$pathString' is not in the allowed list.") + }.getOrElse { + when (it) { + is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}") + is IOException -> ValidationResult.Invalid("IO error: ${it.message}") + is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}") + else -> ValidationResult.Invalid(it.message ?: "Unknown error occured") + } + } + } ?: ValidationResult.Invalid("Missing or invalid 'path' parameter. Expected String.") + + override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) { + validateRequest(request).run { + takeIf { it is ValidationResult.Valid }?.let { + val pathString = request.parameters["path"] as String + val path = Paths.get(pathString).normalize().toAbsolutePath() + if (Files.exists(path)) { + readStringCatching(request, path, pathString) + } else { + ToolResult.Failure( + invocationId = request.invocationId, + reason = "File not found: $pathString", + recoverable = false, + ) + } + } ?: ToolResult.Failure( + invocationId = request.invocationId, + reason = (this as ValidationResult.Invalid).reason, + recoverable = false, + ) + } + } + + private fun readStringCatching(request: ToolRequest, path: Path, pathString: String): ToolResult = + runCatching { + ToolResult.Success( + invocationId = request.invocationId, + output = Files.readString(path), + ) + }.getOrElse { + when (it) { + is CancellationException -> throw it + is SecurityException -> ToolResult.Failure( + invocationId = request.invocationId, + reason = "Access denied: $pathString, ${it.message}", + recoverable = false, + ) + + is IOException -> ToolResult.Failure( + invocationId = request.invocationId, + reason = "IO error: ${it.message}", + recoverable = false, + ) + + else -> ToolResult.Failure( + invocationId = request.invocationId, + reason = it.message ?: "Unknown error occurred while reading file", + recoverable = false, + ) + } + } +} diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt new file mode 100644 index 00000000..f9648a2b --- /dev/null +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt @@ -0,0 +1,172 @@ +package com.correx.infrastructure.tools.filesystem + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.contract.FileAffectingTool +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path +import java.nio.file.Paths + +class FileWriteTool( + allowedPaths: Set = emptySet(), +) : Tool, FileAffectingTool, ToolExecutor { + private val normalizedAllowedPaths: Set = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet() + + 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 { + val pathString = request.parameters["path"] as? String ?: return emptySet() + return setOf(Paths.get(pathString).normalize().toAbsolutePath()) + } + + override fun validateRequest(request: ToolRequest): ValidationResult { + val operation = request.parameters["operation"] as? String + val pathString = request.parameters["path"] as? String + + return when { + operation == null -> + ValidationResult.Invalid("Missing 'operation' parameter. Expected 'write' or 'delete'.") + + pathString == null -> + ValidationResult.Invalid("Missing 'path' parameter. Expected String.") + + else -> runCatching { + val path = Paths.get(pathString).normalize().toAbsolutePath() + checkPathAllowed(path, pathString, operation, request) + }.getOrElse { e -> + mapExceptionToValidationResult(e) + } + } + } + + private fun checkPathAllowed( + path: Path, + pathString: String, + operation: String, + request: ToolRequest, + ): ValidationResult { + return when { + normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.") + !isPathAllowed(path) -> + ValidationResult.Invalid("Path '$pathString' is not in the allowed list.") + + operation == "write" && !request.parameters.containsKey("content") -> + ValidationResult.Invalid("Missing 'content' parameter for 'write' operation.") + + operation != "write" && operation != "delete" -> + ValidationResult.Invalid("Unknown operation: $operation") + + else -> ValidationResult.Valid + } + } + + private fun isPathAllowed(path: Path): Boolean = + normalizedAllowedPaths.any { allowedPath -> + path.startsWith(allowedPath) + } + + private fun mapExceptionToValidationResult(e: Throwable): ValidationResult = + when (e) { + is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}") + is IOException -> ValidationResult.Invalid("IO error: ${e.message}") + is SecurityException -> ValidationResult.Invalid("Security error: ${e.message}") + else -> ValidationResult.Invalid(e.message ?: "Unknown error occurred") + } + + override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) { + val validation = validateRequest(request) + if (validation is ValidationResult.Invalid) { + return@withContext ToolResult.Failure( + invocationId = request.invocationId, + reason = validation.reason, + recoverable = false, + ) + } + + val operation = request.parameters["operation"] as String + val pathString = request.parameters["path"] as String + val path = Paths.get(pathString).normalize().toAbsolutePath() + + runCatching { + performOperation(operation, path, pathString, request) + }.getOrElse { e -> + handleExecutionException(e, request.invocationId, pathString) + } + } + + private fun performOperation( + operation: String, + path: Path, + pathString: String, + request: ToolRequest, + ): ToolResult = when (operation) { + "write" -> { + val content = request.parameters["content"] as String + Files.writeString(path, content) + ToolResult.Success( + invocationId = request.invocationId, + output = "File written successfully to $pathString", + ) + } + + "delete" -> { + if (Files.exists(path)) { + Files.deleteIfExists(path) + ToolResult.Success( + invocationId = request.invocationId, + output = "File deleted successfully: $pathString", + ) + } else { + ToolResult.Failure( + invocationId = request.invocationId, + reason = "File not found: $pathString", + recoverable = false, + ) + } + } + + else -> ToolResult.Failure( + invocationId = request.invocationId, + reason = "Unknown operation: $operation", + recoverable = false, + ) + } + + private fun handleExecutionException( + e: Throwable, + invocationId: ToolInvocationId, + pathString: String, + ): ToolResult = when (e) { + is CancellationException -> throw e + is IOException -> ToolResult.Failure( + invocationId = invocationId, + reason = "IO error: ${e.message}", + recoverable = false, + ) + + is SecurityException -> ToolResult.Failure( + invocationId = invocationId, + reason = "Access denied: $pathString, ${e.message}", + recoverable = false, + ) + + else -> ToolResult.Failure( + invocationId = invocationId, + reason = e.message ?: "Unknown error occurred", + recoverable = false, + ) + } +} diff --git a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileEditToolTest.kt b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileEditToolTest.kt new file mode 100644 index 00000000..b04a9a2a --- /dev/null +++ b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileEditToolTest.kt @@ -0,0 +1,234 @@ +package com.correx.infrastructure.tools.filesystem + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.events.events.ToolRequest +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import java.util.UUID + +class FileEditToolTest { + + private val sessionId = SessionId(UUID.randomUUID().toString()) + private val stageId = StageId(UUID.randomUUID().toString()) + private val invocationId = ToolInvocationId(UUID.randomUUID().toString()) + + private fun createRequest( + parameters: Map, + toolName: String = "file_edit", + ): ToolRequest { + return ToolRequest( + invocationId = invocationId, + sessionId = sessionId, + stageId = stageId, + toolName = toolName, + parameters = parameters, + ) + } + + @Test + fun `execute patch success`() = runBlocking { + val tempDir = Files.createTempDirectory("file_edit_patch") + val filePath = tempDir.resolve("test.txt") + Files.writeString(filePath, "line1\nline2\nline3\n") + val tool = FileEditTool(allowedPaths = setOf(tempDir)) + + // Patch to replace line2 with line2_modified + val patchContent = """ + --- a/test.txt + +++ b/test.txt + @@ -2 +2 @@ + -line2 + +line2_modified + """.trimIndent() + "\n" + + val request = createRequest( + mapOf( + "operation" to "patch", + "path" to filePath.toString(), + "patch" to patchContent, + ), + ) + + val result = tool.execute(request) + assertTrue(result is ToolResult.Success, "Expected Success but got $result") + val content = Files.readString(filePath) + assertEquals("line1\nline2_modified\nline3\n", content) + } + + @Test + fun `validateRequest returns Invalid for disallowed path`() = runBlocking { + val tempDir = Files.createTempDirectory("file_edit_test") + val otherDir = Files.createTempDirectory("other_dir") + val tool = FileEditTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "append", + "path" to otherDir.resolve("file.txt").toString(), + "content" to "hello", + ), + ) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertTrue((result as ValidationResult.Invalid).reason.contains("is not in the allowed list")) + } + + @Test + fun `validateRequest returns Invalid for non-existent file`() = runBlocking { + val tempDir = Files.createTempDirectory("file_edit_test") + val tool = FileEditTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "append", + "path" to tempDir.resolve("non_existent.txt").toString(), + "content" to "hello", + ), + ) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertTrue((result as ValidationResult.Invalid).reason.contains("File not found")) + } + + @Test + fun `validateRequest returns Invalid for missing parameters`() = runBlocking { + val tempDir = Files.createTempDirectory("file_edit_test") + val filePath = tempDir.resolve("test.txt") + Files.writeString(filePath, "content") + val tool = FileEditTool(allowedPaths = setOf(tempDir)) + + // Missing operation + val req1 = createRequest(mapOf("path" to filePath.toString())) + assertTrue(tool.validateRequest(req1) is ValidationResult.Invalid) + + // Missing content for append + val req2 = createRequest(mapOf("operation" to "append", "path" to filePath.toString())) + assertTrue(tool.validateRequest(req2) is ValidationResult.Invalid) + + // Missing target/replacement for replace + val req3 = createRequest(mapOf("operation" to "replace", "path" to filePath.toString(), "target" to "content")) + assertTrue(tool.validateRequest(req3) is ValidationResult.Invalid) + + // Missing patch for patch + val req4 = createRequest(mapOf("operation" to "patch", "path" to filePath.toString())) + assertTrue(tool.validateRequest(req4) is ValidationResult.Invalid) + } + + @Test + fun `execute append success`() = runBlocking { + val tempDir = Files.createTempDirectory("file_edit_append") + val filePath = tempDir.resolve("test.txt") + Files.writeString(filePath, "hello") + val tool = FileEditTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "append", + "path" to filePath.toString(), + "content" to " world", + ), + ) + + val result = tool.execute(request) + assertTrue(result is ToolResult.Success) + assertEquals("hello world", Files.readString(filePath)) + } + + @Test + fun `execute replace success`() = runBlocking { + val tempDir = Files.createTempDirectory("file_edit_replace") + val filePath = tempDir.resolve("test.txt") + Files.writeString(filePath, "the quick brown fox") + val tool = FileEditTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "replace", + "path" to filePath.toString(), + "target" to "brown", + "replacement" to "red", + ), + ) + + val result = tool.execute(request) + assertTrue(result is ToolResult.Success) + assertEquals("the quick red fox", Files.readString(filePath)) + } + + @Test + fun `execute replace failure zero matches`() = runBlocking { + val tempDir = Files.createTempDirectory("file_edit_replace_fail") + val filePath = tempDir.resolve("test.txt") + Files.writeString(filePath, "the quick brown fox") + val tool = FileEditTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "replace", + "path" to filePath.toString(), + "target" to "green", + "replacement" to "red", + ), + ) + + val result = tool.execute(request) + assertTrue(result is ToolResult.Failure) + val failure = result as ToolResult.Failure + assertTrue(failure.reason.contains("not found")) + } + + @Test + fun `execute replace failure multiple matches`() = runBlocking { + val tempDir = Files.createTempDirectory("file_edit_replace_fail_multi") + val filePath = tempDir.resolve("test.txt") + Files.writeString(filePath, "fox fox fox") + val tool = FileEditTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "replace", + "path" to filePath.toString(), + "target" to "fox", + "replacement" to "dog", + ), + ) + + val result = tool.execute(request) + assertTrue(result is ToolResult.Failure) + val failure = result as ToolResult.Failure + assertTrue(failure.reason.contains("found 3 times")) + } + + + @Test + fun `execute patch failure`() = runBlocking { + val tempDir = Files.createTempDirectory("file_edit_patch_fail") + val filePath = tempDir.resolve("test.txt") + Files.writeString(filePath, "original") + val tool = FileEditTool(allowedPaths = setOf(tempDir)) + + val patchContent = """ + --- test.txt + +++ test.txt + @@ -1 +1 @@ + -nonexistent + +something + """.trimIndent() + + val request = createRequest( + mapOf( + "operation" to "patch", + "path" to filePath.toString(), + "patch" to patchContent, + ), + ) + + val result = tool.execute(request) + assertTrue(result is ToolResult.Failure) + val failure = result as ToolResult.Failure + assertTrue(failure.reason.contains("Patch failed")) + } +} diff --git a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileReadToolTest.kt b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileReadToolTest.kt new file mode 100644 index 00000000..a9522644 --- /dev/null +++ b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileReadToolTest.kt @@ -0,0 +1,112 @@ +package com.correx.infrastructure.tools.filesystem + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import com.correx.core.events.events.ToolRequest +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.util.UUID + +class FileReadToolTest { + + private val sessionId = SessionId(UUID.randomUUID().toString()) + private val stageId = StageId(UUID.randomUUID().toString()) + private val invocationId = ToolInvocationId(UUID.randomUUID().toString()) + + private fun createRequest(path: String): ToolRequest { + return ToolRequest( + invocationId = invocationId, + sessionId = sessionId, + stageId = stageId, + toolName = "file_read", + parameters = mapOf("path" to path), + ) + } + + @Test + fun `validateRequest returns Valid for allowed path`() = runBlocking { + val tool = FileReadTool(allowedPaths = emptySet()) + val tempFile = Files.createTempFile("test_allowed", ".txt") + val request = createRequest(tempFile.toString()) + assertEquals(ValidationResult.Valid, tool.validateRequest(request)) + } + + @Test + fun `validateRequest returns Invalid for disallowed path`() = runBlocking { + val tempFile = Files.createTempFile("test_disallowed", ".txt") + val tool = FileReadTool(allowedPaths = setOf(tempFile.parent.resolve("subdir"))) + val request = createRequest(tempFile.toString()) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertTrue((result as ValidationResult.Invalid).reason.contains("not in the allowed list")) + } + + @Test + fun `validateRequest returns Invalid for missing path parameter`() = runBlocking { + val tool = FileReadTool() + val request = ToolRequest( + invocationId = invocationId, + sessionId = sessionId, + stageId = stageId, + toolName = "file_read", + parameters = emptyMap(), + ) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertEquals( + "Missing or invalid 'path' parameter. Expected String.", + (result as ValidationResult.Invalid).reason, + ) + } + + @Test + fun `execute returns Success for valid file`() = runBlocking { + val content = "Hello, world!" + val tempFile = Files.createTempFile("test_content", ".txt") + Files.writeString(tempFile, content) + + val tool = FileReadTool(allowedPaths = emptySet()) + val request = createRequest(tempFile.toString()) + val result = tool.execute(request) + + assertTrue(result is ToolResult.Success) + val success = result as ToolResult.Success + assertEquals(content, success.output) + assertEquals(invocationId, success.invocationId) + } + + @Test + fun `execute returns Failure for non-existent file`() = runBlocking { + val tempDir = Files.createTempDirectory("test_dir") + val nonExistentFile = tempDir.resolve("missing.txt") + + val tool = FileReadTool(allowedPaths = setOf(nonExistentFile)) + val request = createRequest(nonExistentFile.toString()) + val result = tool.execute(request) + + assertTrue(result is ToolResult.Failure) + val failure = result as ToolResult.Failure + assertTrue(failure.reason.contains("File not found")) + assertFalse(failure.recoverable) + } + + @Test + fun `execute returns Failure for disallowed path`() = runBlocking { + val tempFile = Files.createTempFile("test_disallowed_exec", ".txt") + val tool = FileReadTool(allowedPaths = setOf(tempFile.parent.resolve("other"))) + val request = createRequest(tempFile.toString()) + val result = tool.execute(request) + + // It should fail at validation stage + assertTrue(result is ToolResult.Failure) + val failure = result as ToolResult.Failure + assertTrue(failure.reason.contains("is not in the allowed list")) + } +} diff --git a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteToolTest.kt b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteToolTest.kt new file mode 100644 index 00000000..bc101616 --- /dev/null +++ b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteToolTest.kt @@ -0,0 +1,238 @@ +package com.correx.infrastructure.tools.filesystem + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import com.correx.core.events.events.ToolRequest +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import java.util.UUID + +class FileWriteToolTest { + + private val sessionId = SessionId(UUID.randomUUID().toString()) + private val stageId = StageId(UUID.randomUUID().toString()) + private val invocationId = ToolInvocationId(UUID.randomUUID().toString()) + + private fun createRequest( + parameters: Map, + toolName: String = "file_write", + ): ToolRequest { + return ToolRequest( + invocationId = invocationId, + sessionId = sessionId, + stageId = stageId, + toolName = toolName, + parameters = parameters, + ) + } + + @Test + fun `validateRequest returns Valid for allowed path and valid operation`() = runBlocking { + val tempDir = Files.createTempDirectory("file_write_test") + val tool = FileWriteTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "write", + "path" to tempDir.resolve("file.txt").toString(), + "content" to "hello", + ), + ) + assertEquals(ValidationResult.Valid, tool.validateRequest(request)) + } + + @Test + fun `validateRequest returns Invalid for disallowed path`() = runBlocking { + val tempDir = Files.createTempDirectory("file_write_test") + val otherDir = Files.createTempDirectory("other_dir") + val tool = FileWriteTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "write", + "path" to otherDir.resolve("file.txt").toString(), + "content" to "hello", + ), + ) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertTrue((result as ValidationResult.Invalid).reason.contains("is not in the allowed list")) + } + + @Test + fun `validateRequest returns Invalid for missing operation`() = runBlocking { + val tempDir = Files.createTempDirectory("file_write_test") + val tool = FileWriteTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "path" to tempDir.resolve("file.txt").toString(), + "content" to "hello", + ), + ) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertEquals( + "Missing 'operation' parameter. Expected 'write' or 'delete'.", + (result as ValidationResult.Invalid).reason, + ) + } + + @Test + fun `validateRequest returns Invalid for missing path`() = runBlocking { + val tempDir = Files.createTempDirectory("file_write_test") + val tool = FileWriteTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "write", + "content" to "hello", + ), + ) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertEquals("Missing 'path' parameter. Expected String.", (result as ValidationResult.Invalid).reason) + } + + @Test + fun `validateRequest returns Invalid for missing content on write`() = runBlocking { + val tempDir = Files.createTempDirectory("file_write_test") + val tool = FileWriteTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "write", + "path" to tempDir.resolve("file.txt").toString(), + ), + ) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertEquals("Missing 'content' parameter for 'write' operation.", (result as ValidationResult.Invalid).reason) + } + + @Test + fun `validateRequest returns Invalid for unknown operation`() = runBlocking { + val tempDir = Files.createTempDirectory("file_write_test") + val tool = FileWriteTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "unknown", + "path" to tempDir.resolve("file.txt").toString(), + ), + ) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertEquals("Unknown operation: unknown", (result as ValidationResult.Invalid).reason) + } + + @Test + fun `execute returns Success for write operation`() = runBlocking { + val tempDir = Files.createTempDirectory("file_write_test") + val tool = FileWriteTool(allowedPaths = setOf(tempDir)) + val filePath = tempDir.resolve("test.txt") + val content = "Hello, FileWriteTool!" + val request = createRequest( + mapOf( + "operation" to "write", + "path" to filePath.toString(), + "content" to content, + ), + ) + + val result = tool.execute(request) + + assertTrue(result is ToolResult.Success) + val success = result as ToolResult.Success + assertEquals("File written successfully to ${filePath}", success.output) + assertEquals(invocationId, success.invocationId) + assertEquals(content, Files.readString(filePath)) + } + + @Test + fun `execute returns Success for delete operation`() = runBlocking { + val tempDir = Files.createTempDirectory("file_write_test") + val tool = FileWriteTool(allowedPaths = setOf(tempDir)) + val filePath = tempDir.resolve("to_delete.txt") + Files.writeString(filePath, "content to delete") + + val request = createRequest( + mapOf( + "operation" to "delete", + "path" to filePath.toString(), + ), + ) + + println("TempDir: $tempDir") + println("FilePath: $filePath") + println("PathString from request: ${request.parameters["path"]}") + val result = tool.execute(request) + println("Result: $result") + println("Exists after: ${Files.exists(filePath)}") + assertTrue(result is ToolResult.Success) + val success = result as ToolResult.Success + assertEquals("File deleted successfully: $filePath", success.output) + assertEquals(invocationId, success.invocationId) + assertFalse(Files.exists(filePath)) + } + + @Test + fun `execute returns Failure for deleting non-existent file`() = runBlocking { + val tempDir = Files.createTempDirectory("file_write_test") + val tool = FileWriteTool(allowedPaths = setOf(tempDir)) + val filePath = tempDir.resolve("non_existent.txt") + + val request = createRequest( + mapOf( + "operation" to "delete", + "path" to filePath.toString(), + ), + ) + + val result = tool.execute(request) + + assertTrue(result is ToolResult.Failure) + val failure = result as ToolResult.Failure + assertEquals("File not found: $filePath", failure.reason) + assertFalse(failure.recoverable) + } + + @Test + fun `execute returns Failure for disallowed path`() = runBlocking { + val tempDir = Files.createTempDirectory("file_write_test") + val otherDir = Files.createTempDirectory("other_dir") + val tool = FileWriteTool(allowedPaths = setOf(tempDir)) + val filePath = otherDir.resolve("illegal.txt") + + val request = createRequest( + mapOf( + "operation" to "write", + "path" to filePath.toString(), + "content" to "illegal", + ), + ) + + val result = tool.execute(request) + + assertTrue(result is ToolResult.Failure) + val failure = result as ToolResult.Failure + assertTrue(failure.reason.contains("is not in the allowed list")) + } + + @Test + fun `validateRequest returns Valid for existing file in allowed directory`() = runBlocking { + val tempDir = Files.createTempDirectory("file_write_test_existing") + val filePath = tempDir.resolve("existing.txt") + Files.writeString(filePath, "content") + val tool = FileWriteTool(allowedPaths = setOf(tempDir)) + val request = createRequest( + mapOf( + "operation" to "delete", + "path" to filePath.toString(), + ), + ) + assertEquals(ValidationResult.Valid, tool.validateRequest(request)) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/DefaultToolRegistry.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/DefaultToolRegistry.kt new file mode 100644 index 00000000..280d08ec --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/DefaultToolRegistry.kt @@ -0,0 +1,19 @@ +package com.correx.infrastructure.tools + +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.registry.ToolRegistry + +class DefaultToolRegistry private constructor( + private val tools: Map, +) : ToolRegistry { + override fun resolve(name: String): Tool? = tools[name] + override fun all(): List = tools.values.toList() + + companion object { + fun build(vararg tools: Tool): ToolRegistry = + DefaultToolRegistry(tools.associateBy { it.name }) + + fun build(tools: List): ToolRegistry = + DefaultToolRegistry(tools.associateBy { it.name }) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/DispatchingToolExecutor.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/DispatchingToolExecutor.kt new file mode 100644 index 00000000..cd57529c --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/DispatchingToolExecutor.kt @@ -0,0 +1,26 @@ +package com.correx.infrastructure.tools + +import com.correx.core.events.events.ToolRequest +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.registry.ToolRegistry + +class DispatchingToolExecutor( + private val registry: ToolRegistry +) : ToolExecutor { + override suspend fun execute(request: ToolRequest): ToolResult { + val tool = registry.resolve(request.toolName) + ?: return ToolResult.Failure( + invocationId = request.invocationId, + reason = "tool not found: ${request.toolName}", + recoverable = false + ) + + return (tool as? ToolExecutor)?.execute(request) + ?: ToolResult.Failure( + invocationId = request.invocationId, + reason = "tool ${request.toolName} does not support execution", + recoverable = false + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt new file mode 100644 index 00000000..43847032 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt @@ -0,0 +1,267 @@ +package com.correx.infrastructure.tools + +import com.correx.core.approvals.Tier +import com.correx.core.approvals.domain.ApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.approvals.model.DomainApprovalRequest +import com.correx.core.events.EventDispatcher +import com.correx.core.events.events.ApprovalGrantCreatedEvent +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolExecutionFailedEvent +import com.correx.core.events.events.ToolExecutionRejectedEvent +import com.correx.core.events.events.ToolExecutionStartedEvent +import com.correx.core.events.events.ToolReceipt +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.events.types.ValidationReportId +import com.correx.core.sessions.ApprovalMode +import com.correx.core.tools.contract.FileAffectingTool +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.registry.ToolRegistry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.* + +@SuppressWarnings("TooManyFunctions", "LongParameterList") +class SandboxedToolExecutor( + private val delegate: ToolExecutor, + private val registry: ToolRegistry, + private val approvalEngine: ApprovalEngine, + private val eventStore: EventStore, + private val eventDispatcher: EventDispatcher, + private val workDir: Path = Path.of("/tmp/correx-sandbox"), +) : ToolExecutor { + + override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) { + val sessionId = request.sessionId + val invocationId = request.invocationId + val toolName = request.toolName + val startTime = System.currentTimeMillis() + + // 1. resolve tool + val tool = registry.resolve(toolName) + ?: return@withContext ToolResult.Failure( + invocationId = invocationId, + reason = "tool not found: $toolName", + recoverable = false, + ) + + // 2. approval check for T2-T4 + val requiresApproval = when (tool.tier) { + Tier.T0, Tier.T1 -> false + Tier.T2, Tier.T3, Tier.T4 -> true + } + + if (requiresApproval) { + val sessionEvents = eventStore.read(sessionId) + val decision = evaluateApproval(tool, sessionId, request.stageId, sessionEvents) + if (!decision.isApproved) { + emitRejected(sessionId, invocationId, toolName, tool.tier, decision.reason ?: "approval denied") + return@withContext ToolResult.Failure( + invocationId = invocationId, + reason = decision.reason ?: "approval denied", + recoverable = false, + ) + } + } + + // 3. emit started + emitStarted(sessionId, invocationId, toolName) + + // 4. create working dir + val workingDir = workDir.resolve(sessionId.value).resolve(invocationId.value) + Files.createDirectories(workingDir) + + // 5. compute affected paths once (A3: avoid double-invocation divergence) + val affectedPaths: Set = if (tool is FileAffectingTool) tool.affectedPaths(request) else emptySet() + + // 6. backup affected files + val backupMap: Map = try { + backupAffectedFiles(affectedPaths, workingDir) + } catch (e: IOException) { + return@withContext ToolResult.Failure( + invocationId = invocationId, + reason = "backup failed: ${e.message}", + recoverable = false, + ) + } + + // 7. delegate + val durationMs = { System.currentTimeMillis() - startTime } + + when (val result = delegate.execute(request)) { + is ToolResult.Success -> { + restoreOrClean(backupMap, success = true) + cleanWorkingDir(workingDir) + emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs()) + result + } + + is ToolResult.Failure -> { + restoreOrClean(backupMap, success = false) + cleanWorkingDir(workingDir) + emitFailed(sessionId, invocationId, toolName, result.reason) + result + } + } + } + + // --- approval --- + + private fun evaluateApproval( + tool: Tool, + sessionId: SessionId, + stageId: StageId, + sessionEvents: List, + ) = approvalEngine.evaluate( + request = DomainApprovalRequest( + id = ApprovalRequestId(UUID.randomUUID().toString()), + tier = tool.tier, + validationReportId = ValidationReportId(UUID.randomUUID().toString()), + riskSummaryId = null, + timestamp = Clock.System.now(), + causationId = null, + correlationId = null, + ), + context = ApprovalContext( + identity = ApprovalScopeIdentity(sessionId, stageId, null), + mode = ApprovalMode.PROMPT, + ), + grants = extractGrants(sessionEvents), + now = Clock.System.now(), + ) + + private fun extractGrants(events: List): List = + events + .mapNotNull { it.payload as? ApprovalGrantCreatedEvent } + .map { e -> + ApprovalGrant( + id = e.grantId, + scope = e.scope, + permittedTiers = e.permittedTiers, + reason = e.reason, + timestamp = Clock.System.now(), + expiresAt = e.expiresAt, + ) + } + + // --- backup/restore --- + + /** + * Returns a map of originalPath -> backupPath for all affected files. + * Throws IOException if any backup fails — caller handles this. + */ + private fun backupAffectedFiles( + affectedPaths: Collection, + workingDir: Path, + ): Map = buildMap { + for (original in affectedPaths) { + // A1: skip files that don't yet exist (new-file tools have nothing to back up) + if (!Files.exists(original)) continue + val backupPath = workingDir.resolve("${original.fileName}.bak") + Files.copy(original, backupPath, StandardCopyOption.REPLACE_EXISTING) + put(original, backupPath) + } + } + + /** + * On success: delete backups. + * On failure: restore originals from backups. + */ + private fun restoreOrClean(backupMap: Map, success: Boolean) { + for ((original, backup) in backupMap) { + try { + if (success) { + Files.deleteIfExists(backup) + } else { + Files.move(backup, original, StandardCopyOption.REPLACE_EXISTING) + } + } catch (_: IOException) { + // best effort + } + } + } + + private fun cleanWorkingDir(workingDir: Path) { + try { + Files.walk(workingDir) + .sorted(Comparator.reverseOrder()) + .forEach { Files.deleteIfExists(it) } + } catch (_: IOException) { + // best effort + } + } + + // --- event emission --- + + private suspend fun emitRejected( + sessionId: SessionId, + invocationId: ToolInvocationId, + toolName: String, + tier: Tier, + reason: String, + ) = emit(sessionId, ToolExecutionRejectedEvent(invocationId, sessionId, toolName, tier, reason)) + + private suspend fun emitStarted( + sessionId: SessionId, + invocationId: ToolInvocationId, + toolName: String, + ) = emit(sessionId, ToolExecutionStartedEvent(invocationId, sessionId, toolName)) + + private suspend fun emitCompleted( + sessionId: SessionId, + invocationId: ToolInvocationId, + toolName: String, + result: ToolResult.Success, + tool: Tool, + affectedPaths: Set, + durationMs: Long, + ) { + val affectedEntities = affectedPaths.map { it.toString() } + emit( + sessionId, + ToolExecutionCompletedEvent( + invocationId = invocationId, + sessionId = sessionId, + toolName = toolName, + receipt = ToolReceipt( + invocationId = invocationId, + toolName = toolName, + exitCode = result.exitCode, + outputSummary = result.output, + structuredOutput = result.metadata, + affectedEntities = affectedEntities, + durationMs = durationMs, + tier = tool.tier, + timestamp = Clock.System.now(), + ), + ), + ) + } + + private suspend fun emitFailed( + sessionId: SessionId, + invocationId: ToolInvocationId, + toolName: String, + reason: String, + ) = emit(sessionId, ToolExecutionFailedEvent(invocationId, sessionId, toolName, reason)) + + private suspend fun emit(sessionId: SessionId, payload: EventPayload) { + eventDispatcher.emit(payload, sessionId) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt new file mode 100644 index 00000000..f2543cf7 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt @@ -0,0 +1,46 @@ +package com.correx.infrastructure.tools + +import com.correx.core.tools.contract.Tool +import com.correx.infrastructure.tools.filesystem.FileEditTool +import com.correx.infrastructure.tools.filesystem.FileReadTool +import com.correx.infrastructure.tools.filesystem.FileWriteTool +import com.correx.infrastructure.tools.shell.ShellTool +import java.nio.file.Path + +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()) + +/** + * Extension function to convert [ToolConfig] into a list of [Tool] implementations. + */ +fun ToolConfig.buildTools(): List = buildList { + if (shell.enabled) { + add(ShellTool( + allowedExecutables = shell.allowedExecutables + )) + } + if (fileRead.enabled) { + add(FileReadTool( + allowedPaths = fileRead.allowedPaths + )) + } + if (fileWrite.enabled) { + add(FileWriteTool( + allowedPaths = fileWrite.allowedPaths + )) + } + if (fileEdit.enabled) { + add(FileEditTool( + allowedPaths = fileEdit.allowedPaths + )) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt new file mode 100644 index 00000000..fbd48907 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt @@ -0,0 +1,93 @@ +package com.correx.infrastructure.tools.shell + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout + +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 { + val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance() + return when { + argv.isNullOrEmpty() -> + ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List.") + argv[0] !in allowedExecutables -> + ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.") + else -> ValidationResult.Valid + } + } + + override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) { + validateRequest(request).run { + takeIf { this is ValidationResult.Valid }?.let { + val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance() + val process = ProcessBuilder(argv).apply { + environment().clear() + }.start() + runCatching { + runCmd(request, process) + }.getOrElse { + process.destroyForcibly() + if (it is TimeoutCancellationException) { + ToolResult.Failure( + invocationId = request.invocationId, + reason = "Process timed out after ${timeoutMs}ms", + recoverable = false, + ) + } else { + ToolResult.Failure( + invocationId = request.invocationId, + reason = it.message ?: "Unknown error occurred during execution", + recoverable = false, + ) + } + } + } ?: ToolResult.Failure( + invocationId = request.invocationId, + reason = (this as ValidationResult.Invalid).reason, + recoverable = false, + ) + } + } + + private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) { + val stdoutDeferred = async { process.inputStream.bufferedReader().use { it.readText() } } + val stderrDeferred = async { process.errorStream.bufferedReader().use { it.readText() } } + + val exitCode = process.waitFor() + val stdout = stdoutDeferred.await() + val stderr = stderrDeferred.await() + + val metadata = if (stderr.isNotBlank()) mapOf("stderr" to stderr) else emptyMap() + if (exitCode != 0) { + ToolResult.Failure( + invocationId = request.invocationId, + reason = "Process exited with code $exitCode${if (stderr.isNotBlank()) ": $stderr" else ""}", + recoverable = false, + ) + } else { + ToolResult.Success( + invocationId = request.invocationId, + output = stdout, + exitCode = exitCode, + metadata = metadata, + ) + } + } +} diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt new file mode 100644 index 00000000..3a5cf90f --- /dev/null +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt @@ -0,0 +1,115 @@ +package com.correx.infrastructure.tools.shell + +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.UUID + +class ShellToolTest { + + private val sessionId = SessionId(UUID.randomUUID().toString()) + private val stageId = StageId(UUID.randomUUID().toString()) + private val invocationId = ToolInvocationId(UUID.randomUUID().toString()) + + private fun createRequest(argv: List): ToolRequest { + return ToolRequest( + invocationId = invocationId, + sessionId = sessionId, + stageId = stageId, + toolName = "shell", + parameters = mapOf("argv" to argv), + ) + } + + @Test + fun `validateRequest returns Valid for allowed executable`() = runBlocking { + val tool = ShellTool(allowedExecutables = setOf("echo")) + val request = createRequest(listOf("echo", "hello")) + assertEquals(ValidationResult.Valid, tool.validateRequest(request)) + } + + @Test + fun `validateRequest returns Invalid for disallowed executable`() = runBlocking { + val tool = ShellTool(allowedExecutables = setOf("echo")) + val request = createRequest(listOf("ls")) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertEquals("Executable 'ls' is not in the allowed list.", (result as ValidationResult.Invalid).reason) + } + + @Test + fun `validateRequest returns Invalid for empty argv`() = runBlocking { + val tool = ShellTool(allowedExecutables = setOf("echo")) + val request = createRequest(emptyList()) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertEquals( + "Missing or empty 'argv' parameter. Expected List.", + (result as ValidationResult.Invalid).reason, + ) + } + + @Test + fun `validateRequest returns Invalid for null argv`() = runBlocking { + val tool = ShellTool(allowedExecutables = setOf("echo")) + val request = ToolRequest( + invocationId = invocationId, + sessionId = sessionId, + stageId = stageId, + toolName = "shell", + parameters = emptyMap(), + ) + val result = tool.validateRequest(request) + assertTrue(result is ValidationResult.Invalid) + assertEquals( + "Missing or empty 'argv' parameter. Expected List.", + (result as ValidationResult.Invalid).reason, + ) + } + + @Test + fun `execute returns Success for exit code 0`() = runBlocking { + val tool = ShellTool(allowedExecutables = setOf("echo")) + val request = createRequest(listOf("echo", "hello")) + val result = tool.execute(request) + + assertTrue(result is ToolResult.Success) + val success = result as ToolResult.Success + assertTrue(success.output.startsWith("hello")) + assertEquals(0, success.exitCode) + assertEquals(invocationId, success.invocationId) + } + + @Test + fun `execute returns Failure with non-zero exit code for failed process`() = runBlocking { + val tool = ShellTool(allowedExecutables = setOf("false")) + val request = createRequest(listOf("false")) + val result = tool.execute(request) + + assertTrue(result is ToolResult.Failure) + val failure = result as ToolResult.Failure + assertTrue(failure.reason.contains("exited with code 1")) + assertEquals(invocationId, failure.invocationId) + } + + @Test + fun `execute returns Failure on timeout`() = runBlocking { + // Using sleep command to simulate timeout + val tool = ShellTool(allowedExecutables = setOf("sleep"), timeoutMs = 100L) + val request = createRequest(listOf("sleep", "1")) + val result = tool.execute(request) + + assertTrue(result is ToolResult.Failure) + val failure = result as ToolResult.Failure + assertEquals("Process timed out after 100ms", failure.reason) + assertFalse(failure.recoverable) + } +} diff --git a/interfaces/api/build.gradle b/interfaces/api/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/interfaces/api/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/interfaces/api/src/main/kotlin/com/correx/interfaces/api/Module.kt b/interfaces/api/src/main/kotlin/com/correx/interfaces/api/Module.kt new file mode 100644 index 00000000..e6a4e8a6 --- /dev/null +++ b/interfaces/api/src/main/kotlin/com/correx/interfaces/api/Module.kt @@ -0,0 +1,3 @@ +package com.correx.interfaces.api + +object Module diff --git a/interfaces/cli/build.gradle b/interfaces/cli/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/interfaces/cli/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/interfaces/cli/src/main/kotlin/com/correx/interfaces/cli/Module.kt b/interfaces/cli/src/main/kotlin/com/correx/interfaces/cli/Module.kt new file mode 100644 index 00000000..1630b446 --- /dev/null +++ b/interfaces/cli/src/main/kotlin/com/correx/interfaces/cli/Module.kt @@ -0,0 +1,3 @@ +package com.correx.interfaces.cli + +object Module diff --git a/interfaces/sdk/build.gradle b/interfaces/sdk/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/interfaces/sdk/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/interfaces/sdk/src/main/kotlin/com/correx/interfaces/sdk/Module.kt b/interfaces/sdk/src/main/kotlin/com/correx/interfaces/sdk/Module.kt new file mode 100644 index 00000000..0b8c78a6 --- /dev/null +++ b/interfaces/sdk/src/main/kotlin/com/correx/interfaces/sdk/Module.kt @@ -0,0 +1,3 @@ +package com.correx.interfaces.sdk + +object Module diff --git a/plugins/compressors/build.gradle b/plugins/compressors/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/plugins/compressors/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/plugins/compressors/src/main/kotlin/com/correx/plugins/compressors/Module.kt b/plugins/compressors/src/main/kotlin/com/correx/plugins/compressors/Module.kt new file mode 100644 index 00000000..2b7d1126 --- /dev/null +++ b/plugins/compressors/src/main/kotlin/com/correx/plugins/compressors/Module.kt @@ -0,0 +1,3 @@ +package com.correx.plugins.compressors + +object Module diff --git a/plugins/policies/build.gradle b/plugins/policies/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/plugins/policies/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/plugins/policies/src/main/kotlin/com/correx/plugins/policies/Module.kt b/plugins/policies/src/main/kotlin/com/correx/plugins/policies/Module.kt new file mode 100644 index 00000000..99b1bfab --- /dev/null +++ b/plugins/policies/src/main/kotlin/com/correx/plugins/policies/Module.kt @@ -0,0 +1,3 @@ +package com.correx.plugins.policies + +object Module diff --git a/plugins/providers/build.gradle b/plugins/providers/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/plugins/providers/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/plugins/providers/src/main/kotlin/com/correx/plugins/providers/Module.kt b/plugins/providers/src/main/kotlin/com/correx/plugins/providers/Module.kt new file mode 100644 index 00000000..3a035432 --- /dev/null +++ b/plugins/providers/src/main/kotlin/com/correx/plugins/providers/Module.kt @@ -0,0 +1,3 @@ +package com.correx.plugins.providers + +object Module diff --git a/plugins/stages/build.gradle b/plugins/stages/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/plugins/stages/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/plugins/stages/src/main/kotlin/com/correx/plugins/stages/Module.kt b/plugins/stages/src/main/kotlin/com/correx/plugins/stages/Module.kt new file mode 100644 index 00000000..7fccb6f1 --- /dev/null +++ b/plugins/stages/src/main/kotlin/com/correx/plugins/stages/Module.kt @@ -0,0 +1,3 @@ +package com.correx.plugins.stages + +object Module diff --git a/plugins/tools/build.gradle b/plugins/tools/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/plugins/tools/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/plugins/tools/src/main/kotlin/com/correx/plugins/tools/Module.kt b/plugins/tools/src/main/kotlin/com/correx/plugins/tools/Module.kt new file mode 100644 index 00000000..827cce10 --- /dev/null +++ b/plugins/tools/src/main/kotlin/com/correx/plugins/tools/Module.kt @@ -0,0 +1,3 @@ +package com.correx.plugins.tools + +object Module diff --git a/plugins/transitions/build.gradle b/plugins/transitions/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/plugins/transitions/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/plugins/transitions/src/main/kotlin/com/correx/plugins/transitions/Module.kt b/plugins/transitions/src/main/kotlin/com/correx/plugins/transitions/Module.kt new file mode 100644 index 00000000..c76c9771 --- /dev/null +++ b/plugins/transitions/src/main/kotlin/com/correx/plugins/transitions/Module.kt @@ -0,0 +1,3 @@ +package com.correx.plugins.transitions + +object Module diff --git a/plugins/validators/build.gradle b/plugins/validators/build.gradle new file mode 100644 index 00000000..39d908e1 --- /dev/null +++ b/plugins/validators/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} diff --git a/plugins/validators/src/main/kotlin/com/correx/plugins/validators/Module.kt b/plugins/validators/src/main/kotlin/com/correx/plugins/validators/Module.kt new file mode 100644 index 00000000..e98d3400 --- /dev/null +++ b/plugins/validators/src/main/kotlin/com/correx/plugins/validators/Module.kt @@ -0,0 +1,3 @@ +package com.correx.plugins.validators + +object Module diff --git a/prompts/bootstrap.md b/prompts/bootstrap.md new file mode 100644 index 00000000..5f0ff3e9 --- /dev/null +++ b/prompts/bootstrap.md @@ -0,0 +1,413 @@ +# Correx Context Bootstrap + +--- + +## 1. module architecture + +```text +❯ tree -L 2 +. +├── apps +│ ├── cli +│ ├── desktop +│ ├── server +│ └── worker +├── build +│ └── reports +├── build.gradle +├── core +│ ├── agents +│ ├── approvals +│ ├── artifacts +│ ├── config +│ ├── context +│ ├── events +│ ├── inference +│ ├── kernel +│ ├── observability +│ ├── policies +│ ├── router +│ ├── sessions +│ ├── stages +│ ├── tools +│ ├── transitions +│ └── validation +├── detekt.yml +├── docs +│ ├── architecture +│ ├── configs +│ ├── decisions +│ ├── des-doc-v0.1.md +│ ├── epics.md +│ ├── events +│ ├── example.yaml +│ ├── lang-framewok-missing-pieces.md +│ ├── modules +│ ├── plugins +│ ├── spec-v0.1.md +│ ├── structure.md +│ ├── threat_model +│ └── transitions +├── epics +│ ├── epic-1.5.md +│ ├── epic-1.md +│ ├── epic-2.md +│ └── epic-3.md +├── examples +│ ├── configs +│ ├── plugins +│ ├── stages +│ └── workflows +├── frontend +│ ├── src +│ └── static +├── gradle +│ └── wrapper +├── gradle.properties +├── gradlew +├── gradlew.bat +├── infrastructure +│ ├── inference +│ ├── persistence +│ ├── scheduler +│ ├── security +│ ├── telemetry +│ └── tools +├── interfaces +│ ├── api +│ ├── cli +│ └── sdk +├── plugins +│ ├── compressors +│ ├── policies +│ ├── providers +│ ├── stages +│ ├── tools +│ ├── transitions +│ └── validators +├── settings.gradle +└── testing + ├── approvals + ├── contracts + ├── deterministic + ├── fixtures + ├── integration + ├── projections + ├── replay + └── transitions +``` + +--- + +## dependency rules (important) + +* core modules → no infrastructure dependencies + +* infrastructure → implements core interfaces + +* testing/contracts → defines reusable invariants + +* core/events = event system foundation + +* core/sessions = deterministic event-sourced lifecycle projection layer + +* core/transitions = deterministic workflow graph evaluation engine + +* projections are domain-specific folds over event streams + +* replay engine stays generic and domain-agnostic + +* transition engine is pure evaluation logic (no orchestration) + +--- + +## 2. current epic + +```text +Epic 4: Validation pipeline + +goal: deterministic multi-layer validation system for workflows, sessions, and transitions + +deliverables: + +- routing validation layer (graph + transition correctness) +- schema validation layer (event + config integrity) +- semantic validation hooks (domain rules without execution coupling) +- validation pipeline executor (ordered deterministic execution) +- approval trigger integration point (event-based, no direct execution coupling) + +dependencies: + +- Epic 1 (event system + replay foundation) +- Epic 2 (session projection layer) +- Epic 3 (workflow transition engine) +``` + +--- + +## 3. implementation state + +```text +READY TO START + +Completed before Epic 4: + +- append-only EventStore with strict ordering guarantees +- polymorphic EventPayload serialization (kotlinx.serialization) +- deterministic replay infrastructure (EventReplayer + Projection fold model) +- session lifecycle projection (SessionProjector + SessionReducer) +- FSM abstraction replaced by deterministic reducer semantics +- workflow graph model (WorkflowGraph, TransitionEdge, StageId) +- deterministic transition evaluation engine (resolver + condition evaluation) +- cycle detection + canonicalization (DFS-based graph analysis) +- stage execution contract (execution isolated from orchestration) +- workflow execution events integrated into event stream +- session ↔ transition integration via event-driven projection model +- full replay determinism across session + workflow execution + +``` + +--- + +## 4. key design decisions (do not violate) + +```text +- EventPayload uses kotlinx.serialization polymorphic serialization +- EventStore is append-only per session stream +- sequence is strictly monotonic per session +- projections are pure functions: (state, event) -> new state +- replay must be deterministic across implementations +- core/events contains replay infrastructure only + +- core/sessions contains event-driven lifecycle projection (no FSM coupling) +- SessionProjector is a deterministic reducer over StoredEvent stream +- SessionReducer replaces FSM semantics (no imperative state transitions) + +- core/transitions is a pure workflow graph evaluation engine +- transitions do NOT execute runtime logic +- transitions do NOT schedule or orchestrate execution +- transitions only emit execution-relevant events + +- workflow graph is immutable and unordered (Set-based topology) +- determinism is enforced by resolver, not input structure + +- cycle detection is analysis-only in Epic 3 (no runtime policy enforcement yet) + +- Event stream is the single source of truth for all system state + +- projections MUST NOT perform IO, side effects, or runtime clock access +- createdAt/updatedAt derived strictly from EventMetadata timestamps + +- same event stream MUST always reconstruct identical system state +``` + +--- + +## 5. current invariants + +```text +- no mutation outside store/projection boundaries +- ordering is enforced at EventStore level +- idempotency enforced by eventId +- replay must be deterministic for identical event streams + +- projections are stateless deterministic reducers +- projections MUST NOT depend on external mutable state +- projections MUST NOT perform side effects + +- event sequence is authoritative ordering source +- event stream is the only system truth source + +- session state is fully derived from replay +- session state is never persisted directly + +- workflow execution is event-driven and replay-safe +- transition evaluation must be deterministic and context-bound + +- workflow topology is independent from execution semantics +- graph structure does not define execution order + +- cycles are structurally allowed but not yet policy-governed +``` + +--- + +## 6. current task + +```text +Epic 4 implementation: + +Design and implement deterministic validation pipeline over workflow + session + transition layers. + +Focus areas: + +1. validate workflow graph correctness (structural + cycle awareness hooks) +2. validate transition definitions (conditions + reachability consistency) +3. validate session-event consistency against workflow semantics +4. introduce ordered validation pipeline executor +5. define extension points for semantic validation rules +6. ensure validation is replay-safe and deterministic + +Important constraint: +validation MUST NOT mutate state or trigger execution +it only produces deterministic validation results over existing models +``` + +--- + +## 7. relevant code + +no new core code required at bootstrap level beyond existing: + +```kotlin +data class WorkflowGraph( + val stages: Set, + val transitions: Set, + val start: StageId +) { + init { + require(start in stages) { + "start stage must exist in stages" + } + } +} +``` + +```kotlin +class DefaultTransitionResolver( + private val evaluator: TransitionConditionEvaluator +) : TransitionResolver { + + override fun resolve( + graph: WorkflowGraph, + context: EvaluationContext + ): TransitionDecision { + val outgoing = graph.transitions + .asSequence() + .filter { it.from == context.currentStage } + .sortedWith(TransitionOrdering.comparator) + .toList() + + for (edge in outgoing) { + val result = evaluator.evaluate(edge.condition, context) + if (result) { + return TransitionDecision.Move( + transitionId = edge.id, + to = edge.to + ) + } + } + return TransitionDecision.Stay + } +} +``` +```kotlin +class SessionProjector( + private val reducer: SessionReducer +) : Projection { + + override fun initial(): SessionState = + SessionState( + status = SessionStatus.CREATED + ) + + override fun apply( + state: SessionState, + event: StoredEvent + ): SessionState = reducer.reduce(state, event) +} +``` + +```kotlin +class DefaultSessionReducer : SessionReducer { + + override fun reduce( + state: SessionState, + event: StoredEvent + ): SessionState { + + val payload = event.payload + + val newStatus = when (payload) { + + is SessionStartedEvent -> + SessionStatus.ACTIVE + + is SessionPausedEvent -> + SessionStatus.PAUSED + + is SessionResumedEvent -> + SessionStatus.ACTIVE + + is SessionCompletedEvent -> + SessionStatus.COMPLETED + + is SessionFailedEvent, + is StageFailedEvent -> + SessionStatus.FAILED + + is StageStartedEvent, + is StageCompletedEvent, + is TransitionExecutedEvent -> + SessionStatus.ACTIVE + + else -> + state.status + } + + val createdAt = state.createdAt + ?: event.metadata.timestamp + + return state.copy( + status = newStatus, + createdAt = createdAt, + updatedAt = event.metadata.timestamp + ) + } +} +``` + +```kotlin +class DefaultEventReplayer( + private val store: EventStore, + private val projection: Projection +) : EventReplayer { + + override fun rebuild(sessionId: SessionId): S { + val events = store.read(sessionId) + + return DefaultStateBuilder(projection) + .build(events) + } +} +``` + +```kotlin +internal class CycleExtractor( + private val adjacencyBuilder: DeterministicAdjacencyBuilder, + private val dfs: CycleDfs, +) { + + fun extract(graph: WorkflowGraph): List { + + val adjacency = adjacencyBuilder.build(graph) + + val state = mutableMapOf() + val path = mutableListOf() + + val cycles = mutableListOf() + + val sortedNodes = graph.stages + .sortedBy { it.value } + + for (node in sortedNodes) { + if (state[node] == null) { + dfs.dfs(node, adjacency, state, path, cycles) + } + } + + return canonicalize(cycles) + } +} +``` +--- diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..108f2707 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,56 @@ +rootProject.name = 'correx' + +include ':apps:cli' +include ':apps:server' +include ':apps:worker' +include ':apps:desktop' + +include ':core:kernel' +include ':core:events' +include ':core:transitions' +include ':core:context' +include ':core:inference' +include ':core:stages' +include ':core:agents' +include ':core:artifacts' +include ':core:validation' +include ':core:approvals' +include ':core:tools' +include ':core:router' +include ':core:sessions' +include ':core:policies' +include ':core:observability' +include ':core:config' +include ':core:risk' + +include ':infrastructure:persistence' +include ':infrastructure:inference' +include ':infrastructure:inference:commons' +include ':infrastructure:inference:llama_cpp' +include ':infrastructure:tools' +include ':infrastructure:tools:filesystem' +include ':infrastructure:security' +include ':infrastructure:scheduler' +include ':infrastructure:telemetry' + +include ':interfaces:api' +include ':interfaces:cli' +include ':interfaces:sdk' + +include ':plugins:tools' +include ':plugins:validators' +include ':plugins:compressors' +include ':plugins:providers' +include ':plugins:transitions' +include ':plugins:stages' +include ':plugins:policies' + +include ':testing:replay' +include ':testing:contracts' +include ':testing:kernel' +include ':testing:integration' +include ':testing:fixtures' +include ':testing:projections' +include ':testing:transitions' +include ':testing:approvals' +include ':testing:deterministic' diff --git a/testing/approvals/build.gradle b/testing/approvals/build.gradle new file mode 100644 index 00000000..311af523 --- /dev/null +++ b/testing/approvals/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + testImplementation(project(":core:events")) + testImplementation(project(":core:sessions")) + testImplementation(project(":core:transitions")) + testImplementation(project(":core:validation")) + testImplementation(project(":core:approvals")) + testImplementation(project(":infrastructure:persistence")) + testImplementation(project(":testing:fixtures")) +} diff --git a/testing/approvals/src/main/kotlin/com/correx/testing/approvals/Module.kt b/testing/approvals/src/main/kotlin/com/correx/testing/approvals/Module.kt new file mode 100644 index 00000000..404f1c3e --- /dev/null +++ b/testing/approvals/src/main/kotlin/com/correx/testing/approvals/Module.kt @@ -0,0 +1,3 @@ +package com.correx.testing.approvals + +object Module diff --git a/testing/approvals/src/test/kotlin/ApprovalEngineEdgeCasesTest.kt b/testing/approvals/src/test/kotlin/ApprovalEngineEdgeCasesTest.kt new file mode 100644 index 00000000..08d98a27 --- /dev/null +++ b/testing/approvals/src/test/kotlin/ApprovalEngineEdgeCasesTest.kt @@ -0,0 +1,88 @@ +import com.correx.core.approvals.Tier +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.GrantScope +import com.correx.core.events.types.GrantId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.sessions.ApprovalMode +import com.correx.testing.fixtures.request +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ApprovalEngineEdgeCasesTest { + + private val engine = DefaultApprovalEngine() + private val now = Instant.parse("2026-01-01T00:00:01Z") + + private fun context(mode: ApprovalMode) = ApprovalContext( + ApprovalScopeIdentity(SessionId("s1"), StageId("st1"), ProjectId("p1")), + mode + ) + + @Test + fun `grants from different scopes apply only to matching identity`() { + val sessionGrant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION, + permittedTiers = setOf(Tier.T3), + reason = "", + timestamp = Instant.parse("2026-01-01T00:00:00Z") + ) + val stageGrant = ApprovalGrant( + id = GrantId("g2"), + scope = GrantScope.STAGE(StageId("st2")), + permittedTiers = setOf(Tier.T4), + reason = "", + timestamp = Instant.parse("2026-01-01T00:00:00Z") + ) + + val decision = engine.evaluate( + request("r1", Tier.T4), + context(ApprovalMode.DENY), + listOf(sessionGrant, stageGrant), + now + ) + assertEquals( + ApprovalStatus.PENDING, decision.state, + "Stage grant for different stage should not apply" + ) + } + + @Test + fun `expired grant is ignored`() { + val past = Instant.parse("2025-12-31T23:59:59Z") + val expiredGrant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION, + permittedTiers = setOf(Tier.T2), + reason = "", + timestamp = past, + expiresAt = past // expired before 'now' + ) + val decision = engine.evaluate( + request("r1", Tier.T2), + context(ApprovalMode.DENY), + listOf(expiredGrant), + now + ) + assertEquals(ApprovalStatus.PENDING, decision.state) + } + + @Test + fun `timestamp passed to evaluate is used as resolution timestamp`() { + val myNow = Instant.parse("2026-07-01T12:00:00Z") + val decision = engine.evaluate( + request("r1", Tier.T0), + context(ApprovalMode.YOLO), + emptyList(), + myNow + ) + assertEquals(myNow, decision.resolutionTimestamp) + } +} diff --git a/testing/approvals/src/test/kotlin/ApprovalTriggerTest.kt b/testing/approvals/src/test/kotlin/ApprovalTriggerTest.kt new file mode 100644 index 00000000..579d556a --- /dev/null +++ b/testing/approvals/src/test/kotlin/ApprovalTriggerTest.kt @@ -0,0 +1,50 @@ +import com.correx.core.validation.approval.ApprovalTrigger +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationReport +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.model.ValidationSeverity +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class ApprovalTriggerTest { + + private val trigger = ApprovalTrigger() + + @Test + fun `should trigger approval on error`() { + + val report = ValidationReport( + sections = listOf( + ValidationSection( + name = "graph", + issues = listOf( + ValidationIssue( + code = "GRAPH_DANGLING_TRANSITION", + message = "error", + severity = ValidationSeverity.ERROR, + ), + ), + ), + ), + ) + + val result = trigger.evaluate(report) + + assertNotNull(result) + } + + @Test + fun `should not trigger approval on clean report`() { + + val report = ValidationReport( + sections = listOf( + ValidationSection("graph", emptyList()), + ), + ) + + val result = trigger.evaluate(report) + + assertNull(result) + } +} \ No newline at end of file diff --git a/testing/approvals/src/test/kotlin/GrantSemanticsTest.kt b/testing/approvals/src/test/kotlin/GrantSemanticsTest.kt new file mode 100644 index 00000000..77ed9d17 --- /dev/null +++ b/testing/approvals/src/test/kotlin/GrantSemanticsTest.kt @@ -0,0 +1,92 @@ +import com.correx.core.approvals.Tier +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.GrantScope +import com.correx.core.events.types.GrantId +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.ApprovalMode +import com.correx.testing.fixtures.request +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class GrantSemanticsTest { + + private val engine = DefaultApprovalEngine() + + @Test + fun `grant permits exact tier - auto_approved`() { + val req = request("r1", Tier.T2) + val grant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION, + permittedTiers = setOf(Tier.T2), + reason = "test", + timestamp = Instant.parse("2026-01-01T00:00:00Z") + ) + val ctx = ApprovalContext( + ApprovalScopeIdentity(SessionId("s1"), null, null), + ApprovalMode.DENY + ) + val now = Instant.parse("2026-01-01T00:00:01Z") + + val decision = engine.evaluate(req, ctx, listOf(grant), now) + assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome) + assertEquals(ApprovalStatus.COMPLETED, decision.state) + } + + @Test + fun `grant does not permit other tiers`() { + val req = request("r1", Tier.T3) + val grant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION, + permittedTiers = setOf(Tier.T2), + reason = "test", + timestamp = Instant.parse("2026-01-01T00:00:00Z") + ) + val ctx = ApprovalContext( + ApprovalScopeIdentity(SessionId("s1"), null, null), + ApprovalMode.DENY + ) + val now = Instant.parse("2026-01-01T00:00:01Z") + + val decision = engine.evaluate(req, ctx, listOf(grant), now) + assertEquals(ApprovalStatus.PENDING, decision.state) + assertNull(decision.outcome) + } + + @Test + fun `multiple grants - any match is enough`() { + val req = request("r1", Tier.T4) + val grants = listOf( + ApprovalGrant( + GrantId("g1"), + GrantScope.SESSION, + setOf(Tier.T3), + "", + Instant.parse("2026-01-01T00:00:00Z") + ), + ApprovalGrant( + GrantId("g2"), + GrantScope.SESSION, + setOf(Tier.T4), + "", + Instant.parse("2026-01-01T00:00:00Z") + ) + ) + val ctx = ApprovalContext( + ApprovalScopeIdentity(SessionId("s1"), null, null), + ApprovalMode.DENY + ) + val now = Instant.parse("2026-01-01T00:00:01Z") + + val decision = engine.evaluate(req, ctx, grants, now) + assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome) + } +} \ No newline at end of file diff --git a/testing/approvals/src/test/kotlin/ModeApprovalTest.kt b/testing/approvals/src/test/kotlin/ModeApprovalTest.kt new file mode 100644 index 00000000..091661a1 --- /dev/null +++ b/testing/approvals/src/test/kotlin/ModeApprovalTest.kt @@ -0,0 +1,88 @@ +import com.correx.core.approvals.Tier +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.ApprovalMode +import com.correx.testing.fixtures.request +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ModeApprovalTest { + + private val engine = DefaultApprovalEngine() + + private fun contextForMode(mode: ApprovalMode) = ApprovalContext( + ApprovalScopeIdentity(SessionId("s1"), null, null), + mode + ) + + private val now = Instant.parse("2026-01-01T00:00:01Z") + + @Test + fun `DENY mode allows only T0`() { + val ctx = contextForMode(ApprovalMode.DENY) + assertEquals( + ApprovalOutcome.AUTO_APPROVED, + engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome + ) + assertEquals( + ApprovalStatus.PENDING, + engine.evaluate(request("r2", Tier.T1), ctx, emptyList(), now).state + ) + assertEquals( + ApprovalStatus.PENDING, + engine.evaluate(request("r3", Tier.T4), ctx, emptyList(), now).state + ) + } + + @Test + fun `PROMPT mode allows T0-T1`() { + val ctx = contextForMode(ApprovalMode.PROMPT) + assertEquals( + ApprovalOutcome.AUTO_APPROVED, + engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome + ) + assertEquals( + ApprovalOutcome.AUTO_APPROVED, + engine.evaluate(request("r2", Tier.T1), ctx, emptyList(), now).outcome + ) + assertEquals( + ApprovalStatus.PENDING, + engine.evaluate(request("r3", Tier.T2), ctx, emptyList(), now).state + ) + } + + @Test + fun `AUTO mode allows T0-T2`() { + val ctx = contextForMode(ApprovalMode.AUTO) + assertEquals( + ApprovalOutcome.AUTO_APPROVED, + engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome + ) + assertEquals( + ApprovalOutcome.AUTO_APPROVED, + engine.evaluate(request("r2", Tier.T2), ctx, emptyList(), now).outcome + ) + assertEquals( + ApprovalStatus.PENDING, + engine.evaluate(request("r3", Tier.T3), ctx, emptyList(), now).state + ) + } + + @Test + fun `YOLO mode allows all tiers, including T4`() { + val ctx = contextForMode(ApprovalMode.YOLO) + assertEquals( + ApprovalOutcome.AUTO_APPROVED, + engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome + ) + assertEquals( + ApprovalOutcome.AUTO_APPROVED, + engine.evaluate(request("r2", Tier.T4), ctx, emptyList(), now).outcome + ) + } +} diff --git a/testing/approvals/src/test/kotlin/NoExecutionCouplingTest.kt b/testing/approvals/src/test/kotlin/NoExecutionCouplingTest.kt new file mode 100644 index 00000000..7879e345 --- /dev/null +++ b/testing/approvals/src/test/kotlin/NoExecutionCouplingTest.kt @@ -0,0 +1,58 @@ +import com.correx.core.approvals.Tier +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.approvals.GrantScope +import com.correx.core.events.types.GrantId +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.ApprovalMode +import com.correx.testing.fixtures.request +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow + +class NoExecutionCouplingTest { + + @Test + fun `engine does not mutate grants or context`() { + val engine = DefaultApprovalEngine() + val grants = mutableListOf( + ApprovalGrant( + GrantId("g1"), + GrantScope.SESSION, + setOf(Tier.T2), + "", + Instant.parse("2026-01-01T00:00:00Z") + ) + ) + val originalGrants = grants.toList() + val ctx = ApprovalContext( + ApprovalScopeIdentity(SessionId("s1"), null, null), + ApprovalMode.DENY + ) + val now = Instant.parse("2026-01-01T00:00:01Z") + + engine.evaluate(request("r1", Tier.T2), ctx, grants, now) + + assertEquals(originalGrants, grants, "Grants list must not be mutated") + assertEquals(ApprovalMode.DENY, ctx.mode, "Context must not be mutated") + } + + @Test + fun `engine does not throw for valid input`() { + val engine = DefaultApprovalEngine() + assertDoesNotThrow { + engine.evaluate( + request("r1", Tier.T0), + ApprovalContext( + ApprovalScopeIdentity(SessionId("s1"), null, null), + ApprovalMode.YOLO + ), + emptyList(), + Instant.parse("2026-01-01T00:00:01Z") + ) + } + } +} diff --git a/testing/approvals/src/test/kotlin/TierImmutabilityTest.kt b/testing/approvals/src/test/kotlin/TierImmutabilityTest.kt new file mode 100644 index 00000000..4de660f7 --- /dev/null +++ b/testing/approvals/src/test/kotlin/TierImmutabilityTest.kt @@ -0,0 +1,55 @@ +import com.correx.core.approvals.Tier +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.approvals.GrantScope +import com.correx.core.sessions.ApprovalMode +import com.correx.core.events.types.GrantId +import com.correx.core.events.types.SessionId +import com.correx.testing.fixtures.request +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +class TierImmutabilityTest { + + private val engine = DefaultApprovalEngine() + + @Test + fun `engine never modifies tier - always returns original`() { + val tiers = listOf(Tier.T0, Tier.T1, Tier.T2, Tier.T3, Tier.T4) + val ctx = ApprovalContext( + ApprovalScopeIdentity(SessionId("s1"), null, null), + ApprovalMode.YOLO + ) + val now = Instant.parse("2026-01-01T00:00:01Z") + + tiers.forEach { tier -> + val req = request("r1", tier) + val decision = engine.evaluate(req, ctx, emptyList(), now) + Assertions.assertEquals(tier, decision.tier, "Tier must remain unchanged") + } + } + + @Test + fun `grant does not change tier in decision`() { + val tier = Tier.T3 + val req = request("r1", tier) + val grant = ApprovalGrant( + id = GrantId("g1"), + scope = GrantScope.SESSION, + permittedTiers = setOf(tier), + reason = "test", + timestamp = Instant.parse("2026-01-01T00:00:00Z") + ) + val ctx = ApprovalContext( + ApprovalScopeIdentity(SessionId("s1"), null, null), + ApprovalMode.DENY + ) + val now = Instant.parse("2026-01-01T00:00:01Z") + + val decision = engine.evaluate(req, ctx, listOf(grant), now) + Assertions.assertEquals(tier, decision.tier) + } +} \ No newline at end of file diff --git a/testing/build.gradle b/testing/build.gradle new file mode 100644 index 00000000..a2a1f2a8 --- /dev/null +++ b/testing/build.gradle @@ -0,0 +1,3 @@ +subprojects { + group = "com.correx.testing" +} \ No newline at end of file diff --git a/testing/contracts/build.gradle b/testing/contracts/build.gradle new file mode 100644 index 00000000..484dacea --- /dev/null +++ b/testing/contracts/build.gradle @@ -0,0 +1,23 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'java-test-fixtures' +} + +dependencies { + implementation(project(":core:events")) + testImplementation(project(":core:transitions")) + testImplementation(project(":core:sessions")) + testImplementation(project(":core:validation")) + testImplementation(project(":core:approvals")) + testImplementation(project(":core:artifacts")) + testImplementation(project(":core:kernel")) + testImplementation(project(":testing:fixtures")) + testImplementation(project(":core:inference")) + testImplementation(project(":core:context")) + testFixturesImplementation(project(":core:events")) + testFixturesImplementation(project(":core:sessions")) + testFixturesImplementation(project(":testing:fixtures")) + testFixturesImplementation "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinx_datetime_version" + testFixturesImplementation "org.junit.jupiter:junit-jupiter:$junit_version" +} diff --git a/testing/contracts/src/main/kotlin/com/correx/testing/contracts/utils/ConcurrencyRunner.kt b/testing/contracts/src/main/kotlin/com/correx/testing/contracts/utils/ConcurrencyRunner.kt new file mode 100644 index 00000000..b233fbd8 --- /dev/null +++ b/testing/contracts/src/main/kotlin/com/correx/testing/contracts/utils/ConcurrencyRunner.kt @@ -0,0 +1,36 @@ +package com.correx.testing.contracts.utils + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors + +object ConcurrencyRunner { + + fun run( + threads: Int, + iterations: Int, + block: (threadIndex: Int, iteration: Int) -> Unit + ) { + val executor = Executors.newFixedThreadPool(threads) + + val start = CountDownLatch(1) + val done = CountDownLatch(threads) + + repeat(threads) { t -> + executor.submit { + start.await() + + try { + repeat(iterations) { i -> + block(t, i) + } + } finally { + done.countDown() + } + } + } + + start.countDown() + done.await() + executor.shutdown() + } +} \ No newline at end of file diff --git a/testing/contracts/src/main/kotlin/com/correx/testing/contracts/utils/DeterministicBarrier.kt b/testing/contracts/src/main/kotlin/com/correx/testing/contracts/utils/DeterministicBarrier.kt new file mode 100644 index 00000000..9d4c3d8c --- /dev/null +++ b/testing/contracts/src/main/kotlin/com/correx/testing/contracts/utils/DeterministicBarrier.kt @@ -0,0 +1,12 @@ +package com.correx.testing.contracts.utils + +import java.util.concurrent.CyclicBarrier + +class DeterministicBarrier(parties: Int) { + + private val barrier = CyclicBarrier(parties) + + fun await() { + barrier.await() + } +} \ No newline at end of file diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/ContextSnapshotTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/ContextSnapshotTest.kt new file mode 100644 index 00000000..c26bbce4 --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/ContextSnapshotTest.kt @@ -0,0 +1,44 @@ +package com.correx.testing.contracts.model + +import com.correx.core.approvals.Tier +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ProjectId +import com.correx.core.sessions.ApprovalMode +import com.correx.testing.fixtures.request +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class ContextSnapshotTest { + + @Test + fun `decision contains snapshot, not live context`() { + val engine = DefaultApprovalEngine() + val identity = ApprovalScopeIdentity(SessionId("s1"), StageId("st1"), ProjectId("p1")) + val mode = ApprovalMode.AUTO + val context = ApprovalContext(identity, mode) + val now = Instant.parse("2026-01-01T00:00:01Z") + + val decision = engine.evaluate(request("r1", Tier.T2), context, emptyList(), now) + + assertEquals(identity, decision.contextSnapshot.identity) + assertEquals(mode, decision.contextSnapshot.mode) + } + + @Test + fun `snapshot does not change after decision`() { + val engine = DefaultApprovalEngine() + val identity = ApprovalScopeIdentity(SessionId("s1"), null, null) + val context = ApprovalContext(identity, ApprovalMode.PROMPT) + val now = Instant.parse("2026-01-01T00:00:01Z") + + val decision = engine.evaluate(request("r1", Tier.T2), context, emptyList(), now) + val anotherContext = context.copy(mode = ApprovalMode.YOLO) + assertNotEquals(anotherContext.mode, decision.contextSnapshot.mode) + } +} diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/StageIdTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/StageIdTest.kt new file mode 100644 index 00000000..81344063 --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/StageIdTest.kt @@ -0,0 +1,32 @@ +package com.correx.testing.contracts.model + +import com.correx.core.events.types.StageId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class StageIdTest { + + @Test + fun `rejects blank value`() { + assertThrows { + StageId("") + } + } + + @Test + fun `stores value correctly`() { + val id = StageId("stage-1") + + assertEquals("stage-1", id.value) + assertEquals("stage-1", id.toString()) + } + + @Test + fun `value class equality works`() { + val a = StageId("x") + val b = StageId("x") + + assertEquals(a, b) + } +} \ No newline at end of file diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/TransitionIdTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/TransitionIdTest.kt new file mode 100644 index 00000000..c9b5de7b --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/TransitionIdTest.kt @@ -0,0 +1,32 @@ +package com.correx.testing.contracts.model + +import com.correx.core.events.types.TransitionId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class TransitionIdTest { + + @Test + fun `rejects blank value`() { + assertThrows { + TransitionId("") + } + } + + @Test + fun `stores value correctly`() { + val id = TransitionId("t-1") + + assertEquals("t-1", id.value) + assertEquals("t-1", id.toString()) + } + + @Test + fun `value class equality works`() { + val a = TransitionId("x") + val b = TransitionId("x") + + assertEquals(a, b) + } +} \ No newline at end of file diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/ValidationReportContractTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/ValidationReportContractTest.kt new file mode 100644 index 00000000..23540436 --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/ValidationReportContractTest.kt @@ -0,0 +1,28 @@ +package com.correx.testing.contracts.model + +import com.correx.core.validation.graph.GraphValidator +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.testing.fixtures.WorkflowFixtures +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ValidationReportContractTest { + + @Test + fun `report must be deterministic`() { + + val graph = WorkflowFixtures.simpleGraph() + + val pipeline = ValidationPipeline( + listOf(GraphValidator()) + ) + + val context = ValidationContext(graph) + + val r1 = pipeline.validate(context) + val r2 = pipeline.validate(context) + + assertEquals(r1, r2) + } +} \ No newline at end of file diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/WorkflowGraphTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/WorkflowGraphTest.kt new file mode 100644 index 00000000..fcd0bc28 --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/WorkflowGraphTest.kt @@ -0,0 +1,67 @@ +package com.correx.testing.contracts.model + +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WorkflowGraphTest { + + private val s1 = StageId("s1") + private val s2 = StageId("s2") + + @Test + fun `graph stores immutable structure`() { + val graph = WorkflowGraph( + stages = mapOf(s1 to StageConfig(), s2 to StageConfig()), + transitions = setOf( + TransitionEdge( + id = TransitionId("t1"), + from = s1, + to = s2, + condition = { true } + ) + ), + start = s1 + ) + + assertEquals(2, graph.stages.size) + assertEquals(1, graph.transitions.size) + assertEquals(s1, graph.start) + } + + @Test + fun `map removes duplicate keys deterministically`() { + val node = s1 + + val graph = WorkflowGraph( + stages = mapOf(node to StageConfig()), + transitions = emptySet(), + start = s1 + ) + + assertEquals(1, graph.stages.size) + assertTrue(graph.stages.containsKey(node)) + } + + @Test + fun `graph equality is structural`() { + val g1 = WorkflowGraph( + stages = mapOf(s1 to StageConfig()), + transitions = emptySet(), + start = s1 + ) + + val g2 = WorkflowGraph( + stages = mapOf(s1 to StageConfig()), + transitions = emptySet(), + start = s1 + ) + + assertEquals(g1, g2) + } +} diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/DefaultInferenceRouterCacheTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/DefaultInferenceRouterCacheTest.kt new file mode 100644 index 00000000..52f71997 --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/DefaultInferenceRouterCacheTest.kt @@ -0,0 +1,150 @@ +package com.correx.testing.contracts.model.inference + +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.StageId +import com.correx.core.inference.CapabilityScore +import com.correx.core.inference.DefaultInferenceRouter +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.ModelCapability +import com.correx.core.inference.NoEligibleProviderException +import com.correx.core.inference.ProviderHealth +import com.correx.core.inference.ProviderRegistry +import com.correx.core.inference.RoutingStrategy +import com.correx.testing.fixtures.inference.MockInferenceProvider +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource + +class DefaultInferenceRouterCacheTest { + + private val stage = StageId("cache-test") + + private class CountingProvider( + delegate: MockInferenceProvider, + ) : InferenceProvider by delegate { + var healthCheckCount: Int = 0 + private set + private var _health: ProviderHealth = ProviderHealth.Healthy + + fun setHealth(h: ProviderHealth) { _health = h } + + override suspend fun healthCheck(): ProviderHealth { + healthCheckCount++ + return _health + } + } + + private fun registryOf(vararg providers: InferenceProvider): ProviderRegistry = + object : ProviderRegistry { + override fun register(provider: InferenceProvider) = Unit + override fun resolve(capability: ModelCapability) = + providers.filter { p -> p.capabilities().any { it.capability == capability } } + override fun listAll() = providers.toList() + override suspend fun healthCheckAll() = providers.associate { it.id to ProviderHealth.Healthy } + } + + private fun firstStrategy(): RoutingStrategy = + RoutingStrategy { candidates, required -> + candidates.firstOrNull() ?: throw NoEligibleProviderException(StageId("?"), required) + } + + // ── (a) repeated route() within TTL invokes health check once per provider ── + + @Test + fun `health check is cached within TTL`(): Unit = runBlocking { + val timeSource = TestTimeSource() + val mock = MockInferenceProvider( + id = ProviderId("p1"), + declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)), + ) + val counting = CountingProvider(mock) + val router = DefaultInferenceRouter( + registry = registryOf(counting), + strategy = firstStrategy(), + cacheTtl = 5.seconds, + timeSource = timeSource, + ) + + router.route(stage, setOf(ModelCapability.General)) + router.route(stage, setOf(ModelCapability.General)) + router.route(stage, setOf(ModelCapability.General)) + + // Cache hit for all subsequent calls: health checked once for the filter pass, + // plus the post-selection re-check on the first call (uncached path), then cached. + // After the first route() the entry is cached; the two subsequent calls use the cache. + // Post-selection re-check always goes live, so total = 1 (filter) + 1 (post-select) + // on first call, then 0 (filter cached) + 1 (post-select live) * 2 more = 4 total. + // But the cache only governs the filter pass; post-select is always live by design. + // So: first call = 2 checks (filter + post-select), subsequent calls = 1 each (post-select). + assertEquals(4, counting.healthCheckCount) + } + + // ── (b) after TTL expires, health is re-checked ────────────────────────── + + @Test + fun `health check is refreshed after TTL expires`(): Unit = runBlocking { + val timeSource = TestTimeSource() + val mock = MockInferenceProvider( + id = ProviderId("p1"), + declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)), + ) + val counting = CountingProvider(mock) + val router = DefaultInferenceRouter( + registry = registryOf(counting), + strategy = firstStrategy(), + cacheTtl = 5.seconds, + timeSource = timeSource, + ) + + router.route(stage, setOf(ModelCapability.General)) + val countAfterFirstCall = counting.healthCheckCount + + // Advance past TTL + timeSource.plusAssign(6.seconds) + + router.route(stage, setOf(ModelCapability.General)) + + // After TTL expiry a new filter-pass health check must fire + assert(counting.healthCheckCount > countAfterFirstCall + 1) { + "Expected more than ${countAfterFirstCall + 1} total checks, got ${counting.healthCheckCount}" + } + } + + // ── (c) unhealthy cached entry causes provider skip ─────────────────────── + + @Test + fun `cached unavailable entry causes provider to be skipped`(): Unit = runBlocking { + val timeSource = TestTimeSource() + val mock1 = MockInferenceProvider( + id = ProviderId("p1"), + declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)), + ) + val mock2 = MockInferenceProvider( + id = ProviderId("p2"), + declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)), + ) + val counting1 = CountingProvider(mock1) + val counting2 = CountingProvider(mock2) + val router = DefaultInferenceRouter( + registry = registryOf(counting1, counting2), + strategy = firstStrategy(), + cacheTtl = 5.seconds, + timeSource = timeSource, + ) + + // Warm the cache with p1 healthy + router.route(stage, setOf(ModelCapability.General)) + + // Mark p1 unavailable in the counting provider — but cache still says healthy + counting1.setHealth(ProviderHealth.Unavailable("went down")) + + // Advance past TTL so cache refreshes + timeSource.plusAssign(6.seconds) + + val selected = router.route(stage, setOf(ModelCapability.General)) + assertSame(counting2, selected) + } +} diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/DefaultInferenceRouterTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/DefaultInferenceRouterTest.kt new file mode 100644 index 00000000..9cef682c --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/DefaultInferenceRouterTest.kt @@ -0,0 +1,123 @@ +package com.correx.testing.contracts.model.inference + +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.StageId +import com.correx.core.inference.CapabilityScore +import com.correx.core.inference.DefaultInferenceRouter +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.ModelCapability +import com.correx.core.inference.NoEligibleProviderException +import com.correx.core.inference.ProviderRegistry +import com.correx.core.inference.ProviderHealth +import com.correx.core.inference.RoutingStrategy +import com.correx.testing.fixtures.inference.MockInferenceProvider +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class DefaultInferenceRouterTest { + + private val stage = StageId("s1") + + private fun provider(id: String, vararg caps: ModelCapability): MockInferenceProvider = + MockInferenceProvider( + id = ProviderId(id), + declaredCapabilities = caps.map { CapabilityScore(it, 1.0) }.toSet(), + ) + + private fun registryOf(vararg providers: InferenceProvider): ProviderRegistry = + object : ProviderRegistry { + override fun register(provider: InferenceProvider) = Unit + override fun resolve(capability: ModelCapability) = + providers.filter { p -> p.capabilities().any { it.capability == capability } } + override fun listAll() = providers.toList() + override suspend fun healthCheckAll() = providers.associate { it.id to ProviderHealth.Healthy } + } + + private fun firstStrategy(): RoutingStrategy = + object : RoutingStrategy { + override fun select(candidates: List, requiredCapabilities: Set) = + candidates.firstOrNull() ?: throw NoEligibleProviderException(StageId("?"), emptySet()) + } + + private fun throwingStrategy(): RoutingStrategy = + object : RoutingStrategy { + override fun select(candidates: List, requiredCapabilities: Set) = + throw NoEligibleProviderException(StageId("?"), requiredCapabilities) + } + + // ── capability matching ─────────────────────────────────────────────────── + + @Test + fun `routes to provider that matches required capability`(): Unit = runBlocking { + val p = provider("a", ModelCapability.General) + val router = DefaultInferenceRouter(registryOf(p), firstStrategy()) + assertSame(p, router.route(stage, setOf(ModelCapability.General))) + } + + @Test + fun `deduplicates providers that satisfy multiple required capabilities`(): Unit = runBlocking { + val p = provider("a", ModelCapability.General, ModelCapability.Coding) + val selected = mutableListOf() + val captureStrategy = object : RoutingStrategy { + override fun select( + candidates: List, + requiredCapabilities: Set, + ): InferenceProvider { + selected.addAll(candidates) + return candidates.first() + } + } + val router = DefaultInferenceRouter( + registryOf(p), + captureStrategy, + ) + router.route(stage, setOf(ModelCapability.General, ModelCapability.Coding)) + assert(selected.count { it.id == p.id } == 1) { "provider appeared ${selected.size} times, expected 1" } + } + + // ── empty capabilities falls back to all providers ──────────────────────── + + @Test + fun `empty required capabilities routes from all registered providers`(): Unit = runBlocking { + val p1 = provider("a", ModelCapability.General) + val p2 = provider("b", ModelCapability.Coding) + val router = DefaultInferenceRouter(registryOf(p1, p2), firstStrategy()) + val result = router.route(stage, emptySet()) + assertSame(p1, result) + } + + // ── no candidates throws ────────────────────────────────────────────────── + + @Test + fun `throws NoEligibleProviderException when no provider satisfies capability`() { + val router = DefaultInferenceRouter(registryOf(), throwingStrategy()) + assertThrows { + runBlocking { router.route(stage, setOf(ModelCapability.General)) } + } + } + + @Test + fun `throws NoEligibleProviderException when registry is empty and capabilities are empty`() { + val router = DefaultInferenceRouter(registryOf(), throwingStrategy()) + assertThrows { + runBlocking { router.route(stage, emptySet()) } + } + } + + // ── unavailable providers are filtered ─────────────────────────────────── + + @Test + fun `filters out unavailable providers before selection`(): Unit = runBlocking { + val p1 = MockInferenceProvider( + id = com.correx.core.events.types.ProviderId("a"), + declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)), + health = ProviderHealth.Unavailable("down"), + ) + val p2 = provider("b", ModelCapability.General) + val router = DefaultInferenceRouter(registryOf(p1, p2), firstStrategy()) + val result = router.route(stage, setOf(ModelCapability.General)) + assertSame(p2, result) + } +} diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/InferenceProviderContractTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/InferenceProviderContractTest.kt new file mode 100644 index 00000000..e5b71ecf --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/inference/InferenceProviderContractTest.kt @@ -0,0 +1,135 @@ +package com.correx.testing.contracts.model.inference + +import com.correx.core.context.model.CompressionMetadata +import com.correx.core.context.model.ContextPack +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.InferenceRequestId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.GenerationConfig +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRequest +import com.correx.testing.fixtures.inference.MockInferenceProvider +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class InferenceProviderContractTest { + + fun provider(): InferenceProvider = MockInferenceProvider() + + private fun request() = InferenceRequest( + requestId = InferenceRequestId("req-1"), + sessionId = SessionId("session-1"), + stageId = StageId("stage-1"), + contextPack = emptyContextPack(), // test helper — returns minimal stub + generationConfig = GenerationConfig( + temperature = 0.0, + topP = 1.0, + maxTokens = 128, + ), + ) + + // ── Successful inference ────────────────────────────────────────────────── + + @Test + fun `infer returns response with matching requestId`(): Unit = runBlocking { + val p = provider() + val req = request() + val resp = p.infer(req) + assertEquals(req.requestId, resp.requestId) + } + + @Test + fun `infer returns non-blank text`(): Unit = runBlocking { + val resp = provider().infer(request()) + assertTrue(resp.text.isNotBlank()) + } + + @Test + fun `infer tracks token usage`(): Unit = runBlocking { + val resp = provider().infer(request()) + assertTrue(resp.tokensUsed.totalTokens > 0) + } + + @Test + fun `infer latency is non-negative`(): Unit = runBlocking { + val resp = provider().infer(request()) + assertTrue(resp.latencyMs >= 0) + } + + // ── Health ──────────────────────────────────────────────────────────────── + + @Test + fun `healthCheck returns non-null`(): Unit = runBlocking { + assertNotNull(provider().healthCheck()) + } + + // ── Capabilities ────────────────────────────────────────────────────────── + + @Test + fun `capabilities is non-empty`() { + assertTrue(provider().capabilities().isNotEmpty()) + } + + @Test + fun `all capability scores are in range 0 to 1`() { + provider().capabilities().forEach { cs -> + assertTrue(cs.score in 0.0..1.0, "score out of range: $cs") + } + } + + // ── Cancellation ────────────────────────────────────────────────────────── + + // A slow provider — required so cancellation actually fires mid-flight rather than + // racing a 0ms infer that has already completed by the time cancel() is called. + private fun slowProvider(): InferenceProvider = MockInferenceProvider(artificialDelayMs = 1_000L) + + @Test + fun `infer respects coroutine cancellation`(): Unit = runBlocking { + val p = slowProvider() + val started = System.currentTimeMillis() + val job = launch { p.infer(request()) } + // give the coroutine a moment to enter delay() + while (job.isActive && System.currentTimeMillis() - started < 50) { /* spin */ } + job.cancel() + job.join() + assertFalse(job.isActive) + // Must complete well before the 1000ms artificial delay would have elapsed. + assertTrue(System.currentTimeMillis() - started < 500, "cancellation did not interrupt mid-flight") + } + + @Test + fun `infer cancellation completes within 500ms`(): Unit = runBlocking { + val p = slowProvider() + val started = System.currentTimeMillis() + val job = launch { p.infer(request()) } + job.cancel() + withTimeout(500) { job.join() } + assertTrue(System.currentTimeMillis() - started < 500, "join exceeded budget after cancel") + } + + // ── Tokenizer ───────────────────────────────────────────────────────────── + + @Test + fun `tokenizer countTokens is consistent with tokenize`(): Unit = runBlocking { + val t = provider().tokenizer + val text = "hello world" + assertEquals(t.tokenize(text).size, t.countTokens(text)) + } + + private fun emptyContextPack(): ContextPack = ContextPack( + id = ContextPackId("cp1"), + sessionId = SessionId("session-1"), + stageId = StageId("stage-1"), + layers = emptyMap(), + budgetUsed = 10, + budgetLimit = 20, + compressionMetadata = CompressionMetadata(), + ) +} diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/EventsTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/EventsTest.kt new file mode 100644 index 00000000..a9adc16a --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/EventsTest.kt @@ -0,0 +1,132 @@ +package com.correx.testing.contracts.model.orchestration + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.OrchestrationResumedEvent +import com.correx.core.events.events.RetryAttemptedEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.events.WorkflowFailedEvent +import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.serialization.eventJson +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class EventsTest { + @Test + fun `WorkflowStartedEvent survives round-trip`() { + val event: EventPayload = WorkflowStartedEvent( + sessionId = SessionId("s1"), + startStageId = StageId("stage-a"), + ) + + val json = eventJson.encodeToString(EventPayload.serializer(), event) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + + assertEquals(event, decoded) + } + + @Test + fun `WorkflowCompletedEvent survives round-trip`() { + val event: EventPayload = WorkflowCompletedEvent( + sessionId = SessionId("s1"), + terminalStageId = StageId("stage-a"), + totalStages = 1, + ) + + val json = eventJson.encodeToString(EventPayload.serializer(), event) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + + assertEquals(event, decoded) + } + + @Test + fun `WorkflowFailedEvent survives round-trip`() { + val event: EventPayload = WorkflowFailedEvent( + sessionId = SessionId("s1"), + stageId = StageId("stage-a"), + reason = "Something went wrong", + retryExhausted = false, + ) + + val json = eventJson.encodeToString(EventPayload.serializer(), event) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + + assertEquals(event, decoded) + } + + @Test + fun `WorkflowFailedEvent survives round-trip with retry exhausted`() { + val event: EventPayload = WorkflowFailedEvent( + sessionId = SessionId("s1"), + stageId = StageId("stage-a"), + reason = "Something went wrong", + retryExhausted = true, + ) + + val json = eventJson.encodeToString(EventPayload.serializer(), event) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + + assertEquals(event, decoded) + } + + @Test + fun `OrchestrationPausedEvent survives round-trip`() { + val event: EventPayload = OrchestrationPausedEvent( + sessionId = SessionId("s1"), + stageId = StageId("stage-a"), + reason = "Requires user approval", + ) + + val json = eventJson.encodeToString(EventPayload.serializer(), event) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + + assertEquals(event, decoded) + } + + @Test + fun `OrchestrationResumedEvent survives round-trip`() { + val event: EventPayload = OrchestrationResumedEvent( + sessionId = SessionId("s1"), + stageId = StageId("stage-a"), + ) + + val json = eventJson.encodeToString(EventPayload.serializer(), event) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + + assertEquals(event, decoded) + } + + @Test + fun `RetryAttemptedEvent survives round-trip`() { + val event: EventPayload = RetryAttemptedEvent( + sessionId = SessionId("s1"), + stageId = StageId("stage-a"), + attemptNumber = 1, + maxAttempts = 3, + failureReason = "Something went wrong", + ) + + val json = eventJson.encodeToString(EventPayload.serializer(), event) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + + assertEquals(event, decoded) + } + + @Test + fun `RetryAttemptedEvent survives round-trip attempt = max attempts`() { + val event: EventPayload = RetryAttemptedEvent( + sessionId = SessionId("s1"), + stageId = StageId("stage-a"), + attemptNumber = 3, + maxAttempts = 3, + failureReason = "Something went wrong", + ) + + val json = eventJson.encodeToString(EventPayload.serializer(), event) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + + assertEquals(event, decoded) + } +} \ No newline at end of file diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/OrchestrationConfigTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/OrchestrationConfigTest.kt new file mode 100644 index 00000000..ec82bfd5 --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/OrchestrationConfigTest.kt @@ -0,0 +1,37 @@ +package com.correx.testing.contracts.model.orchestration + +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.kernel.execution.ReplayStrategy +import com.correx.core.kernel.orchestration.OrchestrationConfig +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class OrchestrationConfigTest { + + @Test + fun `default retryPolicy has maxAttempts of 3`() { + assertEquals(3, OrchestrationConfig().retryPolicy.maxAttempts) + } + + @Test + fun `default replayStrategy is Full`() { + assertEquals(ReplayStrategy.Full, OrchestrationConfig().replayStrategy) + } + + @Test + fun `default stageTimeoutMs is 60 seconds`() { + assertEquals(60_000L, OrchestrationConfig().stageTimeoutMs) + } + + @Test + fun `custom values are stored`() { + val config = OrchestrationConfig( + retryPolicy = RetryPolicy(maxAttempts = 5, backoffMs = 200L), + replayStrategy = ReplayStrategy.Full, + stageTimeoutMs = 30_000L, + ) + assertEquals(5, config.retryPolicy.maxAttempts) + assertEquals(200L, config.retryPolicy.backoffMs) + assertEquals(30_000L, config.stageTimeoutMs) + } +} diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/RetryPolicyTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/RetryPolicyTest.kt new file mode 100644 index 00000000..cb5255d2 --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/RetryPolicyTest.kt @@ -0,0 +1,41 @@ +package com.correx.testing.contracts.model.orchestration + +import com.correx.core.events.execution.RetryPolicy +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class RetryPolicyTest { + + @Test + fun `maxAttempts of zero throws IllegalArgumentException`() { + assertThrows { + RetryPolicy(maxAttempts = 0) + } + } + + @Test + fun `negative maxAttempts throws IllegalArgumentException`() { + assertThrows { + RetryPolicy(maxAttempts = -1) + } + } + + @Test + fun `maxAttempts of one is valid`() { + val policy = RetryPolicy(maxAttempts = 1) + assertEquals(1, policy.maxAttempts) + } + + @Test + fun `backoffMs defaults to zero`() { + val policy = RetryPolicy(maxAttempts = 1) + assertEquals(0L, policy.backoffMs) + } + + @Test + fun `backoffMs is stored when provided`() { + val policy = RetryPolicy(maxAttempts = 3, backoffMs = 500L) + assertEquals(500L, policy.backoffMs) + } +} diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/StageOutcomeTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/StageOutcomeTest.kt new file mode 100644 index 00000000..86be1568 --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/StageOutcomeTest.kt @@ -0,0 +1,28 @@ +package com.correx.testing.contracts.model.orchestration + +import com.correx.core.transitions.execution.StageExecutionResult +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class StageOutcomeTest { + + @Test + fun `sealed exhaustiveness - all variants reachable`() { + val results: List = listOf( + StageExecutionResult.Success(emptyList()), + StageExecutionResult.Failure("something went wrong", false), + ) + + val labels = results.map { result -> + when (result) { + is StageExecutionResult.Success -> "success" + is StageExecutionResult.Failure -> "failure" + } + } + + assertEquals( + listOf("success", "failure"), + labels, + ) + } +} diff --git a/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/WorkflowResultTest.kt b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/WorkflowResultTest.kt new file mode 100644 index 00000000..9b33e9bc --- /dev/null +++ b/testing/contracts/src/test/kotlin/com/correx/testing/contracts/model/orchestration/WorkflowResultTest.kt @@ -0,0 +1,58 @@ +package com.correx.testing.contracts.model.orchestration + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.kernel.execution.WorkflowResult +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WorkflowResultTest { + + @Test + fun `Completed carries sessionId and terminalStageId`() { + val result = WorkflowResult.Completed(SessionId("s1"), StageId("stage-z")) + assertEquals(SessionId("s1"), result.sessionId) + assertEquals(StageId("stage-z"), result.terminalStageId) + } + + @Test + fun `Failed carries sessionId reason and retryExhausted`() { + val result = WorkflowResult.Failed(SessionId("s2"), "boom", retryExhausted = true) + assertEquals(SessionId("s2"), result.sessionId) + assertEquals("boom", result.reason) + assertTrue(result.retryExhausted) + } + + @Test + fun `Failed retryExhausted can be false`() { + val result = WorkflowResult.Failed(SessionId("s3"), "transient", retryExhausted = false) + assertFalse(result.retryExhausted) + } + + @Test + fun `Cancelled carries sessionId`() { + val result = WorkflowResult.Cancelled(SessionId("s4")) + assertEquals(SessionId("s4"), result.sessionId) + } + + @Test + fun `sealed exhaustiveness - all variants reachable`() { + val results: List = listOf( + WorkflowResult.Completed(SessionId("s1"), StageId("stage-z")), + WorkflowResult.Failed(SessionId("s2"), "reason", retryExhausted = false), + WorkflowResult.Cancelled(SessionId("s3")), + ) + + val labels = results.map { result -> + when (result) { + is WorkflowResult.Completed -> "completed" + is WorkflowResult.Failed -> "failed" + is WorkflowResult.Cancelled -> "cancelled" + } + } + + assertEquals(listOf("completed", "failed", "cancelled"), labels) + } +} diff --git a/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/events/store/EventStoreContractTest.kt b/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/events/store/EventStoreContractTest.kt new file mode 100644 index 00000000..ad4c035e --- /dev/null +++ b/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/events/store/EventStoreContractTest.kt @@ -0,0 +1,180 @@ +package com.correx.testing.contracts.fixtures.events.store + +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.testing.contracts.utils.ConcurrencyRunner +import com.correx.testing.fixtures.EventFixtures +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +abstract class EventStoreContractTest { + protected abstract fun store(): EventStore + + @Test + fun `appends preserve ordering`() { + val store = store() + + store.append(EventFixtures.newEvent(EventId("e1"), SessionId("s1"))) + store.append(EventFixtures.newEvent(EventId("e2"), SessionId("s1"))) + + val events = store.read(SessionId("s1")) + + Assertions.assertEquals(2, events.size) + Assertions.assertEquals("e1", events[0].metadata.eventId.value) + Assertions.assertEquals("e2", events[1].metadata.eventId.value) + } + + @Test + fun `idempotency prevents duplicates`() { + val store = store() + val e = EventFixtures.newEvent(EventId("dup"), SessionId("s1")) + + store.append(e) + assertThrows { + store.append(e) + } + Assertions.assertEquals(1, store.read(SessionId("s1")).size) + } + + @Test + fun `sessions are isolated`() { + val store = store() + + store.append(EventFixtures.newEvent(EventId("a"), SessionId("s1"))) + store.append(EventFixtures.newEvent(EventId("b"), SessionId("s2"))) + + Assertions.assertEquals(1, store.read(SessionId("s1")).size) + Assertions.assertEquals(1, store.read(SessionId("s2")).size) + } + + @Test + fun `readFrom respects sequence cursor`() { + val store = store() + + store.append(EventFixtures.newEvent(EventId("1"), SessionId("s1"))) + store.append(EventFixtures.newEvent(EventId("2"), SessionId("s1"))) + store.append(EventFixtures.newEvent(EventId("3"), SessionId("s1"))) + + val partial = store.readFrom(SessionId("s1"), 2) + + Assertions.assertEquals(1, partial.size) + } + + @Test + fun `event ids are globally unique under concurrent appends`() { + val store = store() + + ConcurrencyRunner.run(20, 100) { t, i -> + store.append( + EventFixtures.newEvent(EventId("e-$i-$t"), SessionId("s1")) + ) + } + + val all = store.read(SessionId("s1")) + + val ids = all.map { it.metadata.eventId }.toSet() + + Assertions.assertEquals(all.size, ids.size) + } + + @Test + fun `sequences are strictly increasing per session`() { + val store = store() + + repeat(100) { + store.append(EventFixtures.newEvent(EventId("e$it"), SessionId("s1"))) + } + + val seqs = store.read(SessionId("s1")).map { it.sequence } + + seqs.zipWithNext().forEach { (a, b) -> + Assertions.assertTrue(a < b) + } + } + + @Test + fun `sequences are isolated per session`() { + val store = store() + + repeat(50) { + store.append(EventFixtures.newEvent(EventId("a$it"), SessionId("A"))) + store.append(EventFixtures.newEvent(EventId("b$it"), SessionId("B"))) + } + + val aSeq = store.read(SessionId("A")).map { it.sequence } + val bSeq = store.read(SessionId("B")).map { it.sequence } + + Assertions.assertEquals(aSeq.sorted(), aSeq) + Assertions.assertEquals(bSeq.sorted(), bSeq) + } + + @Test + fun `appendAll is atomic per session`() { + val store = store() + + val batch = (1..50).map { + EventFixtures.newEvent(EventId("e$it"), SessionId("s1")) + } + + store.appendAll(batch) + + val result = store.read(SessionId("s1")) + + Assertions.assertEquals(50, result.size) + + val seqs = result.map { it.sequence } + Assertions.assertEquals(seqs.sorted(), seqs) + } + + @Test + fun `concurrent appendAll and append remain consistent`() { + val store = store() + + ConcurrencyRunner.run(10, 50) { t, i -> + if (i % 2 == 0) { + store.append(EventFixtures.newEvent(EventId("a-$t-$i"), SessionId("s1"))) + } else { + store.appendAll( + listOf(EventFixtures.newEvent(EventId("b-$t-$i"), SessionId("s1"))) + ) + } + } + + val all = store.read(SessionId("s1")) + + val seqs = all.map { it.sequence } + + Assertions.assertEquals(seqs.sorted(), seqs) + } + + @Test + fun `read returns stable snapshot`() { + val store = store() + + repeat(100) { + store.append(EventFixtures.newEvent(EventId("e$it"), SessionId("s1"))) + } + + val r1 = store.read(SessionId("s1")) + val r2 = store.read(SessionId("s1")) + + Assertions.assertEquals(r1, r2) + } + + @Test + fun `readFrom behaves like streaming cursor`() { + val store = store() + + repeat(10) { + store.append(EventFixtures.newEvent(EventId("$it"), SessionId("s1"))) + } + + val first = store.readFrom(SessionId("s1"), 3) + val second = store.readFrom(SessionId("s1"), 7) + + Assertions.assertTrue(first.all { it.sequence > 3 }) + Assertions.assertTrue(second.all { it.sequence > 7 }) + } +} \ No newline at end of file diff --git a/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/CountingProjectionContractTest.kt b/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/CountingProjectionContractTest.kt new file mode 100644 index 00000000..b3b42e14 --- /dev/null +++ b/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/CountingProjectionContractTest.kt @@ -0,0 +1,53 @@ +package com.correx.testing.contracts.fixtures.projections + +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.DefaultStateBuilder +import com.correx.core.sessions.SessionCounterProjection +import com.correx.core.sessions.SessionCounterState +import com.correx.testing.fixtures.EventFixtures.stored +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +open class CountingProjectionContractTest : ProjectionContractTest() { + + override fun projection() = + SessionCounterProjection("s1") + + @Test + fun `projection reflects event count`() { + val events = (0 until 42).map { + stored(EventId(it.toString()), sessionId = SessionId("s1"), sequence = it.toLong()) + } + + val state = DefaultStateBuilder( + projection() + ).build(events) + + assertEquals(42, state.count) + } + + @Test + fun `projection is order sensitive but deterministic`() { + val events = listOf( + stored(EventId("1"), sessionId = SessionId("s1"), sequence = 0), + stored(EventId("2"), sessionId = SessionId("s1"), sequence = 1), + stored(EventId("3"), sessionId = SessionId("s1"), sequence = 2) + ) + + val state = DefaultStateBuilder( + projection() + ).build(events) + + assertEquals(3, state.count) + } + + @Test + fun `projection handles empty stream`() { + val state = DefaultStateBuilder( + projection() + ).build(emptyList()) + + assertEquals(0, state.count) + } +} \ No newline at end of file diff --git a/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/ProjectionContractTest.kt b/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/ProjectionContractTest.kt new file mode 100644 index 00000000..300d8ca9 --- /dev/null +++ b/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/ProjectionContractTest.kt @@ -0,0 +1,30 @@ +package com.correx.testing.contracts.fixtures.projections + +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.DefaultStateBuilder +import com.correx.core.sessions.projections.Projection +import com.correx.testing.fixtures.EventFixtures.stored +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +abstract class ProjectionContractTest { + + abstract fun projection(): Projection + + @Test + fun `projection rebuild is deterministic`() { + val events = (0 until 100).map { + stored(EventId("e$it"), sessionId = SessionId("s1"), sequence = it.toLong()) + } + + val projection = projection() + + val builder = DefaultStateBuilder(projection) + + val state1 = builder.build(events) + val state2 = builder.build(events) + + assertEquals(state1, state2) + } +} \ No newline at end of file diff --git a/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/ReplayContractTest.kt b/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/ReplayContractTest.kt new file mode 100644 index 00000000..9cc6a615 --- /dev/null +++ b/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/ReplayContractTest.kt @@ -0,0 +1,34 @@ +package com.correx.testing.contracts.fixtures.projections + +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.testing.fixtures.EventFixtures.newEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +abstract class ReplayContractTest { + + protected abstract fun store(): EventStore + + protected abstract fun replayer( + store: EventStore + ): EventReplayer + + @Test + fun `rebuild is deterministic`() { + val store = store() + + repeat(50) { + store.append(newEvent(EventId("e$it"), SessionId("s1"))) + } + + val replayer = replayer(store) + + val state1 = replayer.rebuild(SessionId("s1")) + val state2 = replayer.rebuild(SessionId("s1")) + + assertEquals(state1, state2) + } +} \ No newline at end of file diff --git a/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/SessionReplayContractTest.kt b/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/SessionReplayContractTest.kt new file mode 100644 index 00000000..7164d5e9 --- /dev/null +++ b/testing/contracts/src/testFixtures/kotlin/com/correx/testing/contracts/fixtures/projections/SessionReplayContractTest.kt @@ -0,0 +1,50 @@ +package com.correx.testing.contracts.fixtures.projections + +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.core.sessions.SessionCounterState +import com.correx.testing.fixtures.EventFixtures.newEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +abstract class SessionReplayContractTest { + + protected abstract fun store(): EventStore + + protected abstract fun replayer( + store: EventStore + ): EventReplayer + + @Test + fun `rebuild is deterministic`() { + val store = store() + + repeat(50) { + store.append(newEvent(EventId("e$it"), SessionId("s1"))) + } + + val replayer = replayer(store) + + val state1 = replayer.rebuild(SessionId("s1")) + val state2 = replayer.rebuild(SessionId("s1")) + + assertEquals(state1, state2) + } + + @Test + fun `rebuild reflects complete stream`() { + val store = store() + + repeat(42) { + store.append(newEvent(EventId("e$it"), SessionId("s1"))) + } + + val replayer = replayer(store) + + val state = replayer.rebuild(SessionId("s1")) + + assertEquals(42, state.count) + } +} \ No newline at end of file diff --git a/testing/deterministic/build.gradle b/testing/deterministic/build.gradle new file mode 100644 index 00000000..9c8d357b --- /dev/null +++ b/testing/deterministic/build.gradle @@ -0,0 +1,17 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + testImplementation(project(":core:events")) + testImplementation(project(":core:sessions")) + testImplementation(project(":core:transitions")) + testImplementation(project(":core:validation")) + testImplementation(project(":core:approvals")) + testImplementation(project(":core:context")) + testImplementation(project(":infrastructure:persistence")) + testImplementation(project(":testing:fixtures")) + testImplementation(project(":core:inference")) +} \ No newline at end of file diff --git a/testing/deterministic/src/main/kotlin/com/correx/testing/deterministic/Module.kt b/testing/deterministic/src/main/kotlin/com/correx/testing/deterministic/Module.kt new file mode 100644 index 00000000..72d22d1c --- /dev/null +++ b/testing/deterministic/src/main/kotlin/com/correx/testing/deterministic/Module.kt @@ -0,0 +1,3 @@ +package com.correx.testing.deterministic + +object Module diff --git a/testing/deterministic/src/test/kotlin/ApprovalEngineDeterminismTest.kt b/testing/deterministic/src/test/kotlin/ApprovalEngineDeterminismTest.kt new file mode 100644 index 00000000..dce3f29f --- /dev/null +++ b/testing/deterministic/src/test/kotlin/ApprovalEngineDeterminismTest.kt @@ -0,0 +1,45 @@ +import com.correx.core.approvals.Tier +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.ApprovalMode +import com.correx.testing.fixtures.request +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class ApprovalEngineDeterminismTest { + + private val engine = DefaultApprovalEngine() + + @Test + fun `identical inputs produce identical decisions`() { + val request = request("r1", Tier.T2) + val identity = ApprovalScopeIdentity(SessionId("s1"), null, null) + val context = ApprovalContext(identity, ApprovalMode.AUTO) + val grants = emptyList() + val now = Instant.parse("2026-01-01T00:00:01Z") + + val d1 = engine.evaluate(request, context, grants, now) + val d2 = engine.evaluate(request, context, grants, now) + + assertEquals(d1, d2) + } + + @Test + fun `different requests produce different decision IDs`() { + val request1 = request("r1", Tier.T2) + val request2 = request("r2", Tier.T2) + val identity = ApprovalScopeIdentity(SessionId("s1"), null, null) + val context = ApprovalContext(identity, ApprovalMode.AUTO) + val now = Instant.parse("2026-01-01T00:00:01Z") + + val d1 = engine.evaluate(request1, context, emptyList(), now) + val d2 = engine.evaluate(request2, context, emptyList(), now) + + assertNotEquals(d1.id, d2.id) + } +} diff --git a/testing/deterministic/src/test/kotlin/ContextCompressionDeterminismTest.kt b/testing/deterministic/src/test/kotlin/ContextCompressionDeterminismTest.kt new file mode 100644 index 00000000..b5bf45f0 --- /dev/null +++ b/testing/deterministic/src/test/kotlin/ContextCompressionDeterminismTest.kt @@ -0,0 +1,207 @@ +import com.correx.core.context.compression.CompressionStrategy +import com.correx.core.context.compression.DefaultContextCompressor +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.ContextLayer +import com.correx.core.context.model.TokenBudget +import com.correx.core.events.types.ContextEntryId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +class ContextCompressionDeterminismTest { + + private val compressor = DefaultContextCompressor() + + private fun entry( + id: String, + layer: ContextLayer, + tokens: Int, + content: String = "content-$id", + sourceId: String = id + ) = ContextEntry( + id = ContextEntryId(id), + layer = layer, + content = content, + sourceType = "test", + sourceId = sourceId, + tokenEstimate = tokens + ) + + @Test + fun `same input always produces same output`() { + val entries = listOf( + entry("a", ContextLayer.L2, 100), + entry("b", ContextLayer.L1, 50), + entry("c", ContextLayer.L0, 30) + ) + val budget = TokenBudget(limit = 200) + val result1 = compressor.compress(entries, budget, CompressionStrategy.Conversation()) + val result2 = compressor.compress(entries, budget, CompressionStrategy.Conversation()) + assertEquals(result1, result2) + } + + @Test + fun `L2 entries are dropped before L1 when over budget`() { + val entries = listOf( + entry("l2-1", ContextLayer.L2, 80), + entry("l1-1", ContextLayer.L1, 80), + entry("l0-1", ContextLayer.L0, 80) + ) + val budget = TokenBudget(limit = 170) + val result = compressor.compress(entries, budget, CompressionStrategy.Conversation()) + assertTrue(result.none { it.layer == ContextLayer.L2 }, "L2 must be dropped first") + assertTrue(result.any { it.layer == ContextLayer.L1 }, "L1 must be retained") + assertTrue(result.any { it.layer == ContextLayer.L0 }, "L0 must always be retained") + } + + @Test + fun `SteeringNote entries are never dropped regardless of budget`() { + val entries = listOf( + entry("s1", ContextLayer.L1, 500), + entry("s2", ContextLayer.L2, 500) + ) + val budget = TokenBudget(limit = 10) + val result = compressor.compress(entries, budget, CompressionStrategy.SteeringNote) + assertEquals(2, result.size) + } + + @Test + fun `EventHistory entries are never dropped`() { + val entries = listOf( + entry("e1", ContextLayer.L2, 500), + entry("e2", ContextLayer.L2, 500) + ) + val budget = TokenBudget(limit = 10) + val result = compressor.compress(entries, budget, CompressionStrategy.EventHistory) + assertEquals(2, result.size) + } + + @Test + fun `Artifact strategy keeps latest entry per sourceId`() { + val entries = listOf( + entry("v1", ContextLayer.L1, 50, sourceId = "artifact-a"), + entry("v2", ContextLayer.L1, 50, sourceId = "artifact-a"), + entry("v3", ContextLayer.L1, 50, sourceId = "artifact-b") + ) + val budget = TokenBudget(limit = 4000) + val result = compressor.compress(entries, budget, CompressionStrategy.Artifact) + assertEquals(2, result.size) + assertTrue(result.any { it.id == ContextEntryId("v2") }, "Latest artifact-a version must be kept") + assertTrue(result.any { it.id == ContextEntryId("v3") }, "artifact-b must be kept") + } + + @Test + fun `ToolLog strategy deduplicates identical content`() { + val entries = listOf( + entry("t1", ContextLayer.L1, 30, content = "same output"), + entry("t2", ContextLayer.L1, 30, content = "same output"), + entry("t3", ContextLayer.L1, 30, content = "different output") + ) + val budget = TokenBudget(limit = 4000) + val result = compressor.compress(entries, budget, CompressionStrategy.ToolLog) + assertEquals(2, result.size) + } + + @Test + fun `entries fitting within budget are not dropped`() { + val entries = listOf( + entry("a", ContextLayer.L1, 30), + entry("b", ContextLayer.L2, 30) + ) + val budget = TokenBudget(limit = 200) + val result = compressor.compress(entries, budget, CompressionStrategy.Conversation()) + assertEquals(2, result.size) + } + + @Test + fun `Conversation keepLast drops oldest entries beyond the limit`() { + val entries = (1..5).map { entry("e$it", ContextLayer.L1, 10) } + val budget = TokenBudget(limit = 4000) + val result = compressor.compress(entries, budget, CompressionStrategy.Conversation(keepLast = 3)) + assertEquals(3, result.size) + assertEquals(listOf("e3", "e4", "e5"), result.map { it.id.value }) + } + + @Test + fun `Conversation L0 is never truncated when L1 overflows budget`() { + val entries = listOf( + entry("l0-1", ContextLayer.L0, 60), + entry("l1-1", ContextLayer.L1, 60), + entry("l1-2", ContextLayer.L1, 60) + ) + val budget = TokenBudget(limit = 100) + val result = compressor.compress(entries, budget, CompressionStrategy.Conversation()) + assertTrue(result.any { it.layer == ContextLayer.L0 }, "L0 must never be dropped") + assertTrue(result.none { it.id == ContextEntryId("l1-1") }, "Oldest L1 must be dropped first") + } + + // Parameterized equivalence test: verifies the refactored running-total trimToFit + // produces the same output as the reference naive implementation for varied input shapes. + @ParameterizedTest + @MethodSource("trimToFitEquivalenceCases") + fun `trimToFit running-total matches reference naive implementation`( + entries: List, + budget: TokenBudget + ) { + val expected = naiveTrimToFit(entries, budget) + val actual = compressor.compress(entries, budget, CompressionStrategy.Conversation()) + assertEquals(expected, actual, "running-total trimToFit must match naive O(n²) reference") + } + + // Reference O(n²) implementation kept as spec baseline — intentionally not optimized. + private fun naiveTrimToFit(entries: List, budget: TokenBudget): List { + val dropOrder = listOf(ContextLayer.L2, ContextLayer.L1) + if (entries.sumOf { it.tokenEstimate } <= budget.limit) return entries + val mutable = entries.toMutableList() + for (layer in dropOrder) { + if (mutable.sumOf { it.tokenEstimate } <= budget.limit) break + val layerEntries = mutable.filter { it.layer == layer } + for (e in layerEntries) { + if (mutable.sumOf { it.tokenEstimate } <= budget.limit) break + mutable.remove(e) + } + } + return mutable + } + + companion object { + @JvmStatic + fun trimToFitEquivalenceCases(): List> { + fun e(id: String, layer: ContextLayer, tokens: Int) = + ContextEntry( + id = ContextEntryId(id), + layer = layer, + content = "c-$id", + sourceType = "test", + sourceId = id, + tokenEstimate = tokens + ) + + return listOf( + // All fit — nothing dropped + arrayOf( + listOf(e("a", ContextLayer.L1, 10), e("b", ContextLayer.L2, 10)), + TokenBudget(limit = 100) + ), + // L2 fully dropped, L1 retained + arrayOf( + listOf(e("x1", ContextLayer.L2, 80), e("x2", ContextLayer.L1, 80), e("x3", ContextLayer.L0, 40)), + TokenBudget(limit = 130) + ), + // Both L2 and some L1 dropped + arrayOf( + (1..5).map { e("l2-$it", ContextLayer.L2, 30) } + + (1..5).map { e("l1-$it", ContextLayer.L1, 30) } + + listOf(e("l0-1", ContextLayer.L0, 20)), + TokenBudget(limit = 80) + ), + // Empty list + arrayOf(emptyList(), TokenBudget(limit = 50)), + // Single entry exactly at budget + arrayOf(listOf(e("only", ContextLayer.L2, 50)), TokenBudget(limit = 50)) + ) + } + } +} diff --git a/testing/deterministic/src/test/kotlin/DefaultContextPackBuilderTest.kt b/testing/deterministic/src/test/kotlin/DefaultContextPackBuilderTest.kt new file mode 100644 index 00000000..8a5a6c5f --- /dev/null +++ b/testing/deterministic/src/test/kotlin/DefaultContextPackBuilderTest.kt @@ -0,0 +1,97 @@ +import com.correx.core.context.builder.DefaultContextPackBuilder +import com.correx.core.context.builder.DefaultDecisionPointBuilder +import com.correx.core.context.compression.DefaultContextCompressor +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.ContextLayer +import com.correx.core.context.model.TokenBudget +import com.correx.core.events.types.ContextEntryId +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DefaultContextPackBuilderTest { + + private val compressor = DefaultContextCompressor() + private val builder = DefaultContextPackBuilder(compressor) + private val decisionBuilder = DefaultDecisionPointBuilder() + + private val sessionId = SessionId("s1") + private val stageId = StageId("stage-1") + private val packId = ContextPackId("pack-1") + + private fun entry(id: String, layer: ContextLayer, tokens: Int) = ContextEntry( + id = ContextEntryId(id), + layer = layer, + content = "content-$id", + sourceType = "test", + sourceId = id, + tokenEstimate = tokens + ) + + @Test + fun `build groups entries by layer`() { + val entries = listOf( + entry("a", ContextLayer.L0, 50), + entry("b", ContextLayer.L1, 60), + entry("c", ContextLayer.L2, 40) + ) + val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 4000)) + assertEquals(1, pack.layers[ContextLayer.L0]?.size) + assertEquals(1, pack.layers[ContextLayer.L1]?.size) + assertEquals(1, pack.layers[ContextLayer.L2]?.size) + } + + @Test + fun `budgetUsed reflects total token estimate of retained entries`() { + val entries = listOf( + entry("a", ContextLayer.L0, 100), + entry("b", ContextLayer.L1, 200) + ) + val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 4000)) + assertEquals(300, pack.budgetUsed) + } + + @Test + fun `pack budgetUsed never exceeds budgetLimit`() { + val entries = (1..20).map { entry("e$it", ContextLayer.L2, 100) } + val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 500)) + assertTrue(pack.budgetUsed <= 500) + } + + @Test + fun `DecisionPointBuilder returns only L0 and L1 entries`() { + val entries = listOf( + entry("a", ContextLayer.L0, 50), + entry("b", ContextLayer.L1, 60), + entry("c", ContextLayer.L2, 40) + ) + val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 4000)) + val decisionEntries = decisionBuilder.build(pack) + assertTrue(decisionEntries.all { it.layer == ContextLayer.L0 || it.layer == ContextLayer.L1 }) + assertEquals(2, decisionEntries.size) + } + + @Test + fun `build handles empty entries list`() { + val pack = builder.build(packId, sessionId, stageId, emptyList(), TokenBudget(limit = 1000)) + assertEquals(0, pack.budgetUsed) + assertEquals(0, pack.compressionMetadata.entriesDropped) + assertTrue(pack.layers.isEmpty()) + } + + @Test + fun `steeringNote and eventHistory entries are retained even when exceeding budget`() { + val entries = listOf( + entry("steering", ContextLayer.L0, 500).copy(sourceType = "steeringNote"), + entry("history", ContextLayer.L1, 600).copy(sourceType = "eventHistory"), + entry("normal", ContextLayer.L2, 200) + ) + val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 800)) + val allRetained = pack.layers.values.flatten() + assertTrue(allRetained.any { it.id == ContextEntryId("steering") }, "steeringNote must be retained") + assertTrue(allRetained.any { it.id == ContextEntryId("history") }, "eventHistory must be retained") + } +} diff --git a/testing/deterministic/src/test/kotlin/GraphValidatorTest.kt b/testing/deterministic/src/test/kotlin/GraphValidatorTest.kt new file mode 100644 index 00000000..2c12dda9 --- /dev/null +++ b/testing/deterministic/src/test/kotlin/GraphValidatorTest.kt @@ -0,0 +1,120 @@ +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.TransitionOrdering +import com.correx.core.validation.graph.GraphValidator +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.transition.TransitionValidator +import com.correx.testing.fixtures.CycleFixtures +import com.correx.testing.fixtures.WorkflowFixtures +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GraphValidatorTest { + + private val validator = GraphValidator() + + @Test + fun `should detect dangling transition`() { + + val graph = WorkflowFixtures.simpleGraph().copy( + stages = mapOf(StageId("A") to StageConfig()) // B removed → dangling + ) + + val result = validator.validate( + ValidationContext( + graph = graph, + detectedCycles = emptyList() + ) + ) + + val issues = result.issues + + assertTrue( + issues.any { it.code == "GRAPH_DANGLING_TRANSITION" } + ) + } + + @Test + fun `should expose cycles as informational only`() { + + val graph = WorkflowFixtures.simpleGraph() + val cycles = listOf(CycleFixtures.simpleCycle()) + + val validator = GraphValidator() + + val result = validator.validate( + ValidationContext( + graph = graph, + detectedCycles = cycles + ) + ) + + val cycleIssues = result.issues + .filter { it.code == "GRAPH_CYCLE_DETECTED" } + + assertTrue(cycleIssues.isNotEmpty()) + } + + class TransitionValidatorTest { + + private val validator = TransitionValidator( + ordering = TransitionOrdering.comparator + ) + + @Test + fun `should be deterministic`() { + + val graph = WorkflowFixtures.simpleGraph() + + val result1 = validator.validate( + ValidationContext(graph, emptyList()) + ) + + val result2 = validator.validate( + ValidationContext(graph, emptyList()) + ) + + assertEquals(result1, result2) + } + + @Test + fun `should flag non-deterministic order when two edges share same from and to`() { + + val a = StageId("A") + val b = StageId("B") + + val edge1 = TransitionEdge( + id = TransitionId("t1"), + from = a, + to = b, + condition = { true } + ) + + val edge2 = TransitionEdge( + id = TransitionId("t2"), + from = a, + to = b, + condition = { true } + ) + + val graph = WorkflowGraph( + stages = mapOf(a to StageConfig(), b to StageConfig()), + transitions = setOf(edge1, edge2), + start = a + ) + + val result = validator.validate( + ValidationContext(graph, emptyList()) + ) + + assertTrue( + result.issues.any { it.code == "TRANSITION_NON_DETERMINISTIC_ORDER" }, + "Expected TRANSITION_NON_DETERMINISTIC_ORDER but got: ${result.issues.map { it.code }}" + ) + } + } +} diff --git a/testing/deterministic/src/test/kotlin/SessionReplayDeterminismTest.kt b/testing/deterministic/src/test/kotlin/SessionReplayDeterminismTest.kt new file mode 100644 index 00000000..a72477d5 --- /dev/null +++ b/testing/deterministic/src/test/kotlin/SessionReplayDeterminismTest.kt @@ -0,0 +1,57 @@ +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.SessionPausedEvent +import com.correx.core.events.events.SessionResumedEvent +import com.correx.core.events.events.SessionStartedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.SessionProjector +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.infrastructure.persistence.InMemoryEventStore +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SessionReplayDeterminismTest { + + private fun build(store: EventStore) = DefaultEventReplayer( + store, + SessionProjector(DefaultSessionReducer()) + ) + + @Test + fun `same events produce same state`() { + val sessionId = SessionId("s1") + + val store1 = InMemoryEventStore() + val store2 = InMemoryEventStore() + + val events = mapOf( + EventMetadata(EventId("start"), sessionId, Clock.System.now(), 1, null, null) to SessionStartedEvent( + sessionId + ), + EventMetadata(EventId("paused"), sessionId, Clock.System.now(), 1, null, null) to SessionPausedEvent( + sessionId + ), + EventMetadata(EventId("resumed"), sessionId, Clock.System.now(), 1, null, null) to SessionResumedEvent( + sessionId + ) + ) + + events.forEach { (meta, payload) -> + store1.append(NewEvent(meta, payload)) + store2.append(NewEvent(meta, payload)) + } + + val replayer1 = build(store1) + val replayer2 = build(store2) + + val state1 = replayer1.rebuild(sessionId) + val state2 = replayer2.rebuild(sessionId) + + assertEquals(state1, state2) + } +} \ No newline at end of file diff --git a/testing/deterministic/src/test/kotlin/TokenBudgetEnforcementTest.kt b/testing/deterministic/src/test/kotlin/TokenBudgetEnforcementTest.kt new file mode 100644 index 00000000..0cd34028 --- /dev/null +++ b/testing/deterministic/src/test/kotlin/TokenBudgetEnforcementTest.kt @@ -0,0 +1,42 @@ +import com.correx.core.context.model.TokenBudget +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TokenBudgetEnforcementTest { + + @Test + fun `remaining equals limit minus used`() { + val budget = TokenBudget(limit = 1000, used = 300) + assertEquals(700, budget.remaining) + } + + @Test + fun `canFit returns true when tokens fit`() { + val budget = TokenBudget(limit = 500, used = 100) + assertTrue(budget.canFit(400)) + } + + @Test + fun `canFit returns false when over budget`() { + val budget = TokenBudget(limit = 500, used = 400) + assertFalse(budget.canFit(200)) + } + + @Test + fun `consume returns new instance with updated used`() { + val original = TokenBudget(limit = 1000, used = 0) + val after = original.consume(250) + assertEquals(0, original.used) + assertEquals(250, after.used) + assertEquals(750, after.remaining) + } + + @Test + fun `fully exhausted budget cannot fit any tokens`() { + val budget = TokenBudget(limit = 100, used = 100) + assertFalse(budget.canFit(1)) + assertEquals(0, budget.remaining) + } +} diff --git a/testing/deterministic/src/test/kotlin/ValidationPipelineShortCircuitTest.kt b/testing/deterministic/src/test/kotlin/ValidationPipelineShortCircuitTest.kt new file mode 100644 index 00000000..862079ce --- /dev/null +++ b/testing/deterministic/src/test/kotlin/ValidationPipelineShortCircuitTest.kt @@ -0,0 +1,61 @@ +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.model.ValidationSeverity +import com.correx.core.validation.pipeline.ValidationOutcome +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.core.validation.pipeline.Validator +import com.correx.testing.fixtures.WorkflowFixtures +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test + +class ValidationPipelineShortCircuitTest { + + private val context = ValidationContext(WorkflowFixtures.simpleGraph()) + + private fun errorValidator(name: String) = Validator { _ -> + ValidationSection( + name = name, + issues = listOf(ValidationIssue("ERR", "failure", ValidationSeverity.ERROR)) + ) + } + + @Test + fun `pipeline stops after first validator with errors and report contains only that section`() { + var secondCalled = false + val trackingValidator = Validator { _ -> + secondCalled = true + ValidationSection(name = "second") + } + + val pipeline = ValidationPipeline(listOf(errorValidator("first"), trackingValidator)) + val outcome = pipeline.validate(context) as ValidationOutcome.Rejected + + assertFalse(secondCalled, "second validator must not run after a rejection") + assertEquals(1, outcome.report.sections.size) + assertEquals("first", outcome.report.sections[0].name) + } + + @Test + fun `warnings do not short-circuit the pipeline`() { + var secondCalled = false + + val warningValidator = Validator { _ -> + ValidationSection( + name = "first", + issues = listOf(ValidationIssue("WARN", "warning", ValidationSeverity.WARNING)) + ) + } + val trackingValidator = Validator { _ -> + secondCalled = true + ValidationSection(name = "second") + } + + val pipeline = ValidationPipeline(listOf(warningValidator, trackingValidator)) + pipeline.validate(context) + + assert(secondCalled) { "warnings must not stop pipeline execution" } + } +} diff --git a/testing/fixtures/build.gradle b/testing/fixtures/build.gradle new file mode 100644 index 00000000..612ce4d6 --- /dev/null +++ b/testing/fixtures/build.gradle @@ -0,0 +1,17 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + implementation(project(":core:transitions")) + implementation(project(":core:approvals")) + implementation(project(":core:sessions")) + implementation(project(":core:tools")) + implementation(project(":core:inference")) + implementation(project(":core:context")) + implementation(project(":core:kernel")) + implementation(project(":core:validation")) +} \ No newline at end of file diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/ApprovalFixtures.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/ApprovalFixtures.kt new file mode 100644 index 00000000..9bbeca37 --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/ApprovalFixtures.kt @@ -0,0 +1,39 @@ +package com.correx.testing.fixtures + +import com.correx.core.approvals.Tier +import com.correx.core.approvals.model.DomainApprovalRequest +import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.events.types.RiskSummaryId +import com.correx.core.events.types.ValidationReportId +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.model.ValidationSeverity +import com.correx.core.validation.pipeline.Validator +import kotlinx.datetime.Instant + +fun request( + id: String, + tier: Tier, + timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z"), +) = DomainApprovalRequest( + id = ApprovalRequestId(id), + tier = tier, + validationReportId = ValidationReportId("vr"), + riskSummaryId = RiskSummaryId("rs"), + timestamp = timestamp, + causationId = null, + correlationId = null, + userSteering = null, +) + +fun cyclePolicyMissingValidator(): Validator = Validator { + ValidationSection(name = "cycle policy", issues = listOf( + ValidationIssue( + severity = ValidationSeverity.WARNING, + code = "CYCLE_POLICY_MISSING", + message = "forced for testing" + ) + )) +} + diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/CycleFixtures.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/CycleFixtures.kt new file mode 100644 index 00000000..47ae356f --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/CycleFixtures.kt @@ -0,0 +1,25 @@ +package com.correx.testing.fixtures + +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.transitions.analysis.DetectedCycle +import com.correx.core.transitions.graph.TransitionEdge + +object CycleFixtures { + fun simpleCycle(): DetectedCycle { + val a = StageId("A") + val b = StageId("B") + + return DetectedCycle( + nodes = listOf(a, b), + edges = listOf( + TransitionEdge( + id = TransitionId("t1"), + from = a, + to = b, + condition = { true }, + ) + ) + ) + } +} diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/EventFixtures.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/EventFixtures.kt new file mode 100644 index 00000000..dde182a4 --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/EventFixtures.kt @@ -0,0 +1,53 @@ +package com.correx.testing.fixtures + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolInvokedEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import kotlinx.datetime.Instant + +object EventFixtures { + fun newEvent( + eventId: EventId, + sessionId: SessionId = SessionId("s1"), + payload: EventPayload = ToolInvokedEvent("write"), + timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z") + ): NewEvent { + return NewEvent( + metadata = EventMetadata( + eventId = eventId, + sessionId = sessionId, + timestamp = timestamp, + schemaVersion = 1, + causationId = null, + correlationId = null + ), + payload = payload + ) + } + + fun stored( + eventId: EventId = EventId("e1"), + payload: EventPayload = ToolInvokedEvent("write"), + sessionId: SessionId = SessionId("s1"), + sequence: Long = 1L, + timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z") + ): StoredEvent { + return StoredEvent( + metadata = EventMetadata( + eventId = eventId, + sessionId = sessionId, + timestamp = timestamp, + schemaVersion = 1, + causationId = null, + correlationId = null + ), + sequence = sequence, + payload = payload + ) + + } +} \ No newline at end of file diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/InferenceFixtures.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/InferenceFixtures.kt new file mode 100644 index 00000000..bc73ed49 --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/InferenceFixtures.kt @@ -0,0 +1,148 @@ +package com.correx.testing.fixtures + +import com.correx.core.context.model.ContextPack +import com.correx.core.events.events.InferenceCompletedEvent +import com.correx.core.events.events.NewEvent +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.InferenceRequestId +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.FinishReason +import com.correx.core.inference.GenerationConfig +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRequest +import com.correx.core.inference.InferenceResponse +import com.correx.core.inference.InferenceRouter +import com.correx.core.inference.ModelCapability +import com.correx.core.inference.TokenUsage +import com.correx.testing.fixtures.inference.MockInferenceProvider +import kotlinx.datetime.Clock +import java.util.* + +@Suppress("LongParameterList") +object InferenceFixtures { + + fun fixedProvider(): InferenceProvider { + return MockInferenceProvider( + fixedResponse = "Fixed test response", + artificialDelayMs = 0, + ) + } + + fun fixedRouter(): InferenceRouter { + return object : InferenceRouter { + override suspend fun route( + stageId: StageId, + requiredCapabilities: Set, + ): InferenceProvider { + return fixedProvider() + } + } + } + + fun failingRouter(): InferenceRouter { + return object : InferenceRouter { + override suspend fun route( + stageId: StageId, + requiredCapabilities: Set, + ): InferenceProvider { + return MockInferenceProvider(forcedFailure = "Simulated failure") + } + } + } + + fun contextPack(): ContextPack { + return ContextPack( + id = ContextPackId("cp1"), + sessionId = SessionId("s1"), + stageId = StageId("stage1"), + layers = emptyMap(), + budgetUsed = 0, + budgetLimit = 4096, + ) + } + + fun generationConfig(): GenerationConfig { + return GenerationConfig( + temperature = 0.7, + topP = 1.0, + maxTokens = 2048, + ) + } + + fun inferenceCompleted( + requestId: InferenceRequestId, + sessionId: SessionId, + stageId: StageId, + providerId: ProviderId, + tokensUsed: TokenUsage, + latencyMs: Long, + ): InferenceCompletedEvent { + return InferenceCompletedEvent( + requestId = requestId, + sessionId = sessionId, + stageId = stageId, + providerId = providerId, + tokensUsed = tokensUsed, + latencyMs = latencyMs, + ) + } + + fun inferenceRequest( + sessionId: SessionId, + stageId: StageId, + requestId: InferenceRequestId? = null, + ): InferenceRequest { + return InferenceRequest( + requestId = requestId ?: InferenceRequestId(UUID.randomUUID().toString()), + sessionId = sessionId, + stageId = stageId, + contextPack = contextPack(), + generationConfig = generationConfig(), + ) + } + + fun inferenceResponse( + requestId: InferenceRequestId, + text: String = "test response", + tokensUsed: TokenUsage = TokenUsage(promptTokens = 100, completionTokens = 200), + latencyMs: Long = 500, + ): InferenceResponse { + return InferenceResponse( + requestId = requestId, + text = text, + finishReason = FinishReason.Stop, + tokensUsed = tokensUsed, + latencyMs = latencyMs, + ) + } + + fun newInferenceCompletedEvent( + requestId: InferenceRequestId, + sessionId: SessionId, + stageId: StageId, + providerId: ProviderId, + tokensUsed: TokenUsage, + latencyMs: Long, + ): NewEvent { + return com.correx.core.events.events.NewEvent( + metadata = com.correx.core.events.events.EventMetadata( + eventId = com.correx.core.events.types.EventId("inf-$requestId"), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = InferenceCompletedEvent( + requestId = requestId, + sessionId = sessionId, + stageId = stageId, + providerId = providerId, + tokensUsed = tokensUsed, + latencyMs = latencyMs, + ), + ) + } +} diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/WorkflowFixtures.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/WorkflowFixtures.kt new file mode 100644 index 00000000..df31ec7a --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/WorkflowFixtures.kt @@ -0,0 +1,28 @@ +package com.correx.testing.fixtures + +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph + +object WorkflowFixtures { + + fun simpleGraph(): WorkflowGraph { + val a = StageId("A") + val b = StageId("B") + + val t1 = TransitionEdge( + id = TransitionId("t1"), + from = a, + to = b, + condition = { true } + ) + + return WorkflowGraph( + stages = mapOf(a to StageConfig(), b to StageConfig()), + transitions = setOf(t1), + start = a + ) + } +} diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/context/ContextFixtures.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/context/ContextFixtures.kt new file mode 100644 index 00000000..df97843b --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/context/ContextFixtures.kt @@ -0,0 +1,26 @@ +package com.correx.testing.fixtures.context + +import com.correx.core.context.builder.DefaultContextPackBuilder +import com.correx.core.context.compression.ContextCompressor +import com.correx.core.context.compression.CompressionStrategy +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.TokenBudget + +object ContextFixtures { + + fun simpleCompressor(): ContextCompressor { + return object : ContextCompressor { + override fun compress( + entries: List, + budget: TokenBudget, + strategy: CompressionStrategy + ): List { + return entries + } + } + } + + fun simpleBuilder(): DefaultContextPackBuilder { + return DefaultContextPackBuilder(simpleCompressor()) + } +} diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/inference/MockInferenceProvider.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/inference/MockInferenceProvider.kt new file mode 100644 index 00000000..fee68cd9 --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/inference/MockInferenceProvider.kt @@ -0,0 +1,72 @@ +package com.correx.testing.fixtures.inference + +import com.correx.core.events.types.ProviderId +import com.correx.core.inference.CapabilityScore +import com.correx.core.inference.FinishReason +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRequest +import com.correx.core.inference.InferenceResponse +import com.correx.core.inference.ModelCapability +import com.correx.core.inference.ProviderHealth +import com.correx.core.inference.TokenUsage +import com.correx.core.inference.Tokenizer +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive + +/** + * Configurable fake provider for deterministic tests. + * + * Modes (mutually exclusive, checked in order): + * 1. [forcedFailure] — throws [InferenceProviderException] immediately + * 2. [artificialDelayMs] > 0 — suspends before responding (tests timeout/cancel) + * 3. default — returns [fixedResponse] immediately + */ +@Suppress("LongParameterList") +class MockInferenceProvider( + override val id: ProviderId = ProviderId("mock"), + override val name: String = "Mock Provider", + private val fixedResponse: String = "mock response", + private val artificialDelayMs: Long = 0L, + private val forcedFailure: String? = null, + private val declaredCapabilities: Set = setOf( + CapabilityScore(ModelCapability.General, 1.0), + ), + private val health: ProviderHealth = ProviderHealth.Healthy, +) : InferenceProvider { + + override val tokenizer: Tokenizer = MockTokenizer() + + var inferCallCount: Int = 0 + private set + + override suspend fun infer(request: InferenceRequest): InferenceResponse { + inferCallCount++ + + if (forcedFailure != null) throw InferenceProviderException(forcedFailure) + + if (artificialDelayMs > 0L) { + delay(artificialDelayMs) + currentCoroutineContext().ensureActive() // cooperative cancellation checkpoint + } + + val usage = TokenUsage( + promptTokens = tokenizer.countTokens(request.contextPack.toString()), + completionTokens = tokenizer.countTokens(fixedResponse), + ) + + return InferenceResponse( + requestId = request.requestId, + text = fixedResponse, + finishReason = FinishReason.Stop, + tokensUsed = usage, + latencyMs = artificialDelayMs, + ) + } + + override suspend fun healthCheck(): ProviderHealth = health + + override fun capabilities(): Set = declaredCapabilities +} + +class InferenceProviderException(message: String) : Exception(message) \ No newline at end of file diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/inference/MockTokenizer.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/inference/MockTokenizer.kt new file mode 100644 index 00000000..bda81c7f --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/inference/MockTokenizer.kt @@ -0,0 +1,17 @@ +package com.correx.testing.fixtures.inference + +import com.correx.core.inference.Token +import com.correx.core.inference.Tokenizer + +/** + * Character-based approximation. Deterministic. Not model-accurate. + * Sufficient for contract and budget tests. + */ +@Suppress("MagicNumber") +class MockTokenizer : Tokenizer { + override suspend fun tokenize(text: String): List = + List(text.chunked(4).size) { i -> Token(i) } // 1 token ≈ 4 chars + + override suspend fun countTokens(text: String): Int = + (text.length + 3) / 4 +} \ No newline at end of file diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/kernel/MockEventReplayer.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/kernel/MockEventReplayer.kt new file mode 100644 index 00000000..d93cbb6a --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/kernel/MockEventReplayer.kt @@ -0,0 +1,24 @@ +package com.correx.testing.fixtures.kernel + +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.SessionState +import com.correx.core.sessions.SessionStatus +import com.correx.core.sessions.projections.replay.EventReplayer +import kotlinx.datetime.Clock + +class MockEventReplayer : EventReplayer { + private val states = mutableMapOf() + + fun setSessionState(sessionId: SessionId, state: SessionState) { + states[sessionId] = state + } + + override fun rebuild(sessionId: SessionId): SessionState { + return states[sessionId] ?: SessionState( + status = SessionStatus.ACTIVE, + createdAt = Clock.System.now(), + updatedAt = Clock.System.now(), + invalidTransitions = 0 + ) + } +} diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/kernel/MockSessionRepository.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/kernel/MockSessionRepository.kt new file mode 100644 index 00000000..479bafa9 --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/kernel/MockSessionRepository.kt @@ -0,0 +1,30 @@ +package com.correx.testing.fixtures.kernel + +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.Session +import com.correx.core.sessions.SessionState +import com.correx.core.sessions.SessionStatus +import kotlinx.datetime.Clock + +class MockSessionRepository { + private val sessions = mutableMapOf() + fun setSession(sessionId: SessionId, session: Session) { + sessions[sessionId] = session + } + + fun getSession(sessionId: SessionId): Session { + return sessions[sessionId] ?: Session( + sessionId = sessionId, + state = SessionState( + status = SessionStatus.ACTIVE, + createdAt = Clock.System.now(), + updatedAt = Clock.System.now(), + invalidTransitions = 0, + ), + ) + } + + fun rebuild(sessionId: SessionId): Session { + return getSession(sessionId) + } +} diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/tools/EchoTool.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/tools/EchoTool.kt new file mode 100644 index 00000000..6233ee8a --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/tools/EchoTool.kt @@ -0,0 +1,14 @@ +package com.correx.testing.fixtures.tools + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ValidationResult + +class EchoTool : Tool { + override val name: String = "echo" + override val tier: Tier = Tier.T0 + override val requiredCapabilities: Set = emptySet() + override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid +} diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/tools/FailingTool.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/tools/FailingTool.kt new file mode 100644 index 00000000..fbf9ddbb --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/tools/FailingTool.kt @@ -0,0 +1,15 @@ +package com.correx.testing.fixtures.tools + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ValidationResult + +class FailingTool : Tool { + override val name: String = "failing" + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = emptySet() + override fun validateRequest(request: ToolRequest): ValidationResult = + ValidationResult.Invalid("simulated validation failure") +} diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/tools/RejectedTool.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/tools/RejectedTool.kt new file mode 100644 index 00000000..8b4d1286 --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/tools/RejectedTool.kt @@ -0,0 +1,14 @@ +package com.correx.testing.fixtures.tools + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ValidationResult + +class RejectedTool : Tool { + override val name: String = "rejected" + override val tier: Tier = Tier.T4 + override val requiredCapabilities: Set = emptySet() + override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid +} diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/transitions/TransitionFixtures.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/transitions/TransitionFixtures.kt new file mode 100644 index 00000000..93af894d --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/transitions/TransitionFixtures.kt @@ -0,0 +1,45 @@ +package com.correx.testing.fixtures.transitions + +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.transitions.evaluation.EvaluationContext +import com.correx.core.transitions.evaluation.TransitionConditionEvaluator +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionCondition +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.DefaultTransitionResolver +import com.correx.core.transitions.resolution.TransitionResolver +import com.correx.testing.fixtures.WorkflowFixtures + +object TransitionFixtures { + + fun alwaysTrueEvaluator(): TransitionConditionEvaluator { + return object : TransitionConditionEvaluator { + override fun evaluate(condition: TransitionCondition, context: EvaluationContext): Boolean { + return true + } + } + } + + fun simpleResolver(): TransitionResolver { + return DefaultTransitionResolver(alwaysTrueEvaluator()) + } + + fun threeStageGraph(): WorkflowGraph { + val a = StageId("A") + val b = StageId("B") + val c = StageId("C") + + return WorkflowGraph( + stages = mapOf(a to StageConfig(), b to StageConfig(), c to StageConfig()), + transitions = setOf( + TransitionEdge(id = TransitionId("t1"), from = a, to = b, condition = { true }), + TransitionEdge(id = TransitionId("t2"), from = b, to = c, condition = { true }), + ), + start = a + ) + } + + fun simpleGraph(): WorkflowGraph = WorkflowFixtures.simpleGraph() +} diff --git a/testing/fixtures/src/test/kotlin/com/correx/testing/fixtures/tools/MockToolsTest.kt b/testing/fixtures/src/test/kotlin/com/correx/testing/fixtures/tools/MockToolsTest.kt new file mode 100644 index 00000000..6c263b5e --- /dev/null +++ b/testing/fixtures/src/test/kotlin/com/correx/testing/fixtures/tools/MockToolsTest.kt @@ -0,0 +1,42 @@ +package com.correx.testing.fixtures.tools + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.contract.ValidationResult +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertInstanceOf + +class MockToolsTest { + + private val request = ToolRequest( + invocationId = ToolInvocationId("inv-1"), + sessionId = SessionId("s-1"), + stageId = StageId("st-1"), + toolName = "test" + ) + + @Test + fun `EchoTool is T0 and validates any request as Valid`() { + val tool = EchoTool() + assertEquals(Tier.T0, tool.tier) + assertInstanceOf(tool.validateRequest(request)) + } + + @Test + fun `FailingTool is T2 and validates any request as Invalid`() { + val tool = FailingTool() + assertEquals(Tier.T2, tool.tier) + assertInstanceOf(tool.validateRequest(request)) + } + + @Test + fun `RejectedTool is T4 and validates any request as Valid`() { + val tool = RejectedTool() + assertEquals(Tier.T4, tool.tier) + assertInstanceOf(tool.validateRequest(request)) + } +} diff --git a/testing/integration/build.gradle b/testing/integration/build.gradle new file mode 100644 index 00000000..37193e65 --- /dev/null +++ b/testing/integration/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + testImplementation(project(":core:events")) + testImplementation(project(":core:sessions")) + testImplementation(project(":core:transitions")) + testImplementation(project(":core:validation")) + testImplementation(project(":core:approvals")) + testImplementation(project(":core:context")) + testImplementation(project(":core:inference")) + testImplementation(project(":core:kernel")) + testImplementation(project(":core:risk")) + testImplementation(project(":infrastructure:persistence")) + testImplementation(project(":testing:fixtures")) + testImplementation(project(":testing:kernel")) + testImplementation(project(":testing:contracts")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") +} \ No newline at end of file diff --git a/testing/integration/src/main/kotlin/com/correx/testing/integration/Module.kt b/testing/integration/src/main/kotlin/com/correx/testing/integration/Module.kt new file mode 100644 index 00000000..dac6e0eb --- /dev/null +++ b/testing/integration/src/main/kotlin/com/correx/testing/integration/Module.kt @@ -0,0 +1,3 @@ +package com.correx.testing.integration + +object Module diff --git a/testing/integration/src/test/kotlin/SessionOrchestratorIntegrationTest.kt b/testing/integration/src/test/kotlin/SessionOrchestratorIntegrationTest.kt new file mode 100644 index 00000000..ea243e8b --- /dev/null +++ b/testing/integration/src/test/kotlin/SessionOrchestratorIntegrationTest.kt @@ -0,0 +1,300 @@ +import com.correx.core.approvals.domain.ApprovalEngine +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.DomainApprovalRequest +import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.OrchestrationResumedEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.events.WorkflowFailedEvent +import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.InferenceRouter +import com.correx.core.inference.InferenceState +import com.correx.core.inference.ModelCapability +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.core.risk.DefaultRiskAssessor +import com.correx.core.sessions.ApprovalMode +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.validation.approval.ApprovalTrigger +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.testing.fixtures.InferenceFixtures +import com.correx.testing.fixtures.context.ContextFixtures +import com.correx.testing.fixtures.cyclePolicyMissingValidator +import com.correx.testing.fixtures.inference.MockInferenceProvider +import com.correx.testing.fixtures.transitions.TransitionFixtures +import com.correx.testing.fixtures.transitions.TransitionFixtures.threeStageGraph +import com.correx.testing.kernel.MockSessionEventReplayer +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +@Suppress("UnusedPrivateProperty") +class SessionOrchestratorIntegrationTest { + private val eventStore = InMemoryEventStore() + private val graph = TransitionFixtures.simpleGraph() + private val transitionResolver = TransitionFixtures.simpleResolver() + private val contextPackBuilder = ContextFixtures.simpleBuilder() + private val inferenceRouter = InferenceFixtures.fixedRouter() + private val validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())) + private val approvalEngine = DefaultApprovalEngine() + private val retryCoordinator = DefaultRetryCoordinator(eventStore) + private val sessionReplayer = MockSessionEventReplayer() + private val sessionRepository = DefaultSessionRepository(sessionReplayer) + val orchestrationReplayer = DefaultEventReplayer( + eventStore, + OrchestrationProjector(DefaultOrchestrationReducer()), + ) + val orchestrationRepository = OrchestrationRepository(orchestrationReplayer) + private val inferenceRepository = InferenceRepository( + object : EventReplayer { + override fun rebuild(sessionId: com.correx.core.events.types.SessionId) = InferenceState() + }, + ) + private val riskAssessor = DefaultRiskAssessor() + + private val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = inferenceRepository, + orchestrationRepository = orchestrationRepository, + sessionRepository = sessionRepository, + ) + + private val engines = OrchestratorEngines( + transitionResolver = transitionResolver, + contextPackBuilder = contextPackBuilder, + inferenceRouter = inferenceRouter, + validationPipeline = validationPipeline, + approvalEngine = approvalEngine, + riskAssessor = riskAssessor, + ) + + private val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = engines, + retryCoordinator = retryCoordinator, + ) + + @Test + fun `successful workflow completes all stages`(): Unit = runBlocking { + val sessionId = SessionId("s1") + val config = OrchestrationConfig( + retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0), + ) + + orchestrator.run(sessionId, graph, config) + + val events = eventStore.read(sessionId) + assertEquals(2, events.size) + + val workflowStarted = events.find { it.payload is WorkflowStartedEvent } + assertNotNull(workflowStarted) + assertEquals( + StageId("A"), + (workflowStarted?.payload as? WorkflowStartedEvent)?.startStageId, + ) + + val workflowCompleted = events.find { it.payload is WorkflowCompletedEvent } + assertNotNull(workflowCompleted) + assertEquals( + StageId("B"), + (workflowCompleted?.payload as WorkflowCompletedEvent).terminalStageId, + ) + assertEquals(1, (workflowCompleted.payload as WorkflowCompletedEvent).totalStages) + } + + @Test + fun `workflow fails with retry exhaustion`(): Unit = runBlocking { + val sessionId = SessionId("s2") + val config = OrchestrationConfig( + retryPolicy = RetryPolicy(maxAttempts = 2, backoffMs = 0), + ) + val graph = threeStageGraph() + + val failingOrchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = engines.copy( + inferenceRouter = object : InferenceRouter { + override suspend fun route(stageId: StageId, requiredCapabilities: Set) = + MockInferenceProvider(forcedFailure = "Simulated failure") + }, + ), + retryCoordinator = retryCoordinator, + ) + failingOrchestrator.run(sessionId, graph, config) + + val events = eventStore.read(sessionId) + val workflowFailed = events.find { it.payload is WorkflowFailedEvent } + assertNotNull(workflowFailed) + val failed = workflowFailed?.payload as WorkflowFailedEvent + assertEquals("Simulated failure", failed.reason) + assertTrue(failed.retryExhausted) + } + + @Test + fun `workflow pauses for approval when required`(): Unit = runBlocking { + val sessionId = SessionId("s3") + val config = OrchestrationConfig( + retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0), + ) + val graph = threeStageGraph() + val approvingPipeline = ValidationPipeline( + validators = listOf(cyclePolicyMissingValidator()), + approvalTrigger = ApprovalTrigger(), + ) + val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = engines.copy(validationPipeline = approvingPipeline), + retryCoordinator = retryCoordinator, + ) + + orchestrator.run(sessionId, graph, config) + + val events = eventStore.read(sessionId) + val orPause = events.find { it.payload is OrchestrationPausedEvent } + assertNotNull(orPause) + val paused = orPause?.let { it.payload as? OrchestrationPausedEvent } + assertEquals("APPROVAL_PENDING", paused?.reason) + } + + @Test + fun `workflow resumes after approval`(): Unit = runBlocking { + val sessionId = SessionId("s4") + val config = OrchestrationConfig( + retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0), + ) + val graph = threeStageGraph() + val yoloApprovalEngine = object : ApprovalEngine { + val inner = DefaultApprovalEngine() + override fun evaluate( + request: DomainApprovalRequest, + context: ApprovalContext, + grants: List, + now: Instant, + ) = + inner.evaluate(request, ApprovalContext(context.identity, ApprovalMode.YOLO), grants, now) + } + + val approvingPipeline = ValidationPipeline( + validators = listOf(cyclePolicyMissingValidator()), + approvalTrigger = ApprovalTrigger(), + ) + + val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = engines.copy( + validationPipeline = approvingPipeline, + approvalEngine = yoloApprovalEngine, + ), + retryCoordinator = retryCoordinator, + ) + + orchestrator.run(sessionId, graph, config) + + val events = eventStore.read(sessionId) + val resumeCount = events.count { it.payload is OrchestrationResumedEvent } + assertEquals(1, resumeCount) + } + + @Test + fun `workflow with empty graph fails immediately`(): Unit = runBlocking { + val emptyGraph = WorkflowGraph( + stages = mapOf(StageId("start") to StageConfig()), + transitions = emptySet(), + start = StageId("start"), + ) + val config = OrchestrationConfig( + retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0), + ) + + val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = engines, + retryCoordinator = retryCoordinator, + ) + + val sessionId = SessionId("s6") + orchestrator.run(sessionId, emptyGraph, config) + + val events = eventStore.read(sessionId) + val workflowFailed = events.find { it.payload is WorkflowFailedEvent } + assertNotNull(workflowFailed) + val failed = workflowFailed?.payload as WorkflowFailedEvent + assertNotNull(failed.reason) + } + + @Test + fun `run throws IllegalArgumentException when transition targets undeclared stage`(): Unit = runBlocking { + val a = StageId("A") + val b = StageId("B") + val ghost = StageId("ghost") + val z = StageId("Z") + // ghost is missing from stages map but has an outgoing transition so it is non-terminal + val brokenGraph = WorkflowGraph( + stages = mapOf(a to StageConfig(), b to StageConfig()), + transitions = setOf( + com.correx.core.transitions.graph.TransitionEdge( + id = com.correx.core.events.types.TransitionId("t1"), from = a, to = b, + condition = { true }, + ), + com.correx.core.transitions.graph.TransitionEdge( + id = com.correx.core.events.types.TransitionId("t2"), from = b, to = ghost, + condition = { true }, + ), + com.correx.core.transitions.graph.TransitionEdge( + id = com.correx.core.events.types.TransitionId("t3"), from = ghost, to = z, + condition = { true }, + ), + ), + start = a, + ) + val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)) + val sessionId = SessionId("s7") + + val ex = org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException::class.java) { + kotlinx.coroutines.runBlocking { orchestrator.run(sessionId, brokenGraph, config) } + } + assertTrue(ex.message?.contains("ghost") == true, "Expected message to mention 'ghost', got: ${ex.message}") + } + + @Test + fun `WorkflowStartedEvent carries retryPolicy from config`(): Unit = runBlocking { + val sessionId = SessionId("s8") + val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 5, backoffMs = 0)) + + orchestrator.run(sessionId, graph, config) + + val events = eventStore.read(sessionId) + val started = events.first { it.payload is WorkflowStartedEvent }.payload as WorkflowStartedEvent + assertEquals(5, started.retryPolicy?.maxAttempts) + } + + @Test + fun `OrchestrationState retryPolicy reflects config after workflow starts`(): Unit = runBlocking { + val sessionId = SessionId("s9") + val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 7, backoffMs = 0)) + + orchestrator.run(sessionId, graph, config) + + val state = orchestrationRepository.getState(sessionId) + assertEquals(7, state.retryPolicy?.maxAttempts) + } +} diff --git a/testing/integration/src/test/kotlin/ValidationPipelineIntegrationTest.kt b/testing/integration/src/test/kotlin/ValidationPipelineIntegrationTest.kt new file mode 100644 index 00000000..9154829d --- /dev/null +++ b/testing/integration/src/test/kotlin/ValidationPipelineIntegrationTest.kt @@ -0,0 +1,71 @@ +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.TransitionOrdering +import com.correx.core.validation.approval.ApprovalTrigger +import com.correx.core.validation.graph.GraphValidator +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.pipeline.ValidationOutcome +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.core.validation.semantic.SemanticValidator +import com.correx.core.validation.semantic.rules.CyclePolicyBindingRule +import com.correx.core.validation.transition.TransitionValidator +import com.correx.testing.fixtures.CycleFixtures +import com.correx.testing.fixtures.WorkflowFixtures +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test + +class ValidationPipelineIntegrationTest { + + private val pipeline = ValidationPipeline( + validators = listOf( + GraphValidator(), + TransitionValidator(TransitionOrdering.comparator), + SemanticValidator( + rules = listOf( + CyclePolicyBindingRule(requirePolicyForCycles = true), + ), + ), + ), + approvalTrigger = ApprovalTrigger() + ) + + @Test + fun `full validation pipeline returns NeedsApproval when cycles are unbound`() { + + val graph = WorkflowFixtures.simpleGraph() + + val cycles = listOf(CycleFixtures.simpleCycle()) + + val context = ValidationContext( + graph = graph, + detectedCycles = cycles, + cyclePolicies = emptySet(), + ) + + val outcome = pipeline.validate(context) + + assertInstanceOf(ValidationOutcome.NeedsApproval::class.java, outcome) + } + + @Test + fun `rejected outcome retryable is false — set by validator, not orchestrator`() { + // A dangling transition triggers GraphValidator ERROR → Rejected(retryable=false). + // Ownership rule: the validator sets retryable; the orchestrator must only read it. + val a = StageId("A") + val ghost = StageId("GHOST") + val graphWithDanglingTransition = WorkflowGraph( + stages = mapOf(a to StageConfig()), + transitions = setOf(TransitionEdge(TransitionId("t1"), from = a, to = ghost) { true }), + start = a, + ) + val pipeline = ValidationPipeline(validators = listOf(GraphValidator()), approvalTrigger = null) + val outcome = pipeline.validate(ValidationContext(graph = graphWithDanglingTransition)) + + assertInstanceOf(ValidationOutcome.Rejected::class.java, outcome) + assertFalse((outcome as ValidationOutcome.Rejected).retryable) + } +} \ No newline at end of file diff --git a/testing/kernel/build.gradle b/testing/kernel/build.gradle new file mode 100644 index 00000000..d0ea61ea --- /dev/null +++ b/testing/kernel/build.gradle @@ -0,0 +1,17 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + implementation(project(":core:kernel")) + implementation(project(":core:sessions")) + implementation(project(":core:inference")) + implementation project(':testing:fixtures') + + testImplementation "org.jetbrains.kotlin:kotlin-test" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test" + testImplementation project(':infrastructure:persistence') +} diff --git a/testing/kernel/src/main/kotlin/com/correx/testing/kernel/MockEventReplayer.kt b/testing/kernel/src/main/kotlin/com/correx/testing/kernel/MockEventReplayer.kt new file mode 100644 index 00000000..b6034671 --- /dev/null +++ b/testing/kernel/src/main/kotlin/com/correx/testing/kernel/MockEventReplayer.kt @@ -0,0 +1,42 @@ +package com.correx.testing.kernel + +import com.correx.core.events.orchestration.OrchestrationState +import com.correx.core.events.orchestration.OrchestrationStatus +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.sessions.SessionState +import com.correx.core.sessions.SessionStatus +import com.correx.core.sessions.projections.replay.EventReplayer +import kotlinx.datetime.Clock + +class MockOrchestrationEventReplayer : EventReplayer { + private val states = mutableMapOf() + + fun setSessionState(sessionId: SessionId, state: OrchestrationState) { + states[sessionId] = state + } + + override fun rebuild(sessionId: SessionId): OrchestrationState { + return states[sessionId] ?: OrchestrationState( + status = OrchestrationStatus.RUNNING, + currentStageId = StageId("stage-1"), + ) + } +} + +class MockSessionEventReplayer : EventReplayer { + private val states = mutableMapOf() + + fun setSessionState(sessionId: SessionId, state: SessionState) { + states[sessionId] = state + } + + override fun rebuild(sessionId: SessionId): SessionState { + return states[sessionId] ?: SessionState( + status = SessionStatus.ACTIVE, + createdAt = Clock.System.now(), + updatedAt = Clock.System.now(), + invalidTransitions = 0, + ) + } +} \ No newline at end of file diff --git a/testing/kernel/src/test/kotlin/DefaultRetryCoordinatorTest.kt b/testing/kernel/src/test/kotlin/DefaultRetryCoordinatorTest.kt new file mode 100644 index 00000000..9dd6e254 --- /dev/null +++ b/testing/kernel/src/test/kotlin/DefaultRetryCoordinatorTest.kt @@ -0,0 +1,168 @@ +import com.correx.core.events.events.RetryAttemptedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.infrastructure.persistence.InMemoryEventStore +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.* +import kotlin.test.assertEquals + +class DefaultRetryCoordinatorTest { + + private lateinit var eventStore: EventStore + private lateinit var retryCoordinator: DefaultRetryCoordinator + + @BeforeEach + fun setup() { + eventStore = InMemoryEventStore() + retryCoordinator = DefaultRetryCoordinator(eventStore) + } + + @Test + fun `should return true when currentAttempt is 0 and maxAttempts is 3`() = runTest { + val sessionId = SessionId(UUID.randomUUID().toString()) + val stageId = StageId("stage1") + val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L) + + val result = retryCoordinator.shouldRetry( + sessionId = sessionId, + stageId = stageId, + currentAttempt = 0, + policy = policy, + failureReason = "test failure", + ) + + Assertions.assertTrue(result) + + val events = eventStore.read(sessionId) + Assertions.assertEquals(1, events.size) + val event = events.first().payload as RetryAttemptedEvent + Assertions.assertEquals(1, event.attemptNumber) + Assertions.assertEquals(stageId, event.stageId) + Assertions.assertEquals("test failure", event.failureReason) + } + + @Test + fun `should return true when currentAttempt is 2 and maxAttempts is 3`() = runTest { + val sessionId = SessionId(UUID.randomUUID().toString()) + val stageId = StageId("stage1") + val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L) + + // First retry + retryCoordinator.shouldRetry( + sessionId = sessionId, + stageId = stageId, + currentAttempt = 0, + policy = policy, + failureReason = "test failure", + ) + + // Second retry (currentAttempt = 2, which is the third attempt) + val result = retryCoordinator.shouldRetry( + sessionId = sessionId, + stageId = stageId, + currentAttempt = 2, + policy = policy, + failureReason = "test failure", + ) + + Assertions.assertTrue(result) + + val events = eventStore.read(sessionId) + Assertions.assertEquals(2, events.size) + val lastEvent = events.last().payload as RetryAttemptedEvent + Assertions.assertEquals(3, lastEvent.attemptNumber) + Assertions.assertEquals("test failure", lastEvent.failureReason) + } + + @Test + fun `should return false when currentAttempt equals maxAttempts`() = runTest { + val sessionId = SessionId(UUID.randomUUID().toString()) + val stageId = StageId("stage1") + val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L) + + // First two retries + retryCoordinator.shouldRetry( + sessionId = sessionId, + stageId = stageId, + currentAttempt = 0, + policy = policy, + failureReason = "test failure", + ) + retryCoordinator.shouldRetry( + sessionId = sessionId, + stageId = stageId, + currentAttempt = 1, + policy = policy, + failureReason = "test failure", + ) + + // currentAttempt = 3 equals maxAttempts = 3, should not retry + val result = retryCoordinator.shouldRetry( + sessionId = sessionId, + stageId = stageId, + currentAttempt = 3, + policy = policy, + failureReason = "test failure", + ) + + Assertions.assertFalse(result) + + val events = eventStore.read(sessionId) + Assertions.assertEquals(2, events.size) + } + + @Test + fun `should return false when currentAttempt is greater than maxAttempts`() = runTest { + val sessionId = SessionId(UUID.randomUUID().toString()) + val stageId = StageId("stage1") + val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L) + + // First retry + retryCoordinator.shouldRetry( + sessionId = sessionId, + stageId = stageId, + currentAttempt = 0, + policy = policy, + failureReason = "test failure", + ) + + // currentAttempt = 5 > maxAttempts = 3, should not retry + val result = retryCoordinator.shouldRetry( + sessionId = sessionId, + stageId = stageId, + currentAttempt = 5, + policy = policy, + failureReason = "test failure", + ) + + Assertions.assertFalse(result) + + val events = eventStore.read(sessionId) + Assertions.assertEquals(1, events.size) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `should delay by backoffMs before retrying`() = runTest { + val policy = RetryPolicy(maxAttempts = 3, backoffMs = 1000L) + + retryCoordinator.shouldRetry( + sessionId = SessionId(UUID.randomUUID().toString()), + stageId = StageId("stage1"), + currentAttempt = 0, + policy = policy, + failureReason = "test failure", + ) + + // virtual time should have advanced by backoffMs + assertEquals(1000L, currentTime) + } +} \ No newline at end of file diff --git a/testing/kernel/src/test/kotlin/ReplayInferenceProviderTest.kt b/testing/kernel/src/test/kotlin/ReplayInferenceProviderTest.kt new file mode 100644 index 00000000..7510b21e --- /dev/null +++ b/testing/kernel/src/test/kotlin/ReplayInferenceProviderTest.kt @@ -0,0 +1,161 @@ +import com.correx.core.events.types.InferenceRequestId +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.FinishReason +import com.correx.core.inference.ProviderHealth +import com.correx.core.inference.TokenUsage +import com.correx.core.kernel.replay.ReplayInferenceProvider +import com.correx.core.kernel.retry.exception.ReplayArtifactMissingException +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.testing.fixtures.InferenceFixtures +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class ReplayInferenceProviderTest { + + private lateinit var eventStore: InMemoryEventStore + private lateinit var replayProvider: ReplayInferenceProvider + + @BeforeEach + fun setup() { + eventStore = InMemoryEventStore() + replayProvider = ReplayInferenceProvider(eventStore) + } + + @Test + fun `response carries correct tokensUsed and latencyMs when matching stageId`() = runTest { + val sessionId = SessionId("s1") + val stageId = StageId("stage1") + val requestId = InferenceRequestId("req1") + val providerId = ProviderId("test-provider") + val tokensUsed = TokenUsage(promptTokens = 100, completionTokens = 200) + val latencyMs = 500L + + // Record an inference completed event + eventStore.append( + InferenceFixtures.newInferenceCompletedEvent( + requestId = requestId, + sessionId = sessionId, + stageId = stageId, + providerId = providerId, + tokensUsed = tokensUsed, + latencyMs = latencyMs, + ), + ) + + val request = InferenceFixtures.inferenceRequest( + sessionId = sessionId, + stageId = stageId, + requestId = requestId, + ) + + val response = replayProvider.infer(request) + + Assertions.assertEquals(request.requestId, response.requestId) + Assertions.assertEquals(tokensUsed, response.tokensUsed) + Assertions.assertEquals(latencyMs, response.latencyMs) + Assertions.assertEquals("", response.text) + Assertions.assertEquals(FinishReason.Stop, response.finishReason) + } + + @Test + fun `ReplayArtifactMissingException thrown when event for different stageId`(): Unit = runBlocking { + val sessionId = SessionId("s1") + val stageId1 = StageId("stage1") + val stageId2 = StageId("stage2") + val requestId = InferenceRequestId("req1") + val providerId = ProviderId("test-provider") + + // Record event for stage1 + eventStore.append( + InferenceFixtures.newInferenceCompletedEvent( + requestId = requestId, + sessionId = sessionId, + stageId = stageId1, + providerId = providerId, + tokensUsed = TokenUsage(promptTokens = 100, completionTokens = 200), + latencyMs = 500L, + ), + ) + + val request = InferenceFixtures.inferenceRequest( + sessionId = sessionId, + stageId = stageId2, // Different stage + requestId = requestId, + ) + + assertThrows { + replayProvider.infer(request) + } + } + + @Test + fun `ReplayArtifactMissingException thrown when event store is empty`(): Unit = runBlocking { + val sessionId = SessionId("s1") + val stageId = StageId("stage1") + val requestId = InferenceRequestId("req1") + + val request = InferenceFixtures.inferenceRequest( + sessionId = sessionId, + stageId = stageId, + requestId = requestId, + ) + + assertThrows { + replayProvider.infer(request) + } + } + + @Test + fun `text is empty string by design`() = runTest { + val sessionId = SessionId("s1") + val stageId = StageId("stage1") + val requestId = InferenceRequestId("req1") + val providerId = ProviderId("test-provider") + + eventStore.append( + InferenceFixtures.newInferenceCompletedEvent( + requestId = requestId, + sessionId = sessionId, + stageId = stageId, + providerId = providerId, + tokensUsed = TokenUsage(promptTokens = 100, completionTokens = 50), + latencyMs = 300L, + ), + ) + + val request = InferenceFixtures.inferenceRequest( + sessionId = sessionId, + stageId = stageId, + requestId = requestId, + ) + + val response = replayProvider.infer(request) + + Assertions.assertEquals("", response.text) + } + + @Test + fun `capabilities returns empty set`() { + val capabilities = replayProvider.capabilities() + + Assertions.assertTrue(capabilities.isEmpty()) + } + + @Test + fun `healthCheck returns Healthy`() = runTest { + val health = replayProvider.healthCheck() + + Assertions.assertEquals(ProviderHealth.Healthy, health) + } + + @Test + fun `id is ReplayProvider`() { + Assertions.assertEquals(ProviderId("replay-provider"), replayProvider.id) + } +} \ No newline at end of file diff --git a/testing/projections/build.gradle b/testing/projections/build.gradle new file mode 100644 index 00000000..ac92c5b6 --- /dev/null +++ b/testing/projections/build.gradle @@ -0,0 +1,16 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + testImplementation(project(":core:events")) + testImplementation(project(":core:sessions")) + testImplementation(project(":core:transitions")) + testImplementation(project(":core:context")) + testImplementation(project(":core:inference")) + testImplementation(project(":core:kernel")) + testImplementation(project(":core:artifacts")) + implementation(project(":testing:fixtures")) +} \ No newline at end of file diff --git a/testing/projections/src/main/kotlin/com/correx/testing/projections/Module.kt b/testing/projections/src/main/kotlin/com/correx/testing/projections/Module.kt new file mode 100644 index 00000000..1b5e1b82 --- /dev/null +++ b/testing/projections/src/main/kotlin/com/correx/testing/projections/Module.kt @@ -0,0 +1,3 @@ +package com.correx.testing.projections + +object Module diff --git a/testing/projections/src/test/kotlin/ArtifactReducerTest.kt b/testing/projections/src/test/kotlin/ArtifactReducerTest.kt new file mode 100644 index 00000000..a613e759 --- /dev/null +++ b/testing/projections/src/test/kotlin/ArtifactReducerTest.kt @@ -0,0 +1,128 @@ +import com.correx.core.artifacts.ArtifactState +import com.correx.core.artifacts.DefaultArtifactReducer +import com.correx.core.events.events.ArtifactArchivedEvent +import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.events.ArtifactRejectedEvent +import com.correx.core.events.events.ArtifactRelationshipAddedEvent +import com.correx.core.events.events.ArtifactSupersededEvent +import com.correx.core.events.events.ArtifactValidatedEvent +import com.correx.core.events.events.ArtifactValidatingEvent +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.ArtifactLifecyclePhase +import com.correx.core.events.types.ArtifactRelationshipType +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.testing.fixtures.EventFixtures.stored +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ArtifactReducerTest { + + private lateinit var reducer: DefaultArtifactReducer + + private val artifactId = ArtifactId("art1") + private val sessionId = SessionId("sess1") + private val stageId = StageId("stage1") + + @BeforeEach + fun setup() { + reducer = DefaultArtifactReducer() + } + + @Test + fun `CREATED to VALIDATING is valid`() { + val state = ArtifactState(phase = ArtifactLifecyclePhase.CREATED) + val event = stored(payload = ArtifactValidatingEvent(artifactId, sessionId, stageId)) + val result = reducer.reduce(state, event) + assertTrue(result.isSuccess) + assertEquals(ArtifactLifecyclePhase.VALIDATING, result.getOrThrow().phase) + } + + @Test + fun `VALIDATING to VALIDATED is valid`() { + val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATING) + val event = stored(payload = ArtifactValidatedEvent(artifactId, sessionId, stageId)) + val result = reducer.reduce(state, event) + assertTrue(result.isSuccess) + assertEquals(ArtifactLifecyclePhase.VALIDATED, result.getOrThrow().phase) + } + + @Test + fun `VALIDATING to REJECTED is valid`() { + val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATING) + val event = stored(payload = ArtifactRejectedEvent(artifactId, sessionId, stageId, "schema mismatch")) + val result = reducer.reduce(state, event) + assertTrue(result.isSuccess) + assertEquals(ArtifactLifecyclePhase.REJECTED, result.getOrThrow().phase) + } + + @Test + fun `VALIDATED to SUPERSEDED is valid`() { + val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED) + val event = stored(payload = ArtifactSupersededEvent(artifactId, ArtifactId("art2"), sessionId, stageId)) + val result = reducer.reduce(state, event) + assertTrue(result.isSuccess) + assertEquals(ArtifactLifecyclePhase.SUPERSEDED, result.getOrThrow().phase) + } + + @Test + fun `VALIDATED to ARCHIVED is valid`() { + val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED) + val event = stored(payload = ArtifactArchivedEvent(artifactId, sessionId, stageId)) + val result = reducer.reduce(state, event) + assertTrue(result.isSuccess) + assertEquals(ArtifactLifecyclePhase.ARCHIVED, result.getOrThrow().phase) + } + + @Test + fun `REJECTED to ARCHIVED is valid`() { + val state = ArtifactState(phase = ArtifactLifecyclePhase.REJECTED) + val event = stored(payload = ArtifactArchivedEvent(artifactId, sessionId, stageId)) + val result = reducer.reduce(state, event) + assertTrue(result.isSuccess) + assertEquals(ArtifactLifecyclePhase.ARCHIVED, result.getOrThrow().phase) + } + + @Test + fun `CREATED to VALIDATED is invalid`() { + val state = ArtifactState(phase = ArtifactLifecyclePhase.CREATED) + val event = stored(payload = ArtifactValidatedEvent(artifactId, sessionId, stageId)) + val result = reducer.reduce(state, event) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalStateException) + } + + @Test + fun `VALIDATED to VALIDATING is invalid`() { + val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED) + val event = stored(payload = ArtifactValidatingEvent(artifactId, sessionId, stageId)) + val result = reducer.reduce(state, event) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalStateException) + } + + @Test + fun `ARCHIVED to anything is invalid`() { + val state = ArtifactState(phase = ArtifactLifecyclePhase.ARCHIVED) + val event = stored(payload = ArtifactValidatingEvent(artifactId, sessionId, stageId)) + val result = reducer.reduce(state, event) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalStateException) + } + + @Test + fun `relationship added event appends to lineage`() { + val state = ArtifactState(phase = ArtifactLifecyclePhase.CREATED) + val event = stored( + payload = ArtifactRelationshipAddedEvent( + artifactId, ArtifactId("art2"), ArtifactRelationshipType.DERIVED_FROM, sessionId + ) + ) + val result = reducer.reduce(state, event) + assertTrue(result.isSuccess) + assertEquals(1, result.getOrThrow().lineage.relationships.size) + assertEquals(ArtifactRelationshipType.DERIVED_FROM, result.getOrThrow().lineage.relationships.first().type) + } +} diff --git a/testing/projections/src/test/kotlin/ContextProjectorTest.kt b/testing/projections/src/test/kotlin/ContextProjectorTest.kt new file mode 100644 index 00000000..01125824 --- /dev/null +++ b/testing/projections/src/test/kotlin/ContextProjectorTest.kt @@ -0,0 +1,36 @@ +import com.correx.core.context.ContextProjector +import com.correx.core.context.DefaultContextReducer +import com.correx.core.events.events.ContextBuildingStartedEvent +import com.correx.core.events.events.ContextPackBuiltEvent +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.testing.fixtures.EventFixtures.stored +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ContextProjectorTest { + + private val projector = ContextProjector(DefaultContextReducer()) + private val sessionId = SessionId("s1") + private val stageId = StageId("stage-1") + private val packId = ContextPackId("pack-1") + + @Test + fun `initial state has no packs and is not building`() { + val state = projector.initial() + assertEquals(0, state.builtPackIds.size) + assertEquals(false, state.buildingInProgress) + } + + @Test + fun `replay is deterministic`() { + val events = listOf( + stored(payload = ContextBuildingStartedEvent(sessionId, stageId)), + stored(payload = ContextPackBuiltEvent(packId, sessionId, stageId, 100, 4000)) + ) + val state1 = events.fold(projector.initial()) { s, e -> projector.apply(s, e) } + val state2 = events.fold(projector.initial()) { s, e -> projector.apply(s, e) } + assertEquals(state1, state2) + } +} diff --git a/testing/projections/src/test/kotlin/DefaultContextReducerTest.kt b/testing/projections/src/test/kotlin/DefaultContextReducerTest.kt new file mode 100644 index 00000000..8efd2a1f --- /dev/null +++ b/testing/projections/src/test/kotlin/DefaultContextReducerTest.kt @@ -0,0 +1,93 @@ +import com.correx.core.context.DefaultContextReducer +import com.correx.core.context.state.ContextState +import com.correx.core.events.events.ContextBuildingFailedEvent +import com.correx.core.events.events.ContextBuildingInterruptedEvent +import com.correx.core.events.events.ContextBuildingStartedEvent +import com.correx.core.events.events.ContextPackBuiltEvent +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.testing.fixtures.EventFixtures.stored +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DefaultContextReducerTest { + + private val reducer = DefaultContextReducer() + private val sessionId = SessionId("s1") + private val stageId = StageId("stage-1") + private val packId = ContextPackId("pack-1") + + @Test + fun `ContextBuildingStartedEvent sets buildingInProgress to true`() { + val state = reducer.reduce( + ContextState(), + stored(payload = ContextBuildingStartedEvent(sessionId, stageId)) + ) + assertTrue(state.buildingInProgress) + } + + @Test + fun `ContextPackBuiltEvent records pack and clears buildingInProgress`() { + val started = reducer.reduce( + ContextState(), + stored(payload = ContextBuildingStartedEvent(sessionId, stageId)) + ) + val built = reducer.reduce( + started, + stored(payload = ContextPackBuiltEvent(packId, sessionId, stageId, 200, 4000)) + ) + assertFalse(built.buildingInProgress) + assertEquals(1, built.builtPackIds.size) + assertTrue(built.builtPackIds.contains(packId)) + } + + @Test + fun `ContextBuildingFailedEvent clears buildingInProgress`() { + val started = reducer.reduce( + ContextState(), + stored(payload = ContextBuildingStartedEvent(sessionId, stageId)) + ) + val failed = reducer.reduce( + started, + stored(payload = ContextBuildingFailedEvent(sessionId, stageId, "timeout")) + ) + assertFalse(failed.buildingInProgress) + } + + @Test + fun `ContextBuildingInterruptedEvent clears buildingInProgress and sets interrupted`() { + val started = reducer.reduce( + ContextState(), + stored(payload = ContextBuildingStartedEvent(sessionId, stageId)) + ) + val interrupted = reducer.reduce( + started, + stored(payload = ContextBuildingInterruptedEvent(sessionId, stageId)) + ) + assertFalse(interrupted.buildingInProgress) + assertTrue(interrupted.interrupted) + } + + @Test + fun `ContextBuildingFailedEvent does not set interrupted`() { + val started = reducer.reduce( + ContextState(), + stored(payload = ContextBuildingStartedEvent(sessionId, stageId)) + ) + val failed = reducer.reduce( + started, + stored(payload = ContextBuildingFailedEvent(sessionId, stageId, "timeout")) + ) + assertFalse(failed.interrupted) + } + + @Test + fun `unrelated events leave state unchanged`() { + val state = ContextState() + val after = reducer.reduce(state, stored()) + assertEquals(state, after) + } +} diff --git a/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt new file mode 100644 index 00000000..e921118e --- /dev/null +++ b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt @@ -0,0 +1,244 @@ +import com.correx.core.events.events.SessionCompletedEvent +import com.correx.core.events.events.SessionFailedEvent +import com.correx.core.events.events.SessionPausedEvent +import com.correx.core.events.events.SessionResumedEvent +import com.correx.core.events.events.SessionStartedEvent +import com.correx.core.events.events.StageCompletedEvent +import com.correx.core.events.events.StageFailedEvent +import com.correx.core.events.events.StageStartedEvent +import com.correx.core.events.events.TransitionExecutedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.SessionState +import com.correx.core.sessions.SessionStatus +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.testing.fixtures.EventFixtures.stored +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DefaultSessionReducerTest { + + private val reducer = DefaultSessionReducer() + + private val sessionId = SessionId("session-1") + + @Test + fun `SessionStartedEvent sets ACTIVE status`() { + val state = initialState() + + val result = reducer.reduce( + state = state, + event = stored( + sessionId = sessionId, + payload = SessionStartedEvent(sessionId) + ) + ) + + assertEquals(SessionStatus.ACTIVE, result.status) + } + + @Test + fun `SessionPausedEvent sets PAUSED status`() { + val state = activeState() + + val result = reducer.reduce( + state = state, + event = stored( + sessionId = sessionId, + payload = SessionPausedEvent(sessionId) + ) + ) + + assertEquals(SessionStatus.PAUSED, result.status) + } + + @Test + fun `SessionResumedEvent sets ACTIVE status`() { + val state = pausedState() + + val result = reducer.reduce( + state = state, + event = stored( + sessionId = sessionId, + payload = SessionResumedEvent(sessionId) + ) + ) + + assertEquals(SessionStatus.ACTIVE, result.status) + } + + @Test + fun `SessionCompletedEvent sets COMPLETED status`() { + val state = activeState() + + val result = reducer.reduce( + state = state, + event = stored( + sessionId = sessionId, + payload = SessionCompletedEvent(sessionId) + ) + ) + + assertEquals(SessionStatus.COMPLETED, result.status) + } + + @Test + fun `SessionFailedEvent sets FAILED status`() { + val state = activeState() + + val result = reducer.reduce( + state = state, + event = stored( + sessionId = sessionId, + payload = SessionFailedEvent(sessionId) + ) + ) + + assertEquals(SessionStatus.FAILED, result.status) + } + + @Test + fun `StageStartedEvent sets ACTIVE status`() { + val state = pausedState() + + val result = reducer.reduce( + state = state, + event = stored( + sessionId = sessionId, + payload = StageStartedEvent( + sessionId, + stageId = StageId("stage-a"), + transitionId = TransitionId("transition-a") + ) + ) + ) + + assertEquals(SessionStatus.ACTIVE, result.status) + } + + @Test + fun `StageCompletedEvent sets ACTIVE status`() { + val state = pausedState() + + val result = reducer.reduce( + state = state, + event = stored( + sessionId = sessionId, + payload = StageCompletedEvent( + sessionId, + stageId = StageId("stage-a"), + transitionId = TransitionId("transition-a") + ) + ) + ) + + assertEquals(SessionStatus.ACTIVE, result.status) + } + + @Test + fun `StageFailedEvent sets FAILED status`() { + val state = activeState() + + val result = reducer.reduce( + state = state, + event = stored( + sessionId = sessionId, + payload = StageFailedEvent( + sessionId, + stageId = StageId("stage-a"), + transitionId = TransitionId("transition-a"), + reason = "boom" + ) + ) + ) + + assertEquals(SessionStatus.FAILED, result.status) + } + + @Test + fun `TransitionExecutedEvent sets ACTIVE status`() { + val state = pausedState() + + val result = reducer.reduce( + state = state, + event = stored( + sessionId = sessionId, + payload = TransitionExecutedEvent( + sessionId, + from = StageId("stage-a"), + to = StageId("stage-b"), + transitionId = TransitionId("transition-a"), + ) + ) + ) + + assertEquals(SessionStatus.ACTIVE, result.status) + } + + @Test + fun `createdAt is initialized once`() { + val timestamp = Instant.parse("2026-01-01T00:00:00Z") + + val result = reducer.reduce( + state = initialState(), + event = stored( + payload = SessionStartedEvent(sessionId), + timestamp = timestamp + ) + ) + + assertEquals(timestamp, result.createdAt) + } + + @Test + fun `createdAt is preserved after initialization`() { + val createdAt = Instant.parse("2026-01-01T00:00:00Z") + val updatedAt = Instant.parse("2026-01-02T00:00:00Z") + + val state = activeState().copy( + createdAt = createdAt + ) + + val result = reducer.reduce( + state = state, + event = stored( + payload = SessionPausedEvent(sessionId), + timestamp = updatedAt + ) + ) + + assertEquals(createdAt, result.createdAt) + } + + @Test + fun `updatedAt always reflects latest event timestamp`() { + val timestamp = Instant.parse("2026-01-02T00:00:00Z") + + val result = reducer.reduce( + state = activeState(), + event = stored( + payload = SessionPausedEvent(sessionId), + timestamp = timestamp + ) + ) + + assertEquals(timestamp, result.updatedAt) + } + + private fun initialState() = + SessionState( + status = SessionStatus.CREATED + ) + + private fun activeState() = + SessionState( + status = SessionStatus.ACTIVE + ) + + private fun pausedState() = + SessionState( + status = SessionStatus.PAUSED + ) +} \ No newline at end of file diff --git a/testing/projections/src/test/kotlin/InferenceProjectorTest.kt b/testing/projections/src/test/kotlin/InferenceProjectorTest.kt new file mode 100644 index 00000000..45f28c86 --- /dev/null +++ b/testing/projections/src/test/kotlin/InferenceProjectorTest.kt @@ -0,0 +1,112 @@ +import com.correx.core.events.events.InferenceCompletedEvent +import com.correx.core.events.events.InferenceStartedEvent +import com.correx.core.events.types.InferenceRequestId +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.InferenceProjector +import com.correx.core.inference.InferenceRecord +import com.correx.core.inference.InferenceState +import com.correx.core.inference.InferenceStatus +import com.correx.core.inference.TokenUsage +import com.correx.testing.fixtures.EventFixtures.stored +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class InferenceProjectorTest { + + private lateinit var projector: InferenceProjector + + @BeforeEach + fun setup() { + projector = InferenceProjector() + } + + @Test + fun `should initialize state with empty records`() { + val initialState = projector.initial() + assertEquals(0, initialState.records.size) + } + + @Test + fun `should start a new record on InferenceStartedEvent`() { + val initialState = projector.initial() + val event = stored( + payload = InferenceStartedEvent( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + ), + ) + val newState = projector.apply(initialState, event) + + assertEquals(1, newState.records.size) + val record = newState.records.first() + assertEquals(InferenceStatus.STARTED, record.status) + assertEquals("req1", record.requestId.value) + } + + @Test + fun `should complete a record on InferenceCompletedEvent`() { + val startedRecord = InferenceRecord( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + status = InferenceStatus.STARTED, + ) + val initialState = InferenceState(listOf(startedRecord)) + + val event = stored( + payload = InferenceCompletedEvent( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + tokensUsed = TokenUsage(50, 50), + latencyMs = 500L, + ), + ) + val newState = projector.apply(initialState, event) + + assertEquals(1, newState.records.size) + val record = newState.records.first() + assertEquals(InferenceStatus.COMPLETED, record.status) + assertEquals(100, record.tokensUsed?.totalTokens) + assertEquals(500L, record.latencyMs) + } + + @Test + fun `should handle multiple events correctly`() { + // 1. Start + val initialState = projector.initial() + val startEvent = stored( + payload = InferenceStartedEvent( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + ), + ) + var currentState = projector.apply(initialState, startEvent) + assertEquals(1, currentState.records.size) + + // 2. Complete + val completeEvent = stored( + payload = InferenceCompletedEvent( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + tokensUsed = TokenUsage(100, 100), + latencyMs = 1000L, + ), + ) + currentState = projector.apply(currentState, completeEvent) + assertEquals(1, currentState.records.size) + assertEquals(InferenceStatus.COMPLETED, currentState.records.first().status) + assertEquals(200, currentState.records.first().tokensUsed?.totalTokens) + } +} \ No newline at end of file diff --git a/testing/projections/src/test/kotlin/InferenceReducerTest.kt b/testing/projections/src/test/kotlin/InferenceReducerTest.kt new file mode 100644 index 00000000..687723ba --- /dev/null +++ b/testing/projections/src/test/kotlin/InferenceReducerTest.kt @@ -0,0 +1,187 @@ +import com.correx.core.events.events.InferenceCompletedEvent +import com.correx.core.events.events.InferenceFailedEvent +import com.correx.core.events.events.InferenceStartedEvent +import com.correx.core.events.events.InferenceTimeoutEvent +import com.correx.core.events.events.ModelLoadedEvent +import com.correx.core.events.types.InferenceRequestId +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.DefaultInferenceReducer +import com.correx.core.inference.InferenceRecord +import com.correx.core.inference.InferenceReducer +import com.correx.core.inference.InferenceState +import com.correx.core.inference.InferenceStatus +import com.correx.core.inference.TokenUsage +import com.correx.testing.fixtures.EventFixtures.stored +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class InferenceReducerTest { + + private lateinit var reducer: InferenceReducer + + @BeforeEach + fun setup() { + reducer = DefaultInferenceReducer() + } + + @Test + fun `should start a new record on InferenceStartedEvent`() { + val initialState = InferenceState(emptyList()) + val event = stored( + payload = InferenceStartedEvent( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + ), + ) + val newState = reducer.reduce(initialState, event) + + assertEquals(1, newState.records.size) + val record = newState.records.first() + assertEquals(InferenceStatus.STARTED, record.status) + assertEquals("req1", record.requestId.value) + } + + @Test + fun `should complete a record on InferenceCompletedEvent`() { + val startedRecord = InferenceRecord( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + status = InferenceStatus.STARTED, + ) + val initialState = InferenceState(listOf(startedRecord)) + + val event = stored( + payload = InferenceCompletedEvent( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + tokensUsed = TokenUsage(50, 50), + latencyMs = 500L, + ), + ) + val newState = reducer.reduce(initialState, event) + + assertEquals(1, newState.records.size) + val record = newState.records.first() + assertEquals(InferenceStatus.COMPLETED, record.status) + assertEquals(100, record.tokensUsed?.totalTokens) + assertEquals(500L, record.latencyMs) + } + + @Test + fun `should fail a record on InferenceFailedEvent`() { + val startedRecord = InferenceRecord( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + status = InferenceStatus.STARTED, + ) + val initialState = InferenceState(listOf(startedRecord)) + + val event = stored( + payload = InferenceFailedEvent( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + reason = "Network failure", + ), + ) + val newState = reducer.reduce(initialState, event) + + assertEquals(1, newState.records.size) + val record = newState.records.first() + assertEquals(InferenceStatus.FAILED, record.status) + assertEquals("Network failure", record.failureReason) + } + + @Test + fun `should timeout a record on InferenceTimeoutEvent`() { + val startedRecord = InferenceRecord( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + status = InferenceStatus.STARTED, + ) + val initialState = InferenceState(listOf(startedRecord)) + + val event = stored( + payload = InferenceTimeoutEvent( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + timeoutMs = 10000L, + ), + ) + val newState = reducer.reduce(initialState, event) + + assertEquals(1, newState.records.size) + val record = newState.records.first() + assertEquals(InferenceStatus.TIMED_OUT, record.status) + assertEquals(10000L, record.latencyMs) + } + + @Test + fun `should pass state unchanged on unrelated events`() { + val startedRecord = InferenceRecord( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + status = InferenceStatus.STARTED, + ) + val initialState = InferenceState(listOf(startedRecord)) + + val event = stored( + payload = ModelLoadedEvent( + sessionId = SessionId("sess1"), + providerId = ProviderId("providerX"), + modelId = "model", + ), + ) + val newState = reducer.reduce(initialState, event) + + assertEquals(1, newState.records.size) + assertEquals(InferenceStatus.STARTED, newState.records.first().status) + } + + @Test + fun `should not modify record if requestId does not match`() { + val startedRecord = InferenceRecord( + requestId = InferenceRequestId("req1"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + status = InferenceStatus.STARTED, + ) + val initialState = InferenceState(listOf(startedRecord)) + + // Event for a different request + val event = stored( + payload = InferenceCompletedEvent( + requestId = InferenceRequestId("req2"), + sessionId = SessionId("sess1"), + stageId = StageId("stageA"), + providerId = ProviderId("providerX"), + tokensUsed = TokenUsage(50, 50), + latencyMs = 500L, + ), + ) + val newState = reducer.reduce(initialState, event) + + // The record should remain unchanged + assertEquals(1, newState.records.size) + assertEquals(InferenceStatus.STARTED, newState.records.first().status) + } +} \ No newline at end of file diff --git a/testing/projections/src/test/kotlin/OrchestrationProjectorTest.kt b/testing/projections/src/test/kotlin/OrchestrationProjectorTest.kt new file mode 100644 index 00000000..fef1c364 --- /dev/null +++ b/testing/projections/src/test/kotlin/OrchestrationProjectorTest.kt @@ -0,0 +1,112 @@ +import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.OrchestrationResumedEvent +import com.correx.core.events.events.RetryAttemptedEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.events.WorkflowFailedEvent +import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.orchestration.OrchestrationStatus +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.testing.fixtures.EventFixtures.stored +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OrchestrationProjectorTest { + + private val reducer = DefaultOrchestrationReducer() + private val projector = OrchestrationProjector(reducer) + private val sessionId = SessionId("s1") + private val stageId = StageId("stage-1") + + @Test + fun `should initialize orchestration with IDLE status`() { + val initialState = projector.initial() + assertEquals(OrchestrationStatus.IDLE, initialState.status) + } + + @Test + fun `should set RUNNING on WorkflowStartedEvent`() { + val initialState = projector.initial() + val started = + projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId))) + assertEquals(OrchestrationStatus.RUNNING, started.status) + } + + @Test + fun `should set RUNNING after resume on pause`() { + val initialState = projector.initial() + val started = + projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId))) + assertEquals(OrchestrationStatus.RUNNING, started.status) + val paused = + projector.apply(started, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "Tool approval"))) + assertEquals(OrchestrationStatus.PAUSED, paused.status) + assertTrue(paused.pendingApproval) + val resumed = + projector.apply(paused, stored(payload = OrchestrationResumedEvent(sessionId, stageId))) + assertEquals(OrchestrationStatus.RUNNING, resumed.status) + assertFalse(resumed.pendingApproval) + } + + @Test + fun `should set FAILED on WorkflowFailedEvent`() { + val initialState = projector.initial() + val started = + projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId))) + val failed = + projector.apply( + started, + stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)), + ) + assertEquals(OrchestrationStatus.FAILED, failed.status) + } + + @Test + fun `same events produce same result`() { + val initialState = projector.initial() + val events = listOf( + stored(payload = WorkflowStartedEvent(sessionId, stageId)), + stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)), + stored( + payload = RetryAttemptedEvent( + sessionId, + stageId, + attemptNumber = 1, + maxAttempts = 3, + failureReason = "Something went wrong", + ), + ), + stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)), + ) + + val result1 = events.fold(initialState) { state, event -> + projector.apply(state, event) + } + val result2 = events.fold(initialState) { state, event -> + projector.apply(state, event) + } + + assertEquals(result1, result2) + } + + @Test + fun `full pause-resume lifecycle`() { + val s0 = projector.initial() + val s1 = projector.apply(s0, stored(payload = WorkflowStartedEvent(sessionId, stageId))) + val s2 = + projector.apply(s1, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "approval required"))) + val s3 = projector.apply(s2, stored(payload = OrchestrationResumedEvent(sessionId, stageId))) + val s4 = projector.apply(s3, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1))) + + assertEquals(OrchestrationStatus.RUNNING, s1.status) + assertEquals(OrchestrationStatus.PAUSED, s2.status) + assertTrue(s2.pendingApproval) + assertEquals(OrchestrationStatus.RUNNING, s3.status) + assertFalse(s3.pendingApproval) + assertEquals(OrchestrationStatus.COMPLETED, s4.status) + } +} \ No newline at end of file diff --git a/testing/projections/src/test/kotlin/OrchestrationReducerTest.kt b/testing/projections/src/test/kotlin/OrchestrationReducerTest.kt new file mode 100644 index 00000000..b57c6343 --- /dev/null +++ b/testing/projections/src/test/kotlin/OrchestrationReducerTest.kt @@ -0,0 +1,146 @@ +import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.OrchestrationResumedEvent +import com.correx.core.events.events.RetryAttemptedEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.events.WorkflowFailedEvent +import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.orchestration.OrchestrationState +import com.correx.core.events.orchestration.OrchestrationStatus +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.testing.fixtures.EventFixtures.stored +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OrchestrationReducerTest { + + private val reducer = DefaultOrchestrationReducer() + private val sessionId = SessionId("s1") + private val stageId = StageId("stage-1") + private val state = OrchestrationState(stageId) + + @Test + fun `WorkflowStartedEvent sets status to RUNNING`() { + val state = reducer.reduce( + state, + stored(payload = WorkflowStartedEvent(sessionId, stageId)), + ) + assertEquals(OrchestrationStatus.RUNNING, state.status) + assertEquals(stageId, state.currentStageId) + } + + @Test + fun `WorkflowFailedEvent sets status to FAILED and failure reason`() { + val started = reducer.reduce( + state, + stored(payload = WorkflowStartedEvent(sessionId, stageId)), + ) + + val failed = reducer.reduce( + started, + stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)), + ) + assertFalse(failed.failureReason.isNullOrBlank()) + assertEquals(OrchestrationStatus.FAILED, failed.status) + } + + @Test + fun `WorkflowCompletedEvent sets status to COMPLETED`() { + val started = reducer.reduce( + state, + stored(payload = WorkflowStartedEvent(sessionId, stageId)), + ) + + val completed = reducer.reduce( + started, + stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)), + ) + assertTrue(completed.failureReason.isNullOrBlank()) + assertEquals(OrchestrationStatus.COMPLETED, completed.status) + assertEquals(stageId, completed.currentStageId) + } + + @Test + fun `OrchestrationPausedEvent sets status to PAUSED with reason and pending approval`() { + val started = reducer.reduce( + state, + stored(payload = WorkflowStartedEvent(sessionId, stageId)), + ) + + val paused = reducer.reduce( + started, + stored(payload = OrchestrationPausedEvent(sessionId, stageId, "Requires user approval")), + ) + assertFalse(paused.pauseReason.isNullOrBlank()) + assertEquals(OrchestrationStatus.PAUSED, paused.status) + assertTrue(paused.pendingApproval) + } + + @Test + fun `OrchestrationResumedEvent sets status to RUNNING with null reason and no pending approval`() { + val started = reducer.reduce( + state, + stored(payload = WorkflowStartedEvent(sessionId, stageId)), + ) + + val paused = reducer.reduce( + started, + stored(payload = OrchestrationPausedEvent(sessionId, stageId, "Requires user approval")), + ) + + val resumed = reducer.reduce( + paused, + stored(payload = OrchestrationResumedEvent(sessionId, stageId)), + ) + assertTrue(resumed.pauseReason.isNullOrBlank()) + assertEquals(OrchestrationStatus.RUNNING, resumed.status) + assertFalse(resumed.pendingApproval) + } + + @Test + fun `RetryAttemptedEvent sets status to RUNNING with null reason and no pending approval`() { + val started = reducer.reduce( + state, + stored(payload = WorkflowStartedEvent(sessionId, stageId)), + ) + + val failed = reducer.reduce( + started, + stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)), + ) + + val retrying1 = reducer.reduce( + failed, + stored(payload = RetryAttemptedEvent(sessionId, stageId, 1, 3, "Something went wrong")), + ) + + val retrying2 = reducer.reduce( + retrying1, + stored(payload = RetryAttemptedEvent(sessionId, stageId, 2, 3, "Something went wrong")), + ) + + val retrying3 = reducer.reduce( + retrying2, + stored(payload = RetryAttemptedEvent(sessionId, stageId, 3, 3, "Something went wrong")), + ) + assertTrue(retrying1.retryCount < retrying2.retryCount && retrying2.retryCount < retrying3.retryCount) + assertEquals(OrchestrationStatus.RUNNING, retrying1.status) + assertNull(retrying1.failureReason) + } + + + @Test + fun `unrelated event does nothing`() { + val started = reducer.reduce( + state, + stored(payload = WorkflowStartedEvent(sessionId, stageId)), + ) + + val unrelated = reducer.reduce(started, stored()) + assertEquals(started, unrelated) + } +} \ No newline at end of file diff --git a/testing/projections/src/test/kotlin/SessionProjectorTest.kt b/testing/projections/src/test/kotlin/SessionProjectorTest.kt new file mode 100644 index 00000000..dc55a55c --- /dev/null +++ b/testing/projections/src/test/kotlin/SessionProjectorTest.kt @@ -0,0 +1,144 @@ +import com.correx.core.events.events.SessionCompletedEvent +import com.correx.core.events.events.SessionPausedEvent +import com.correx.core.events.events.SessionResumedEvent +import com.correx.core.events.events.SessionStartedEvent +import com.correx.core.events.events.StageCompletedEvent +import com.correx.core.events.events.StageFailedEvent +import com.correx.core.events.events.StageStartedEvent +import com.correx.core.events.events.TransitionExecutedEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.SessionProjector +import com.correx.core.sessions.SessionStatus +import com.correx.testing.fixtures.EventFixtures.stored +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SessionProjectorTest { + + private val projector = SessionProjector(DefaultSessionReducer()) + + @Test + fun `full lifecycle transitions correctly`() { + var state = projector.initial() + + val events = listOf( + stored(eventId = EventId("e1"), payload = SessionStartedEvent(SessionId("s1"))), + stored(eventId = EventId("e2"), payload = SessionPausedEvent(SessionId("s1"))), + stored(eventId = EventId("e3"), payload = SessionResumedEvent(SessionId("s1"))), + stored(eventId = EventId("e4"), payload = SessionCompletedEvent(SessionId("s1"))) + ) + + events.forEach { + state = projector.apply(state, it) + } + + assertEquals(SessionStatus.COMPLETED, state.status) + } + + @Test + fun `failed stage transitions session to FAILED`() { + var state = projector.initial() + + val events = listOf( + stored( + eventId = EventId("e1"), + payload = SessionStartedEvent(SessionId("s1")) + ), + stored( + eventId = EventId("e2"), + payload = StageStartedEvent( + sessionId = SessionId("s1"), + stageId = StageId("draft"), + transitionId = TransitionId("t1") + ) + ), + stored( + eventId = EventId("e3"), + payload = StageFailedEvent( + sessionId = SessionId("s1"), + stageId = StageId("draft"), + transitionId = TransitionId("t1"), + reason = "boom" + ) + ) + ) + + events.forEach { + state = projector.apply(state, it) + } + + assertEquals(SessionStatus.FAILED, state.status) + } + + @Test + fun `transition events keep session ACTIVE`() { + var state = projector.initial() + + val events = listOf( + stored( + eventId = EventId("e1"), + payload = SessionStartedEvent(SessionId("s1")) + ), + stored( + eventId = EventId("e2"), + payload = TransitionExecutedEvent( + sessionId = SessionId("s1"), + from = StageId("a"), + to = StageId("b"), + transitionId = TransitionId("t1") + ) + ), + stored( + eventId = EventId("e3"), + payload = StageStartedEvent( + sessionId = SessionId("s1"), + stageId = StageId("b"), + transitionId = TransitionId("t1") + ) + ), + stored( + eventId = EventId("e4"), + payload = StageCompletedEvent( + sessionId = SessionId("s1"), + stageId = StageId("b"), + transitionId = TransitionId("t1") + ) + ) + ) + + events.forEach { + state = projector.apply(state, it) + } + + assertEquals(SessionStatus.ACTIVE, state.status) + } + + @Test + fun `projection replay is deterministic`() { + + val events = listOf( + stored( + eventId = EventId("e1"), + payload = SessionStartedEvent(SessionId("s1")) + ), + stored( + eventId = EventId("e2"), + payload = SessionCompletedEvent(SessionId("s1")) + ) + ) + + val state1 = events.fold(projector.initial()) { state, event -> + projector.apply(state, event) + } + + val state2 = events.fold(projector.initial()) { state, event -> + projector.apply(state, event) + } + + assertEquals(state1, state2) + } +} \ No newline at end of file diff --git a/testing/replay/build.gradle b/testing/replay/build.gradle new file mode 100644 index 00000000..42d0ac05 --- /dev/null +++ b/testing/replay/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + testImplementation(project(":core:events")) + testImplementation(project(":core:sessions")) + testImplementation(project(":core:transitions")) + testImplementation(project(":core:validation")) + testImplementation(project(":core:approvals")) + testImplementation(project(":testing:fixtures")) + testImplementation(project(":infrastructure:persistence")) +} \ No newline at end of file diff --git a/testing/replay/src/main/kotlin/com/correx/testing/replay/Module.kt b/testing/replay/src/main/kotlin/com/correx/testing/replay/Module.kt new file mode 100644 index 00000000..fe0f3048 --- /dev/null +++ b/testing/replay/src/main/kotlin/com/correx/testing/replay/Module.kt @@ -0,0 +1,3 @@ +package com.correx.testing.replay + +object Module diff --git a/testing/replay/src/test/kotlin/ApprovalReplayTest.kt b/testing/replay/src/test/kotlin/ApprovalReplayTest.kt new file mode 100644 index 00000000..79b4a1a3 --- /dev/null +++ b/testing/replay/src/test/kotlin/ApprovalReplayTest.kt @@ -0,0 +1,59 @@ +import com.correx.core.approvals.Tier +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.approvals.GrantScope +import com.correx.core.events.types.ApprovalDecisionId +import com.correx.core.events.types.GrantId +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.ApprovalMode +import com.correx.testing.fixtures.request +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ApprovalReplayTest { + @Test + fun `replaying same request with same inputs yields identical decision`() { + val engine1 = DefaultApprovalEngine() + val engine2 = DefaultApprovalEngine() + val req = request("req1", Tier.T2) + val ctx = ApprovalContext( + ApprovalScopeIdentity(SessionId("s1"), null, null), + ApprovalMode.AUTO + ) + val grants = listOf( + ApprovalGrant( + GrantId("g1"), + GrantScope.SESSION, + setOf(Tier.T2), + "reason", + Instant.parse("2026-01-01T00:00:00Z") + ) + ) + val now = Instant.parse("2026-01-01T00:00:01Z") + + val d1 = engine1.evaluate(req, ctx, grants, now) + val d2 = engine2.evaluate(req, ctx, grants, now) + + assertEquals(d1, d2) + } + + @Test + fun `decision ID determinism holds over multiple replays`() { + val engine = DefaultApprovalEngine() + val req = request("fixed", Tier.T3) + val ctx = ApprovalContext( + ApprovalScopeIdentity(SessionId("s"), null, null), + ApprovalMode.PROMPT + ) + val now = Instant.parse("2026-01-01T00:00:01Z") + + val d1 = engine.evaluate(req, ctx, emptyList(), now) + val d2 = engine.evaluate(req, ctx, emptyList(), now) + + assertEquals(d1.id, d2.id) + assertEquals(ApprovalDecisionId("decision:fixed"), d1.id) + } +} \ No newline at end of file diff --git a/testing/replay/src/test/kotlin/SessionEmptyReplayTest.kt b/testing/replay/src/test/kotlin/SessionEmptyReplayTest.kt new file mode 100644 index 00000000..3ec48951 --- /dev/null +++ b/testing/replay/src/test/kotlin/SessionEmptyReplayTest.kt @@ -0,0 +1,24 @@ +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.SessionProjector +import com.correx.core.sessions.SessionStatus +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.infrastructure.persistence.InMemoryEventStore +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SessionEmptyReplayTest { + + @Test + fun `empty session returns initial state`() { + val store = InMemoryEventStore() + val replayer = DefaultEventReplayer( + store, + SessionProjector(DefaultSessionReducer()) + ) + + val state = replayer.rebuild(SessionId("missing")) + + assertEquals(SessionStatus.CREATED, state.status) + } +} \ No newline at end of file diff --git a/testing/replay/src/test/kotlin/SessionReplayTest.kt b/testing/replay/src/test/kotlin/SessionReplayTest.kt new file mode 100644 index 00000000..68f4e9bc --- /dev/null +++ b/testing/replay/src/test/kotlin/SessionReplayTest.kt @@ -0,0 +1,57 @@ +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.SessionCompletedEvent +import com.correx.core.events.events.SessionPausedEvent +import com.correx.core.events.events.SessionResumedEvent +import com.correx.core.events.events.SessionStartedEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.SessionProjector +import com.correx.core.sessions.SessionStatus +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.infrastructure.persistence.InMemoryEventStore +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SessionReplayTest { + + private val store = InMemoryEventStore() + private val projector = SessionProjector(DefaultSessionReducer()) + private val replayer = DefaultEventReplayer(store, projector) + + @Test + fun `rebuild session from event stream`() { + val sessionId = SessionId("s1") + + val metadataToPayload = mapOf( + EventMetadata(EventId("start"), sessionId, Clock.System.now(), 1, null, null) to SessionStartedEvent( + sessionId + ), + EventMetadata(EventId("paused"), sessionId, Clock.System.now(), 1, null, null) to SessionPausedEvent( + sessionId + ), + EventMetadata(EventId("resumed"), sessionId, Clock.System.now(), 1, null, null) to SessionResumedEvent( + sessionId + ), + EventMetadata( + EventId("completed"), + sessionId, + Clock.System.now(), + 1, + null, + null + ) to SessionCompletedEvent(sessionId), + ) + + metadataToPayload.map { (meta, payload) -> + store.append(NewEvent(meta, payload)) + } + + val state = replayer.rebuild(sessionId) + + assertEquals(SessionStatus.COMPLETED, state.status) + } +} \ No newline at end of file diff --git a/testing/replay/src/test/kotlin/TransitionReplayIntegrationTest.kt b/testing/replay/src/test/kotlin/TransitionReplayIntegrationTest.kt new file mode 100644 index 00000000..e1345378 --- /dev/null +++ b/testing/replay/src/test/kotlin/TransitionReplayIntegrationTest.kt @@ -0,0 +1,209 @@ +import com.correx.core.events.events.SessionCompletedEvent +import com.correx.core.events.events.SessionStartedEvent +import com.correx.core.events.events.StageCompletedEvent +import com.correx.core.events.events.StageFailedEvent +import com.correx.core.events.events.StageStartedEvent +import com.correx.core.events.events.TransitionExecutedEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.SessionProjector +import com.correx.core.sessions.SessionStatus +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.testing.fixtures.EventFixtures.newEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TransitionReplayIntegrationTest { + + private val sessionId = SessionId("session-1") + + @Test + fun `workflow replay reconstructs COMPLETED session`() { + + val store = InMemoryEventStore() + + store.appendAll( + listOf( + newEvent( + sessionId = sessionId, + eventId = EventId("event-1"), + payload = SessionStartedEvent(sessionId) + ), + newEvent( + sessionId = sessionId, + eventId = EventId("event-2"), + payload = TransitionExecutedEvent( + sessionId = sessionId, + from = StageId("draft"), + to = StageId("review"), + transitionId = TransitionId("transition-1") + ) + ), + newEvent( + sessionId = sessionId, + eventId = EventId("event-3"), + payload = StageStartedEvent( + sessionId = sessionId, + stageId = StageId("review"), + transitionId = TransitionId("transition-1") + ) + ), + newEvent( + sessionId = sessionId, + eventId = EventId("event-4"), + payload = StageCompletedEvent( + sessionId = sessionId, + stageId = StageId("review"), + transitionId = TransitionId("transition-1") + ) + ), + newEvent( + sessionId = sessionId, + eventId = EventId("event-5"), + payload = SessionCompletedEvent(sessionId) + ) + ) + ) + + val replayer = DefaultEventReplayer( + store = store, + projection = SessionProjector( + reducer = DefaultSessionReducer() + ) + ) + + val state = replayer.rebuild(sessionId) + + assertEquals( + SessionStatus.COMPLETED, + state.status + ) + } + + @Test + fun `workflow replay reconstructs FAILED session`() { + + val store = InMemoryEventStore() + + store.appendAll( + listOf( + newEvent( + sessionId = sessionId, + eventId = EventId("event-1"), + payload = SessionStartedEvent(sessionId) + ), + newEvent( + sessionId = sessionId, + eventId = EventId("event-2"), + payload = TransitionExecutedEvent( + sessionId = sessionId, + from = StageId("draft"), + to = StageId("review"), + transitionId = TransitionId("transition-1") + ) + ), + newEvent( + sessionId = sessionId, + eventId = EventId("event-3"), + payload = StageStartedEvent( + sessionId = sessionId, + stageId = StageId("review"), + transitionId = TransitionId("transition-1") + ) + ), + newEvent( + sessionId = sessionId, + eventId = EventId("event-4"), + payload = StageFailedEvent( + sessionId = sessionId, + stageId = StageId("review"), + transitionId = TransitionId("transition-1"), + reason = "validation failed" + ) + ) + ) + ) + + val replayer = DefaultEventReplayer( + store = store, + projection = SessionProjector( + reducer = DefaultSessionReducer() + ) + ) + + val state = replayer.rebuild(sessionId) + + assertEquals( + SessionStatus.FAILED, + state.status + ) + } + + @Test + fun `replay is deterministic for identical event stream`() { + val events = listOf( + newEvent( + sessionId = sessionId, + eventId = EventId("event-1"), + payload = SessionStartedEvent(sessionId) + ), + newEvent( + sessionId = sessionId, + eventId = EventId("event-2"), + payload = TransitionExecutedEvent( + sessionId = sessionId, + from = StageId("draft"), + to = StageId("review"), + transitionId = TransitionId("transition-1") + ) + ), + newEvent( + sessionId = sessionId, + eventId = EventId("event-3"), + payload = StageStartedEvent( + sessionId = sessionId, + stageId = StageId("review"), + transitionId = TransitionId("transition-1") + ) + ), + newEvent( + sessionId = sessionId, + eventId = EventId("event-4"), + payload = StageCompletedEvent( + sessionId = sessionId, + stageId = StageId("review"), + transitionId = TransitionId("transition-1") + ) + ) + ) + + val store1 = InMemoryEventStore() + val store2 = InMemoryEventStore() + + store1.appendAll(events) + store2.appendAll(events) + + val projection = SessionProjector( + reducer = DefaultSessionReducer() + ) + + val replayer1 = DefaultEventReplayer( + store = store1, + projection = projection + ) + + val replayer2 = DefaultEventReplayer( + store = store2, + projection = projection + ) + + val state1 = replayer1.rebuild(sessionId) + val state2 = replayer2.rebuild(sessionId) + + assertEquals(state1, state2) + } +} \ No newline at end of file diff --git a/testing/replay/src/test/kotlin/ValidationReplayTest.kt b/testing/replay/src/test/kotlin/ValidationReplayTest.kt new file mode 100644 index 00000000..148b4e0d --- /dev/null +++ b/testing/replay/src/test/kotlin/ValidationReplayTest.kt @@ -0,0 +1,32 @@ +import com.correx.core.transitions.resolution.TransitionOrdering +import com.correx.core.validation.graph.GraphValidator +import com.correx.core.validation.model.ValidationContext +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.core.validation.transition.TransitionValidator +import com.correx.testing.fixtures.WorkflowFixtures +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ValidationReplayTest { + + @Test + fun `validation must be replay deterministic`() { + + val graph = WorkflowFixtures.simpleGraph() + + val context = ValidationContext(graph) + + val pipeline = ValidationPipeline( + listOf( + GraphValidator(), + TransitionValidator(TransitionOrdering.comparator) + ) + ) + + val results = (1..10).map { + pipeline.validate(context) + } + + assertTrue(results.distinct().size == 1) + } +} \ No newline at end of file diff --git a/testing/transitions/build.gradle b/testing/transitions/build.gradle new file mode 100644 index 00000000..52f9bcd4 --- /dev/null +++ b/testing/transitions/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + testImplementation(project(":core:transitions")) + testImplementation(project(":core:events")) + testImplementation(project(":core:inference")) +} \ No newline at end of file diff --git a/testing/transitions/src/test/kotlin/DefaultTransitionResolverTest.kt b/testing/transitions/src/test/kotlin/DefaultTransitionResolverTest.kt new file mode 100644 index 00000000..b2150bcc --- /dev/null +++ b/testing/transitions/src/test/kotlin/DefaultTransitionResolverTest.kt @@ -0,0 +1,225 @@ +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.transitions.evaluation.EvaluationContext +import com.correx.core.transitions.evaluation.TransitionConditionEvaluator +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionCondition +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.DefaultTransitionResolver +import com.correx.core.transitions.resolution.TransitionDecision +import com.correx.core.transitions.resolution.TransitionResolver +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertInstanceOf + +class DefaultTransitionResolverTest { + + private val evaluator = object : TransitionConditionEvaluator { + override fun evaluate( + condition: TransitionCondition, + context: EvaluationContext + ): Boolean { + return condition.evaluate(context) + } + } + + private val resolver: TransitionResolver = DefaultTransitionResolver(evaluator) + + @Test + fun `returns NoMatch when no outgoing transitions exist for current stage`() { + val graph = graph( + start = "stage-a", + transitions = setOf( + edge( + id = "t1", + from = "stage-a", + to = "stage-b", + condition = alwaysTrue() + ) + ), + stages = setOf("stage-a", "stage-b") + ) + + val result = resolver.resolve( + graph = graph, + context = context(currentStage = "stage-b") + ) + + assertEquals( + TransitionDecision.NoMatch, + result + ) + } + + @Test + fun `returns Stay when all conditions evaluate to false`() { + val graph = graph( + start = "stage-a", + transitions = setOf( + edge( + id = "t1", + from = "stage-a", + to = "stage-b", + condition = alwaysFalse() + ) + ) + ) + + val result = resolver.resolve( + graph = graph, + context = context(currentStage = "stage-a") + ) + + assertEquals( + TransitionDecision.Stay, + result + ) + } + + @Test + fun `returns matching transition when condition evaluates to true`() { + val graph = graph( + start = "stage-a", + transitions = setOf( + edge( + id = "t1", + from = "stage-a", + to = "stage-b", + condition = alwaysTrue() + ) + ) + ) + + val result = resolver.resolve( + graph = graph, + context = context(currentStage = "stage-a") + ) + + val move = assertInstanceOf(result) + + assertEquals( + TransitionId("t1"), + move.transitionId + ) + + assertEquals( + StageId("stage-b"), + move.to + ) + } + + @Test + fun `first matching transition wins deterministically`() { + val graph = graph( + start = "stage-a", + transitions = setOf( + edge( + id = "t2", + from = "stage-a", + to = "stage-c", + condition = alwaysTrue() + ), + edge( + id = "t1", + from = "stage-a", + to = "stage-b", + condition = alwaysTrue() + ) + ) + ) + + val result = resolver.resolve( + graph = graph, + context = context(currentStage = "stage-a") + ) + + val move = assertInstanceOf(result) + + assertEquals( + TransitionId("t1"), + move.transitionId + ) + + assertEquals( + StageId("stage-b"), + move.to + ) + } + + @Test + fun `only transitions from current stage are evaluated`() { + val graph = graph( + start = "stage-a", + transitions = setOf( + edge( + id = "t1", + from = "stage-x", + to = "stage-y", + condition = alwaysTrue() + ), + edge( + id = "t2", + from = "stage-a", + to = "stage-b", + condition = alwaysTrue() + ) + ) + ) + + val result = resolver.resolve( + graph = graph, + context = context(currentStage = "stage-a") + ) + + val move = assertInstanceOf(result) + + assertEquals( + TransitionId("t2"), + move.transitionId + ) + } + + private fun edge( + id: String, + from: String, + to: String, + condition: TransitionCondition + ): TransitionEdge { + return TransitionEdge( + id = TransitionId(id), + from = StageId(from), + to = StageId(to), + condition = condition + ) + } + + private fun graph( + start: String, + transitions: Set, + stages: Set = transitions.flatMap { listOf(it.from.value, it.to.value) }.toSet() + ) = WorkflowGraph( + start = StageId(start), + stages = stages.associate { StageId(it) to StageConfig() }, + transitions = transitions + ) + + private fun context( + currentSession: String = "s1", + currentStage: String, + ): EvaluationContext { + return EvaluationContext( + sessionId = SessionId(currentSession), + currentStage = StageId(currentStage), + variables = emptyMap(), + ) + } + + private fun alwaysTrue() = + TransitionCondition { true } + + @Suppress("UnusedPrivateMember") + private fun alwaysFalse() = + TransitionCondition { false } +} \ No newline at end of file diff --git a/testing/transitions/src/test/kotlin/TransitionEventSerializationTest.kt b/testing/transitions/src/test/kotlin/TransitionEventSerializationTest.kt new file mode 100644 index 00000000..ce9e9e86 --- /dev/null +++ b/testing/transitions/src/test/kotlin/TransitionEventSerializationTest.kt @@ -0,0 +1,72 @@ +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StageCompletedEvent +import com.correx.core.events.events.StageFailedEvent +import com.correx.core.events.events.StageStartedEvent +import com.correx.core.events.events.TransitionExecutedEvent +import com.correx.core.events.serialization.JsonEventSerializer +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TransitionEventSerializationTest { + + @Test + fun `StageStartedEvent serializes and deserializes`() { + val event = StageStartedEvent( + sessionId = SessionId("session-1"), + stageId = StageId("stage-a"), + transitionId = TransitionId("transition-a") + ) + + assertRoundTrip(event) + } + + @Test + fun `StageCompletedEvent serializes and deserializes`() { + val event = StageCompletedEvent( + sessionId = SessionId("session-1"), + stageId = StageId("stage-a"), + transitionId = TransitionId("transition-a") + ) + + assertRoundTrip(event) + } + + @Test + fun `StageFailedEvent serializes and deserializes`() { + val event = StageFailedEvent( + sessionId = SessionId("session-1"), + stageId = StageId("stage-a"), + transitionId = TransitionId("transition-a"), + reason = "boom" + ) + + assertRoundTrip(event) + } + + @Test + fun `TransitionExecutedEvent serializes and deserializes`() { + val event = TransitionExecutedEvent( + sessionId = SessionId("session-1"), + from = StageId("stage-a"), + to = StageId("stage-b"), + transitionId = TransitionId("transition-a") + ) + + assertRoundTrip(event) + } + + private inline fun assertRoundTrip( + event: T + ) { + val json = JsonEventSerializer() + val serialized = json.serialize(event) + + val deserialized = + json.deserialize(serialized) + + assertEquals(event, deserialized) + } +} \ No newline at end of file