feat(validation): capability-gap reflection rung (#30 part 2)

Bounded LLM "are you sure?" pass over the capability gaps part 1 detects, run
once at plan-lock before the operator is asked to approve — honest mistakes
self-correct without escalation; only a genuine tool need reaches the human.

- CapabilityGapReflector: fun-interface seam in core:kernel (plain-data in/out,
  no infrastructure:workflow dep), mirroring SemanticReviewer/SalvageJudge.
- CapabilityGapReflectorImpl (apps/server): one InferenceRouter call for the
  whole gap batch, no per-gap loop, no retry; runCatching degrades to a safe
  RESOLVED-advisory default on any failure (unroutable/timeout/malformed JSON) —
  a broken pass can never manufacture a NEEDS_TOOL escalation or a grant.
- CapabilityGapReflectedEvent(verdict RESOLVED|NEEDS_TOOL) recorded per gap and
  registered in eventModule; replay reads the event, never re-invokes inference
  (invariants #7/#8/#9).
- FreestyleDriver.lockAndRun: RESOLVED is advisory (recorded, plan untouched —
  invariant #3); NEEDS_TOOL is appended to the existing requestPlanApproval
  preview so the operator decides — no auto-grant anywhere (#4/#5).
- reflector nullable -> degrades to part-1 behavior when unwired.

Verified: ./gradlew :core:events:test :core:kernel:test :infrastructure:workflow:test :apps:server:test green.
This commit is contained in:
2026-07-07 13:41:32 +04:00
parent 41ed6414c6
commit efb6eb0334
8 changed files with 583 additions and 4 deletions
@@ -0,0 +1,37 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.CapabilityGapVerdict
import com.correx.core.events.types.SessionId
import com.correx.core.tools.contract.ToolCapability
/** One capability gap finding from [com.correx.infrastructure.workflow.CapabilityGapDetector], as
* plain data so this module does not depend on infrastructure:workflow. */
data class CapabilityGapInput(
val stageId: String,
val impliedCapability: ToolCapability,
val phrase: String,
val grantedCapabilities: Set<ToolCapability>,
)
/** Result of reflecting on a single [CapabilityGapInput]. */
data class CapabilityGapReflection(
val stageId: String,
val impliedCapability: ToolCapability,
val verdict: CapabilityGapVerdict,
val detail: String,
)
/**
* Seam for the Vikunja #30 part 2 reflection rung: consulted once, bounded, on the capability gaps
* a plan-compile pass turned up, before any of them are surfaced to the operator. Like the other gate
* seams ([SemanticReviewer], [SalvageJudge]), the inference-touching implementation is injected so
* the deterministic core never runs inference itself and the caller stays unit-testable with a fake.
*
* A single call covers the whole batch of gaps for one plan — there is no per-gap loop and no retry;
* the caller records the result as a [com.correx.core.events.events.CapabilityGapReflectedEvent] per
* gap and never re-invokes this on replay (invariant #8 — the reflection is nondeterministic,
* invariant #9 requires recording the outcome, not re-observing it).
*/
fun interface CapabilityGapReflector {
suspend fun reflect(sessionId: SessionId, gaps: List<CapabilityGapInput>): List<CapabilityGapReflection>
}