fix: harden event store, serialization, and grant engine; wire router chat
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.
This commit is contained in:
@@ -47,11 +47,19 @@ sealed class ClientMessage {
|
||||
val permittedTiers: List<Tier>,
|
||||
val reason: String,
|
||||
val expiresAt: Instant? = null,
|
||||
// Required for SESSION scope: binds this grant to a specific tool name.
|
||||
val toolName: String? = null,
|
||||
) : ClientMessage()
|
||||
|
||||
@Serializable
|
||||
data class Ping(val timestamp: Long) : ClientMessage()
|
||||
|
||||
@Serializable
|
||||
data class StartChatSession(
|
||||
val sessionId: SessionId,
|
||||
val text: String,
|
||||
) : ClientMessage()
|
||||
|
||||
@Serializable
|
||||
data class ChatInput(val sessionId: SessionId, val text: String, val mode: ChatMode) : ClientMessage()
|
||||
}
|
||||
|
||||
@@ -12,14 +12,17 @@ import com.correx.apps.server.protocol.WorkflowDto
|
||||
import com.correx.apps.server.protocol.StageToolDecl
|
||||
import com.correx.apps.server.protocol.ToolDecl
|
||||
import com.correx.core.approvals.GrantScope
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
||||
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.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.types.GrantId
|
||||
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.inference.ProviderHealth
|
||||
import com.correx.core.utils.TypeId
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
@@ -140,11 +143,29 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
when (msg) {
|
||||
is ClientMessage.Ping -> Unit
|
||||
is ClientMessage.StartSession -> handleStartSession(session, msg, sendFrame)
|
||||
is ClientMessage.StartChatSession -> handleStartChatSession(session, msg, sendFrame)
|
||||
is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId)
|
||||
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
||||
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
|
||||
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
|
||||
is ClientMessage.ChatInput -> Unit // handler deferred to subsequent task
|
||||
is ClientMessage.ChatInput -> {
|
||||
runCatching {
|
||||
module.routerFacade.onUserInput(msg.sessionId, msg.text, msg.mode)
|
||||
}.onSuccess { response ->
|
||||
sendFrame(ServerMessage.RouterResponseMessage(
|
||||
sessionId = msg.sessionId,
|
||||
content = response.content,
|
||||
steeringEmitted = response.steeringEmitted,
|
||||
))
|
||||
}.onFailure {
|
||||
log.error("routerFacade.onUserInput failed: {}", it.message)
|
||||
sendFrame(ServerMessage.ProtocolError(
|
||||
message = "Router error: ${it.message}",
|
||||
sequence = null,
|
||||
sessionSequence = null,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,13 +209,31 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
msg: ClientMessage.CreateGrant,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
// Validate: tiers must be non-empty and bounded to T2 (server-side ceiling).
|
||||
// T3/T4 are destructive/escalated tiers that must never be auto-approved via grants.
|
||||
if (msg.permittedTiers.isEmpty()) {
|
||||
sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
|
||||
return
|
||||
}
|
||||
val maxGrantableTier = Tier.T2
|
||||
val overBroad = msg.permittedTiers.filter { it.level > maxGrantableTier.level }
|
||||
if (overBroad.isNotEmpty()) {
|
||||
sendFrame(errorResponse("CreateGrant: tiers ${overBroad.map { it.name }} exceed maximum grantable tier ${maxGrantableTier.name}"))
|
||||
return
|
||||
}
|
||||
|
||||
val scope = when (msg.scope) {
|
||||
GrantScopeDto.SESSION -> {
|
||||
if (msg.stageId != null) {
|
||||
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
|
||||
return
|
||||
}
|
||||
GrantScope.SESSION
|
||||
// Require toolName — blanket session grants (no operation scope) are rejected.
|
||||
val toolName = msg.toolName?.takeIf { it.isNotBlank() } ?: run {
|
||||
sendFrame(errorResponse("CreateGrant: SESSION scope requires toolName to prevent blanket approval"))
|
||||
return
|
||||
}
|
||||
GrantScope.SESSION(toolName)
|
||||
}
|
||||
GrantScopeDto.STAGE -> {
|
||||
val sid = msg.stageId ?: run {
|
||||
@@ -222,11 +261,66 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
sessionId = msg.sessionId,
|
||||
stageId = (scope as? GrantScope.STAGE)?.stageId,
|
||||
projectId = null,
|
||||
toolName = (scope as? GrantScope.SESSION)?.toolName,
|
||||
),
|
||||
)
|
||||
module.eventStore.append(event)
|
||||
}
|
||||
|
||||
private suspend fun handleStartChatSession(
|
||||
session: DefaultWebSocketServerSession,
|
||||
msg: ClientMessage.StartChatSession,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
val sessionId = msg.sessionId
|
||||
log.info("starting chat session={}", sessionId.value)
|
||||
|
||||
// Emit WorkflowStartedEvent for the new session (no graph, no orchestrator).
|
||||
val event = NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = WorkflowStartedEvent(
|
||||
sessionId = sessionId,
|
||||
workflowId = "chat",
|
||||
startStageId = StageId("none"),
|
||||
),
|
||||
)
|
||||
module.eventStore.append(event)
|
||||
|
||||
// Send SessionStarted so the TUI creates the session entry immediately.
|
||||
val started = ServerMessage.SessionStarted(
|
||||
sessionId = sessionId,
|
||||
workflowId = "chat",
|
||||
sequence = 0L,
|
||||
sessionSequence = 0L,
|
||||
)
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started)))
|
||||
|
||||
// Call router and send response.
|
||||
runCatching {
|
||||
module.routerFacade.onUserInput(sessionId, msg.text)
|
||||
}.onSuccess { response ->
|
||||
sendFrame(ServerMessage.RouterResponseMessage(
|
||||
sessionId = sessionId,
|
||||
content = response.content,
|
||||
steeringEmitted = response.steeringEmitted,
|
||||
))
|
||||
}.onFailure {
|
||||
log.error("routerFacade.onUserInput failed: {}", it.message)
|
||||
sendFrame(ServerMessage.ProtocolError(
|
||||
message = "Router error: ${it.message}",
|
||||
sequence = null,
|
||||
sessionSequence = null,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleStartSession(
|
||||
session: DefaultWebSocketServerSession,
|
||||
msg: ClientMessage.StartSession,
|
||||
|
||||
Reference in New Issue
Block a user