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
@@ -21,10 +21,10 @@ class GrantSemanticsTest {
@Test
fun `grant permits exact tier - auto_approved`() {
val req = request("r1", Tier.T2)
val req = request("r1", Tier.T2).copy(toolName = "shell")
val grant = ApprovalGrant(
id = GrantId("g1"),
scope = GrantScope.SESSION,
scope = GrantScope.SESSION(toolName = "shell"),
permittedTiers = setOf(Tier.T2),
reason = "test",
timestamp = Instant.parse("2026-01-01T00:00:00Z")
@@ -45,7 +45,7 @@ class GrantSemanticsTest {
val req = request("r1", Tier.T3)
val grant = ApprovalGrant(
id = GrantId("g1"),
scope = GrantScope.SESSION,
scope = GrantScope.SESSION(),
permittedTiers = setOf(Tier.T2),
reason = "test",
timestamp = Instant.parse("2026-01-01T00:00:00Z")
@@ -63,18 +63,18 @@ class GrantSemanticsTest {
@Test
fun `multiple grants - any match is enough`() {
val req = request("r1", Tier.T4)
val req = request("r1", Tier.T4).copy(toolName = "write_file")
val grants = listOf(
ApprovalGrant(
GrantId("g1"),
GrantScope.SESSION,
GrantScope.SESSION(toolName = "shell"),
setOf(Tier.T3),
"",
Instant.parse("2026-01-01T00:00:00Z")
),
ApprovalGrant(
GrantId("g2"),
GrantScope.SESSION,
GrantScope.SESSION(toolName = "write_file"),
setOf(Tier.T4),
"",
Instant.parse("2026-01-01T00:00:00Z")