First slice of the plan-generation pipeline epic. A pure-Kotlin PlanLinter runs over the
compiled freestyle ExecutionPlan (WorkflowGraph) before it is locked, complementing
ExecutionPlanCompiler (which throws on unreachable/unknown-kind/bad-edge) with the checks
it does NOT make:
- HARD unproduced_need — a stage needs an artifact no stage produces. Real gap: only the
TOML loader checked this; the freestyle compiler did not, so such plans compiled and
failed at runtime.
- HARD trap_state — a stage with no path to the terminal (inescapable loop / dead end).
Uses terminal-reachability, NOT "any cycle", so legitimate verdict-gated review loops pass.
- SOFT stage_count / fan_out / empty_brief / duplicate_brief — scored, never blocking.
FreestyleDriver lints after compile and records PlanLintCompletedEvent (pure function of
the recorded plan → replay-safe, no env observation in v1); a hard failure rejects the plan
(ExecutionPlanRejectedEvent source="lint") before the operator is asked or the plan locks,
so a broken plan never executes. Pays off now for single-plan freestyle; becomes the
per-candidate filter when multi-candidate generation (Slice 2) lands.
Deferred to later slices: file-path grounding + symbol resolution (needs an index),
token-budget/ADR-bypass checks, and a dedicated :core:planning module.
Run operator-configured static-analysis commands (compiler/detekt/formatters) as a
deterministic harness step on the producing stage, before the LLM reviewer. A stage
declares `static_analysis = [commands]`; after it produces its artifact, each command
runs in the workspace root and the results are recorded in a StaticAnalysisCompletedEvent
(invariant #9 env observation — recorded live, folded on replay, never re-run). A
non-clean command fails the stage retryably with its output fed back verbatim (§2:
compiler/test output is ground truth), so the implementer fixes it and only static-clean
code ever reaches the reviewer. This makes §5's "reviewer context excludes static findings"
mechanical — the findings are resolved upstream, so the reviewer can't waste inference on
what detekt catches for free; no output-parsing, no context plumbing.
- StaticAnalysisCompletedEvent + StaticAnalysisFinding (core:events) + registration.
- StaticAnalysisRunner seam + ProcessStaticAnalysisRunner (whitespace-split argv, stderr
merged, timeout→nonzero exit) in core:kernel; wired (nullable) into OrchestratorEngines
and the server. Null runner / no workspace root → logged no-op, never blocks the run.
- StageConfig.staticAnalysis + `static_analysis` TOML field; runStaticAnalysis folded into
a runPostStageGates sequence (produces → grounding → echo → static analysis).
- Documented `static_analysis` on the role_pipeline implementer stage (commented; commands
are workspace-specific). The reviewer prompt half shipped in 3467826.
New brief_echo stage before the planner in role_pipeline: the model restates
the analyst brief as a structured artifact, and a deterministic gate
(checkBriefEcho, opt-in via brief_echo=true) diffs the restatement against the
original brief. On divergence — dropped requirements (Jaccard coverage < 0.34)
or hallucinated files — it emits BriefEchoMismatchEvent and fails the stage
retryably, so a misread brief never reaches plan generation.
The diff (BriefEchoDiff) is a pure function of two recorded artifacts, so it is
replay-safe and records no observation; the event fires only on mismatch, for
audit. Symbols are recorded but non-blocking in v1. Mirrors the groundBrief-
References gate. role_pipeline only; freestyle_planning wiring is a follow-up.
Runtime install (like research): copy brief_echo.json + brief_echo.md and the
[[artifacts]] block into ~/.config/correx.
IdeaCapturedEvent(ideaId, sessionId, text, timestampMs) + IdeaDiscardedEvent
(ideaId, sessionId) tombstone, registered in eventModule. The cross-session idea
board is rebuilt from these via eventStore.allEvents(); discard hides without
deleting (invariant #1).
ProposedWorkflow + WorkflowProposedEvent(sessionId, proposalId, prompt,
candidates, originalRequest), registered in the polymorphic eventModule.
Records the router's triage suggestion of candidate workflows as a
non-authoritative fact the operator acts on.
Adds the vocabulary for a stage raising open questions to the operator:
ClarificationQuestion/ClarificationAnswer value types, the
ClarificationRequested/ClarificationAnswered events (registered in the
event module), a ClarificationRequestId, and ClarificationQuestions.parse
— a lenient, fence-tolerant reader that pulls an optional questions array
out of an LLM artifact (shared by orchestrator and server).
Health states are events. A HealthMonitor polls registered HealthProbes on an
interval and appends HealthDegraded/HealthRestored only on a status transition
(hysteresis), so health is observable, replayable, and renderable like every
other fact in the log. The probe reads nondeterministic environment state and
records it at observe-time (Invariant #9); HealthProjection rebuilds current
health by replay, never re-probing (Invariant #8). Monitor is live-only, started
in ServerModule.start alongside narration; seeded from the projection so a
restart does not re-emit a degraded the log already records.
First concrete probe: DiskWatermarkProbe — event-log + CAS size and growth rate,
warning before the 2am surprise. Read surface mirrors `correx stats`:
GET /health/checks replays the system session and `correx health` renders it.
Health events ride the reserved __system__ session, filtered from operator
session listings.
LLAMA_SERVER (liveness + tokens/sec trend) and EVENT_STORE (append/fsync latency)
probes plug into the same HealthProbe framework as follow-ups.
Local analysts hallucinate file paths the same as planners. A stage can now
opt in with `ground_references = true`: after it produces its brief, every
workspace-relative file path the brief named is checked for existence, and a
path that doesn't exist fails the stage (retryable) with the misses fed back
("Reference only files you have read") so the model re-grounds.
Grounding probes the filesystem directly rather than the repo map — the map
is recency-ranked and depth/extension-limited, not a complete existence
oracle, so "absent from the map" can't be a hard failure. Existence is a
nondeterministic observation, so the result is recorded as a
BriefGroundingCheckedEvent (invariant #9); replay reads it back and never
re-probes.
- BriefGroundingCheckedEvent + GroundingReference, registered in eventModule
- BriefReferenceExtractor: pure, deterministic pull of relative file-path
references from a brief artifact JSON (needs a '/' and a dotted extension;
skips modules, bare names, URLs, absolute/parent paths — so coarse
"subsystem" mentions the brief is allowed to make aren't false-flagged)
- SessionOrchestrator.groundBriefReferences on the post-verifyProduces path,
gated by stage metadata; uses the existing worldProbe
- TomlWorkflowLoader `ground_references` → metadata; role_pipeline analyst
opts in
Scoped to file references with a '/' + extension (not bare basenames, which
a depth-limited probe can't resolve), so a flagged miss is high-confidence.
The symbol-grounding half of §3 is left for a real symbol index.
Both silent-failure branches in FreestyleDriver.lockAndRun now emit
ExecutionPlanRejectedEvent (source=compile or missing_content) before
returning, satisfying invariant #1. Event registered in eventModule for
correct polymorphic serialization.
Follow-ups spotted while fixing the artifact-content bug:
- #1 robustness: listArtifacts resolves content via runCatching, degrading to null
instead of failing the whole listing on one bad CAS read (resolveContent helper)
- #2 eviction visibility: WARN when a referenced artifact hash resolves to null
(most likely evicted/compacted while still referenced by the event log)
- #3 ordering trap: document on ArtifactCreated/ArtifactContentStored that 'Created'
fires at validation, AFTER ContentStored at inference — the latent fold hazard
- #4 reopen gap: SessionSnapshot now carries workspaceRoot (derived from
SessionWorkspaceBound), so a reopened session restores its workspace label
- #5 orchestrator headroom: isBackEdge externalised to a top-level function so
DefaultSessionOrchestrator drops below the TooManyFunctions threshold (was at it)
#6 (orphaned empty F-010-era artifacts) needs no code change — empty content is
already normalised to null ('(no content stored)'); emission is prevented going
forward by the shipped F-010/F-018/F-020 fixes.
Implements the replay-safe half of preemptive redirect (graph re-routing). An
operator-confirmed PreemptRedirectEvent overrides the resolver's edge at the next
transition boundary; the pure resolver stays untouched.
- PreemptRedirectEvent / PreemptRedirectBlockedEvent (+ serialization registration)
- TransitionExecutedEvent + TransitionDecision.Move gain optional redirectId — the
durable 'consumed once' marker
- PreemptRedirect.decide: pure decision over the event log (override / block / none),
so replay reaches the identical outcome without re-classifying (invariant #8)
- override wired in DefaultSessionOrchestrator.step above resolveTransition; back-edge
jumps count against the existing maxRetries cap via executeMove
- blocks jumps to unknown stages or targets with unsatisfied needs
- DomainEventMapper: redirect events mapped to null (operator surface ships with the
LLM-proposal + approval-confirm front-half)
- tests: override+consume-once, block-on-needs, block-on-unknown, ignore-consumed
Front-half (LLM proposal -> approval-gate confirm -> emit PreemptRedirectEvent) is
deferred; needs live LLM verification. Until it ships no redirect event is emitted in
production, so the override is inert. Spec: docs/plans/2026-06-10-freestyle-graph-rerouting.md
ArtifactPayloadValidator passed the logical slot name into the
content-hash-keyed CAS store, crashing every workflow with a typed
produces slot. Validator now reads payloads from a content map threaded
through ValidationContext (populated from the orchestrator's per-session
artifact cache across all stages) and no longer depends on ArtifactStore.
Also records the slot->CAS-hash mapping as a new ArtifactContentStoredEvent
(registered for serialization), emitted at both CAS write sites, so
artifact content is recoverable from CAS by hash after a cold start.
Records operator profile as a snapshot event at session start (invariants #8/#9),
populates SessionState.boundProfile via reducer, and injects the about text as a
pinned L0 SYSTEM context entry in every stage. Gated by PersonalizationConfig.enabled.
ChatTurnEvent gains nullable latencyMs and tokensUsed fields (defaults preserve
backward-compat; legacy JSON without them deserializes cleanly). emitChatTurn
accepts optional metrics; the ROUTER emit site passes inferenceResponse values
through; the USER emit site leaves both null.
Add a WorkspaceContext value type and a nullable OrchestrationConfig.workspace,
plus SessionWorkspaceBoundEvent registered in eventModule. Purely additive; no
behavior change when workspace is null. (Axis 2 Phase A, tasks 1-2.)
Carry the full RiskSummary (level, signals, rationale) inline on
ApprovalRequestedEvent instead of discarding it after assessment. The two
server-side DTO builders (live ApprovalCoordinator path and replay
DomainEventMapper path) now render real risk level, recommended action,
signal factors, and the [code] rationale lines into the approval WebSocket
message, with a consistent enum-name fallback when no assessment ran (tool
path). RiskSummary/RiskSignal made @Serializable for event embedding;
shared RiskSummary.toDto() lives next to RiskSummaryDto.
Record L3 retrieval as an event carrying hit text (invariant #9), then
rebuild router state and inject recalled memories as a SYSTEM L3 layer in
the context pack. Apply token budget: protected frames (L0 immutable +
current user turn) are never dropped; honest budgetUsed is reported and
'BudgetExceeded' is flagged in appliedStrategies when they overflow. L1/L2
fit newest-first so oldest entries drop first. Emit ContextTruncatedEvent
when entries are dropped. L3MemoryRetrievedEvent is emitted on every CHAT
turn (empty hits reset recalled memory).
When a user sends input, embed it, query L3 across all sessions, dedup
against in-session turns, and record the retrieval as an event so replay
is deterministic (invariant #9). Hits land in RouterState; context
injection follows in a later slice.
- Add L3MemoryRetrievedEvent + L3RetrievedHit (registered in eventModule)
- RouterState.lastRetrievedMemory + reducer case; RouterTurn carries turnId
- RouterConfig.retrievalK (default 5)
- Harden L3 write path: runCatching + visible logging, cancellation
re-thrown; event append stays ahead of the L3 write
- Warn prominently when the non-durable in_memory L3 backend is selected
Risk tier is now derived from the worst validation issue severity and
the issues that drove it travel with the summary, so consumers can show
operators why a tier was assigned instead of an opaque level.
- Add RiskSummary.rationale (defaulted, backward-compatible) holding
human-readable '[code] message' lines from the validation report
- Pin severity -> risk mapping: ERROR->HIGH, WARNING->MEDIUM, INFO->LOW
- DefaultRiskAssessor folds the validation-driven level into the summary
Adds ChatTurnEvent (role USER|ROUTER) so router conversation history
survives restart and is rebuilt from the event log via projection,
restoring Hard Invariant #1 for the CHAT path. Both CHAT and STEERING
now emit user + router turn events; STEERING additionally keeps its
existing SteeringNoteAddedEvent so the structured directive record is
preserved alongside the conversation turns.
RouterFacade drops the in-memory ConcurrentHashMap of histories and
rebuilds state through the repository after each emission. Reducer
appends turns to conversationHistory. ChatTurnEvent is registered in
eventModule.
P2-1: Guard SessionId NPE with safe-call in SessionsReducer
P2-2: Remove 16 dead event classes + reducer branches; replace with live orchestration events in tests
P2-3: Standardize ChatInput divergences — guard CancelSession, single-arg ProtocolError
P2-4: Remove dead TuiToolRecord.diff and ToolDisplayStatus.REQUESTED
P2-5: Remove dead streamLive from SessionEventBridge
P2-6: Extract SessionsReducerContext data class for 6+ param method
P2-7: Replace StageId("none") sentinel with TypeId.NONE
P2-9: Add TypeId.random() factory on all type-alias IDs
P2-10: Add KDoc on schemaVersion documenting reserved-for-migration
P2-8: Verified RouterReducer string-template already correct (TypeId value class toString)
Event log integrity (sole source of truth):
- SqliteEventStore: serialize append/appendAll under a Mutex, enable WAL +
busy_timeout + synchronous=NORMAL, roll back on any Throwable. Replaces the
app-computed `SELECT MAX(seq)+1` read-modify-write that dropped events under
concurrent appends (race was invisible to CI: only the in-memory store and
single-threaded :memory: tests were ever exercised).
- Serialization: encodeDefaults + ignoreUnknownKeys, explicit @SerialName on all
46 EventPayload subclasses and event-referenced enum constants, @Serializable on
RiskLevel/RiskAction. Guards the append-only log against silent corruption from
future class renames, field removals, or default-value changes.
Approval/grant security (Invariant: approvals cannot widen authority):
- Tier authorization is now a ceiling (request.tier.level <= max granted) instead
of set membership; SESSION grants bind to a specific toolName instead of matching
everything; handleCreateGrant rejects empty/over-tier/scopeless grants.
Router chat wiring (Phase 0-2): ChatInput round-trip, StartChatSession from IDLE,
router-panel layout, workflows behind Ctrl+W.
Tests: SqliteEventStore concurrency, serialization round-trip + discriminator
stability + unknown-key tolerance, adversarial grant scope/tier; updated existing
approval and reducer tests for the new grant semantics and StartChatSession effect.
- Add unified diff viewer for file_write/file_edit tools across all layers:
ToolReceipt.diff → DiffUtil (LCS-based) → SandboxedToolExecutor → ToolCompleted
→ TuiToolRecord.diff → DiffViewer widget (colored +/- lines)
- Add workflow selection panel: WorkflowList server message, WorkflowListPanel
widget, ↑↓ seamlessly extends into workflow picker from session list
- Add cursor/caret support: inputCursor in TuiState, CursorLeft/CursorRight
actions and key events, AppendChar inserts at cursor, Backspace deletes before
- Colorize event history strip (green/red/yellow/blue by type) and input bar
(blue/yellow prompt, cyan session name, blue keybindings)
- Show 5 recent events instead of 3; increase event strip height to 6
- Remove lastEventAt sort so display and navigation use consistent order
- Fix approval reconnect: register pending approvals with ApprovalCoordinator
during snapshot replay so lookupSession works after reconnect
- 167 tests pass (126 TUI + 41 server)
StoredEvent gains sessionSequence: Long (per-session monotonic); existing
sequence field becomes the global monotonic cursor across the entire store.
SqliteEventStore allocates both counters inside the same BEGIN IMMEDIATE
transaction and emits to a global MutableSharedFlow after commit.
InMemoryEventStore mirrors this with an AtomicLong global counter and a
suspend-on-overflow SharedFlow. EventStore interface gains subscribeAll()
and lastGlobalSequence(). Contract tests updated and extended to cover
global-sequence and cross-session subscribeAll invariants.
- fixed the tool overlay in tui.
- fixed tool calling - added schemas.
- getting all sessionIds via event store.
- instead of sending all events on the replay - there's a new way for replaying.
- session snapshot is now a thing getting sent for previously created sessions.
- added logging to important parts.
- revert workflowId from WorkflowCompletedEvent.
fixed detekt issues where possible.
fixed disttar failing build because tools is added twice in the server module.
added workflowId where required.
fixed some tests not being recognized because of runBlocking without explicit return type.
formatting + imports.
Implements the full conversational router facade: RouterState, RouterReducer,
RouterProjector, RouterRepository, RouterContextBuilder, RouterFacade, protocol
types, WebSocket wiring, infrastructure factory, and deterministic test suite.
Also fixes spec divergences found in post-implementation review:
- Add SteeringNote domain object to core:context (epic prerequisite)
- Rename RouterFacade.handleChat → onUserInput per spec interface contract
- Add in-memory ConcurrentHashMap conversation history to DefaultRouterFacade
- Make RouterRepository.getRouterState suspend
- Rename RouterConfig.keepLast → conversationKeepLast, fix defaults (6, 4096)
- Refactor InfrastructureModule.createRouterFacade to self-assemble internally
- Fix FileReadTool: allowedPaths was dead constructor param (@SuppressUnusedParameter);
now stored as private val and enforced in validateRequest
- Disable koverVerify on modules tested via testing/ submodules or with
hardware/integration dependencies (24 modules); build gate now passes clean
New core:artifacts-store interface + infrastructure/artifacts-cas
implementation: segment files + SQLite index, Blake3 hashing,
group-commit fsync via flushBefore, recovery tail-scan, manual
compactor, and oldest-first disk-cap evictor.
Inference events now carry promptArtifactId / responseArtifactId;
orchestrators put bytes before emitting. SqliteEventStore wraps
its txn in artifactStore.flushBefore so segment data is fsynced
before the event commit, making the crash window non-corrupting
(TailScanner re-indexes orphan tail records on reopen).
Compactor and evictor are mutually exclusive via maintenanceMutex.
Step 9 (cloud sync) deferred to a later epic.
See docs/reviews/2026-05-18-cas-steps-1-8-review.md for the final
review.