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:
2026-05-28 22:39:15 +04:00
parent 57d2237ba0
commit cdee5f2245
43 changed files with 690 additions and 223 deletions
@@ -26,8 +26,10 @@ class DefaultApprovalEngine : ApprovalEngine {
val matchingGrant = grants.firstOrNull { grant ->
!isExpired(grant, now)
&& scopeMatches(grant.scope, context)
&& request.tier in grant.permittedTiers
&& scopeMatches(grant.scope, context, request.toolName)
// Ceiling semantics: grant authorizes up to its highest tier, not an exact set.
&& grant.permittedTiers.isNotEmpty()
&& request.tier.level <= grant.permittedTiers.maxOf { it.level }
}
if (matchingGrant != null) {
@@ -83,9 +85,15 @@ class DefaultApprovalEngine : ApprovalEngine {
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
}
private fun scopeMatches(scope: GrantScope, context: ApprovalContext, requestToolName: String?): Boolean =
when (scope) {
// SESSION grants are scoped to a specific tool name.
// A null toolName on either side means the binding is absent; treat as no-match
// to prevent a legacy/malformed grant from becoming a blanket approval.
is GrantScope.SESSION -> scope.toolName != null
&& requestToolName != null
&& scope.toolName == requestToolName
is GrantScope.STAGE -> context.identity.stageId == scope.stageId
is GrantScope.PROJECT -> context.identity.projectId == scope.projectId
}
}
@@ -68,7 +68,7 @@ class DefaultApprovalReducerTest {
val grantId = GrantId("grant-1")
val payload = ApprovalGrantCreatedEvent(
grantId = grantId,
scope = GrantScope.SESSION,
scope = GrantScope.SESSION(),
permittedTiers = setOf(Tier.T1, Tier.T2),
reason = "user approved",
expiresAt = null,
@@ -81,7 +81,7 @@ class DefaultApprovalReducerTest {
assertTrue(state.grants.containsKey(grantId))
assertEquals(grantId, state.grants[grantId]?.id)
assertEquals(GrantScope.SESSION, state.grants[grantId]?.scope)
assertEquals(GrantScope.SESSION(), state.grants[grantId]?.scope)
}
@Test
@@ -90,7 +90,7 @@ class DefaultApprovalReducerTest {
val addPayload = ApprovalGrantCreatedEvent(
grantId = grantId,
scope = GrantScope.SESSION,
scope = GrantScope.SESSION(),
permittedTiers = setOf(Tier.T1),
reason = "granted",
expiresAt = null,
@@ -76,7 +76,7 @@ class DefaultApprovalRepositoryTest {
metadata = metadata(sessionId, "event-1"),
payload = ApprovalGrantCreatedEvent(
grantId = grantId,
scope = GrantScope.SESSION,
scope = GrantScope.SESSION(),
permittedTiers = setOf(Tier.T1, Tier.T2),
reason = "approved",
expiresAt = null,