# KHAELOR V1 — Event Model > **Status:** Phase 1 deliverable — the complete typed event vocabulary. **Normative** for `docs/ARCHITECTURE.md` and all Phase 2–6 implementation. > **Inputs:** CLAUDE.md §7 (base vocabulary), ADR-3 (event-sourced JSONL), ADR-4 (typed bus, durable/ephemeral split, 16 ms coalescing), ADR-5 (streaming), ADR-6/7 (compaction-as-event, byte-stability), ADR-11 (interruption/steering), ADR-12 (completion), ADR-15 (git baseline). > > The event log is the source of truth for session replay and resume (CLAUDE.md §7). Everything else — UI state, LLM message history, cost, metadata — is a projection. --- ## 1. Design Rules 1. **Two classes of event.** **DURABLE** events are appended to the session JSONL *before* being published on the bus (write-ahead), and carry a per-session monotonic `seq`. **EPHEMERAL** events (streaming deltas) flow to the TUI only and are never persisted (ADR-4). 2. **The completed block is the durable record.** Deltas are lossy by design; when a stream closes a block, the settled content is recorded durably (`ModelTextBlockCompleted`, `ToolRequested`, …). A crash mid-block loses at most the in-flight block — OpenCode behaves identically (ADR-4). 3. **Rendering never lives on events.** Payloads are domain data; presentation is keyed by `type` inside the TUI (OpenHands `visualize` anti-pattern excluded, ADR-4). 4. **Every event carries `sessionId`** — the cheap seam subagents-as-child-sessions depends on (ADR-16). 5. **Payloads are bounded.** Anything that could be huge (tool output, diffs) is stored budget-truncated with an explicit marker, with full content spilled to a file path (ADR-8). The log never stores unbounded blobs. 6. **Honest data only.** Usage, cost, exit codes, and check results come from real sources; no event may carry fabricated numbers (Absolute Rule #4). 7. **Naming:** past-tense facts (`ToolCompleted`), not commands. Commands are `KernelCommand`s (`ARCHITECTURE.md §2.2`), which *cause* events but are not events. --- ## 2. Envelopes ```ts // shared/events.ts /** Durable envelope — one JSONL line per event. */ export interface Durable { v: 1; // schema version of this event line (§5.4) id: string; // ULID — globally unique, time-ordered sessionId: string; seq: number; // monotonic per session, gapless, assigned at append time ts: number; // epoch milliseconds parentId?: string; // reserved, always absent in V1 (ADR-3: linear log, tree-ready) type: T; payload: P; } /** Ephemeral envelope — bus-only, never persisted. No seq (no log position), no v. */ export interface Ephemeral { id: string; // ULID (correlation/debugging) sessionId: string; ts: number; type: T; payload: P; } /** What producers hand to the bus; envelope fields are assigned by the store/bus. */ export type DurableEventInput = { type: DurableEvent["type"]; payload: DurableEvent["payload"] }; ``` --- ## 3. Catalog Summary — DURABLE vs EPHEMERAL | # | Event | Class | Rationale | |---|---|---|---| | 1 | `SessionStarted` | **D** | Anchors the log: cwd, model, config snapshot. Required by every projection. | | 2 | `SessionResumed` | **D** | Audit trail; marks replay boundaries; records model/tool-set verification (resume contract, ADR-3). | | 3 | `SessionRenamed` | **D** | `/rename` must survive restart; metadata is a projection (Rule: derivable from the log). | | 4 | `ModelChanged` | **D** | LLM history and cost projections must know which model produced which turns; starts a new cache lineage (ADR-7). | | 5 | `BaselineRecorded` | **D** | Attribution (KHAELOR's changes vs user's) must survive resume (ADR-15). | | 6 | `UserMessageCreated` | **D** | Conversation truth; LLM history input. | | 7 | `SteeringQueued` | **D** | Queued instructions must survive a crash before injection (ADR-11); UI shows `Queued instruction` after resume. | | 8 | `SteeringInjected` | **D** | The injection point alters LLM history; replay must reproduce identical bytes (ADR-7). | | 9 | `Interrupted` | **D** | `deriveNext` consumes it from state; explains truncated turns on replay (ADR-11). | | 10 | `ModelRequestStarted` | **D** | Correlates blocks/usage to a request; records model id + context stats for `/context` history. Small payload — never the full prompt (rebuildable by projection). | | 11 | `ModelTextDelta` | **E** | Pure streaming UX; settled text is durably recorded by #13. Persisting deltas would bloat the log for zero replay value (ADR-4). | | 12 | `ModelThinkingDelta` | **E** | Same as #11. | | 13 | `ModelTextBlockCompleted` | **D** | The durable record of assistant text — byte-exact for LLM history replay (ADR-7). | | 14 | `ModelThinkingBlockCompleted` | **D** | Thinking blocks (+ signature) must be replayed byte-exact in assistant turns during tool loops — API requirement; hence durable. | | 15 | `ToolCallStarted` | **E** | "Model began emitting a tool_use block" — UI hint only; input not yet complete. Durable record is #17. | | 16 | `ToolInputDelta` | **E** | Streaming partial JSON input; UI preview only. | | 17 | `ToolRequested` | **D** | The complete `tool_use` block (id, name, input) — doubles as the durable assistant-block record and the pending-work marker `deriveNext` consumes (ADR-2). | | 18 | `ModelResponseCompleted` | **D** | Stop reason + **real usage** — the only source of cost/compaction accounting (Rule #4, ADR-6/10). | | 19 | `ModelRequestFailed` | **D** | Typed error class; `context-overflow` drives reactive compaction on the *next* derivation, so it must be state (ADR-6/10). | | 20 | `PermissionRequested` | **D** | Pending approval = persisted unanswered event; approvals survive restarts (OpenHands pattern, ADR-9). | | 21 | `PermissionGranted` | **D** | Consent record + scope (`once`/`always`); audit. | | 22 | `PermissionDenied` | **D** | Denial + feedback text that becomes the model-facing observation (ADR-9). | | 23 | `ToolApproved` | **D** | Execution authorization (auto-allow or granted); separates policy outcome from execution start. | | 24 | `ToolStarted` | **D** | Execution actually began; duration accounting; distinguishes "approved but crashed before running" on replay. | | 25 | `ToolOutput` | **E** | Live output chunks. The budgeted result is durably recorded by #26/#27; persisting raw chunks would duplicate it unbounded (ADR-8). | | 26 | `ToolCompleted` | **D** | The `tool_result` content the model saw — byte-exact for LLM history (ADR-7); includes spill path + UI meta. | | 27 | `ToolFailed` | **D** | Error `tool_result` (is_error) the model saw; error kind for diagnostics. | | 28 | `ToolCancelled` | **D** | Synthetic cancelled `tool_result` — keeps tool_use/tool_result pairing protocol-valid across interrupt/crash (ADR-11, §6.5). | | 29 | `FileRead` | **D** | Tiny payload; feeds recency/frecency, `/context` file list, and external-modification detection. Cheap and useful ⇒ durable. | | 30 | `FileModified` | **D** | Change set, diff stats, capped diff — powers `/diff`, attribution, and the verification gate after resume (ADR-12/15). | | 31 | `ProcessStarted` | **D** | Process registry state; checkpoint field `running_processes` derives from it (ADR-6). | | 32 | `ProcessOutput` | **E** | Rolling in-memory buffer (200K) is the read model; processes die with the KHAELOR process, so persisted output has no replay value. Model access is via `process.read`, recorded as that tool's result. | | 33 | `ProcessExited` | **D** | Terminal state + cause; resume renders processes as exited. | | 34 | `ContextPruned` | **D** | Deterministic replay: the exact pruned toolUseIds must be re-applied byte-identically on every rebuild (ADR-6/7). | | 35 | `ContextCompacted` | **D** | Compaction-as-event: checkpoint + cut range, re-applied deterministically; doubles as a replay snapshot (ADR-6). | | 36 | `VerificationRequested` | **D** | Gate attempts are budgeted (≤2) — the count must be derivable from state; preserves the withheld candidate (ADR-12). | | 37 | `TaskCompleted` | **D** | Carries `CompletionEvidence` — the rigorous completion record (CLAUDE.md §17). | | 38 | `TaskFailed` | **D** | Terminal failure + typed reason. | **32 durable, 6 ephemeral.** Anything not in this table is not an event. --- ## 4. Type Definitions (normative) ```ts // ───────────────────────── shared payload types ───────────────────────── export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "refusal"; export interface ModelUsage { // real API fields only inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; } export interface DiffStats { added: number; removed: number; } export interface CheckResult { command: string; exitCode: number; summary: string; // e.g. "148 passed", "typecheck passed" durationMs: number; } export interface CompletionEvidence { // CLAUDE.md §17 objective: string; changedFiles: string[]; checks: CheckResult[]; unresolvedIssues: string[]; } export interface GitBaseline { branch: string; dirtyFiles: string[]; untrackedFiles: string[]; diffHash: string; // hash of `git diff` output at capture time } export type ModelErrorKind = | "retryable" | "context-overflow" | "auth" | "invalid-request" | "cancelled"; export type ToolName = | "read" | "write" | "edit" | "grep" | "glob" | "bash" | "process"; // ─────────────────────── session lifecycle (durable) ─────────────────────── export type SessionStarted = Durable<"session.started", { title: string; projectHash: string; workingDirectory: string; gitBranch: string | null; model: string; auxModel: string; khaelorVersion: string; }>; export type SessionResumed = Durable<"session.resumed", { khaelorVersion: string; replayedSeq: number; // highest seq replayed model: string; // active model after resume (may differ — swappable) toolNames: ToolName[]; // verified add-only vs. original set (ADR-3 resume contract) }>; export type SessionRenamed = Durable<"session.renamed", { title: string }>; export type ModelChanged = Durable<"session.model-changed", { from: string; to: string; reason: "user" | "config"; }>; export type BaselineRecorded = Durable<"git.baseline-recorded", { when: "session-start" | "pre-first-edit"; baseline: GitBaseline; }>; // ───────────────────────── user input (durable) ───────────────────────── export type UserMessageCreated = Durable<"user.message-created", { text: string; // byte-exact — enters LLM history verbatim mentions: { path: string; range?: { start: number; end: number } }[]; }>; export type SteeringQueued = Durable<"user.steering-queued", { text: string; }>; export type SteeringInjected = Durable<"user.steering-injected", { queuedEventId: string; // id of the SteeringQueued event seam: "post-tool-batch" | "pre-model-call"; afterSeq: number; // injection position in history — makes replay exact (ADR-7) }>; export type Interrupted = Durable<"user.interrupted", { scope: "turn"; // V1: Esc aborts the turn (model stream + in-flight tools) pendingToolUseIds: string[]; // tools that will be closed via ToolCancelled }>; // ──────────────────────────── model stream ──────────────────────────── export type ModelRequestStarted = Durable<"model.request-started", { requestId: string; // correlates all blocks/usage of this call model: string; purpose: "main" | "compaction" | "verification-nudge"; contextStats: { // for /context history — estimates labeled as such estimatedInputTokens: number; sections: { name: string; estimatedTokens: number }[]; }; }>; export type ModelTextDelta = Ephemeral<"model.text-delta", { requestId: string; blockIndex: number; text: string; }>; export type ModelThinkingDelta = Ephemeral<"model.thinking-delta", { requestId: string; blockIndex: number; text: string; }>; export type ToolCallStarted = Ephemeral<"model.tool-call-started", { requestId: string; blockIndex: number; toolUseId: string; toolName: ToolName; }>; export type ToolInputDelta = Ephemeral<"model.tool-input-delta", { requestId: string; blockIndex: number; toolUseId: string; partialJson: string; }>; export type ModelTextBlockCompleted = Durable<"model.text-block-completed", { requestId: string; blockIndex: number; text: string; // byte-exact settled block }>; export type ModelThinkingBlockCompleted = Durable<"model.thinking-block-completed", { requestId: string; blockIndex: number; thinking: string; signature: string; // required for byte-exact API replay in tool loops }>; export type ToolRequested = Durable<"tool.requested", { requestId: string; blockIndex: number; toolUseId: string; // Anthropic tool_use id — pairing key (§6.5) toolName: ToolName; input: unknown; // complete parsed input — byte-exact via canonical JSON (§5.1) }>; export type ModelResponseCompleted = Durable<"model.response-completed", { requestId: string; stopReason: StopReason; usage: ModelUsage; // REAL API usage — sole source for cost/compaction durationMs: number; }>; export type ModelRequestFailed = Durable<"model.request-failed", { requestId: string; kind: ModelErrorKind; message: string; // redacted — never headers/keys status?: number; retriesExhausted: boolean; }>; // ─────────────────────── permissions (durable) ─────────────────────── export type PermissionRequested = Durable<"permission.requested", { permissionRequestId: string; toolUseId: string; capability: string; // Capability descriptor: string; // human-meaningful: "Run `npm install` in ~/dev/project" suggestion?: { capability: string; pattern: string }; // "always allow git push *" (ADR-9) }>; export type PermissionGranted = Durable<"permission.granted", { permissionRequestId: string; scope: "once" | "always-project"; // "always" also persists a config rule (ADR-9) }>; export type PermissionDenied = Durable<"permission.denied", { permissionRequestId: string; source: "user" | "policy" | "hardline" | "timeout"; // silence is not consent (ADR-9) feedback: string; // returned to the model as the tool observation }>; // ─────────────────────── tool execution (durable + one ephemeral) ─────────────────────── export type ToolApproved = Durable<"tool.approved", { toolUseId: string; via: "policy-allow" | "user-once" | "user-always" | "rule"; }>; export type ToolStarted = Durable<"tool.started", { toolUseId: string; toolName: ToolName; }>; export type ToolOutput = Ephemeral<"tool.output", { toolUseId: string; chunk: string; // live chunk, ANSI-stripped for TUI }>; export type ToolCompleted = Durable<"tool.completed", { toolUseId: string; modelText: string; // byte-exact tool_result content (budget-truncated w/ markers) spillFile?: string; // full output under ~/.khaelor/spill/ (ADR-8) durationMs: number; ui: { // presentation DATA, not presentation (Rule 3) kind: "read" | "search" | "edit" | "exec" | "process"; summary: string; // e.g. `Search "ContextEngine" · 14 matches` diffStats?: DiffStats; exitCode?: number; matchCount?: number; }; }>; export type ToolFailed = Durable<"tool.failed", { toolUseId: string; modelText: string; // byte-exact is_error tool_result content errorKind: "invalid-input" | "not-found" | "ambiguous-edit" | "exec-error" | "timeout" | "permission-denied" | "internal"; durationMs: number; }>; export type ToolCancelled = Durable<"tool.cancelled", { toolUseId: string; reason: "interrupted" | "resume-recovery" | "shutdown"; modelText: string; // synthetic result, e.g. "[Tool execution cancelled by user]" }>; // ─────────────────────── file activity (durable) ─────────────────────── export type FileRead = Durable<"file.read", { path: string; // relative to workspace cwd range?: { start: number; end: number }; bytes: number; mtimeMs: number; // external-modification detection on later writes toolUseId: string; }>; export type FileModified = Durable<"file.modified", { path: string; operation: "write" | "edit"; diffStats: DiffStats; diff?: string; // unified diff, capped (default 32 KiB) with truncation marker toolUseId: string; }>; // ─────────────────────── processes (durable + one ephemeral) ─────────────────────── export type ProcessStarted = Durable<"process.started", { processId: string; // KHAELOR id (stable across PID reuse) pid: number; command: string; cwd: string; name?: string; toolUseId: string; }>; export type ProcessOutput = Ephemeral<"process.output", { processId: string; stream: "stdout" | "stderr"; chunk: string; }>; export type ProcessExited = Durable<"process.exited", { processId: string; exitCode: number | null; // null = signal-killed cause: "exited" | "stopped-by-tool" | "khaelor-shutdown" | "crashed"; durationMs: number; }>; // ─────────────────────── context engine (durable) ─────────────────────── export type ContextPruned = Durable<"context.pruned", { toolUseIds: string[]; // results blanked with the FIXED placeholder string placeholder: string; // recorded so replay is byte-exact even if default changes tokensReclaimedEstimate: number; }>; export type ContextCompacted = Durable<"context.compacted", { checkpointYaml: string; // the structured checkpoint (ARCHITECTURE.md §6.4), verbatim cut: { fromSeq: number; toSeq: number }; // replaced range — pairing-safe boundary (§6.5) trigger: "proactive-token-budget" | "reactive-overflow" | "user-command"; tokensBefore: number; // from real usage accounting summaryModel: string; // the auxModel used }>; // ─────────────────────── completion (durable) ─────────────────────── export type VerificationRequested = Durable<"task.verification-requested", { attempt: 1 | 2; detectedChecks: string[]; // e.g. ["npm test", "npx tsc --noEmit"] withheldCandidateSeq: number; // seq of the withheld answer's final text block (ADR-12) }>; export type TaskCompleted = Durable<"task.completed", { evidence: CompletionEvidence; }>; export type TaskFailed = Durable<"task.failed", { reason: "model-fatal-error" | "iteration-budget-exhausted" | "user-abandoned"; detail: string; }>; // ───────────────────────────── unions ───────────────────────────── export type DurableEvent = | SessionStarted | SessionResumed | SessionRenamed | ModelChanged | BaselineRecorded | UserMessageCreated | SteeringQueued | SteeringInjected | Interrupted | ModelRequestStarted | ModelTextBlockCompleted | ModelThinkingBlockCompleted | ToolRequested | ModelResponseCompleted | ModelRequestFailed | PermissionRequested | PermissionGranted | PermissionDenied | ToolApproved | ToolStarted | ToolCompleted | ToolFailed | ToolCancelled | FileRead | FileModified | ProcessStarted | ProcessExited | ContextPruned | ContextCompacted | VerificationRequested | TaskCompleted | TaskFailed; export type EphemeralEvent = | ModelTextDelta | ModelThinkingDelta | ToolCallStarted | ToolInputDelta | ToolOutput | ProcessOutput; export type KhaelorEvent = DurableEvent | EphemeralEvent; ``` Type-string convention: `domain.past-tense-fact` (`tool.completed`). The TypeScript alias names (PascalCase) match CLAUDE.md §7's vocabulary; the wire `type` strings are the namespaced forms above. --- ## 5. JSONL Serialization ### 5.1 Format - One durable event per line: `JSON.stringify(envelope)` + `"\n"`. No pretty-printing, no BOM, UTF-8. - **Canonical JSON for byte-stability-sensitive payloads:** object keys serialized in a fixed order (envelope: `v,id,sessionId,seq,ts,type,payload`; payloads: schema field order). `ToolRequested.input` is stored via a canonical-JSON stringify so the LLM-history projection reproduces identical bytes on every rebuild (ADR-7). - Strings are stored verbatim (JSON escaping only). No compression in V1; logs are line-greppable by design. ### 5.2 Append and atomicity rules 1. The store holds one file descriptor per open session, opened with `O_APPEND`. 2. Each event is written as **one `write()` call of one complete line** (single-writer process ⇒ a line is never interleaved). 3. Appends are serialized through a per-session write queue; `seq` is assigned at enqueue and is gapless. 4. **Write-ahead:** `publishDurable()` resolves the append (buffered write accepted by the OS) *before* the bus delivers the event to subscribers. Projections can therefore never observe an event the log doesn't contain. 5. **Flush/fsync policy:** `fsync` at loop boundaries — after each tool result batch, after `ModelResponseCompleted`, after `ContextCompacted`, and on shutdown (mini's `finally` discipline, ADR-3). Between boundaries, OS buffering is accepted. 6. The log is **never rewritten, truncated (except §5.3 recovery), or compacted in place**. `/clear` starts a new session file. 7. `meta.json` sidecars are projection caches written atomically (tmp + rename) and are always rebuildable from the log — never a second source of truth (ADR-3 concern, accepted). ### 5.3 Corruption recovery (truncated last line) A crash can leave at most one incomplete final line (consequence of rules 2–3). On `open()`: 1. Read the file; if the last line lacks a trailing `\n` **or** fails `JSON.parse` **or** fails envelope validation, it is a torn write. 2. Truncate the file to the end of the last valid line (atomic: `ftruncate` at the computed byte offset), after copying the torn bytes to `.jsonl.torn` for diagnostics. 3. Log a warning to `~/.khaelor/logs/`; never to the TUI. 4. Corruption anywhere *other* than the final line indicates external interference: the session opens read-only for inspection and resume is refused with an actionable message (no silent repair of user data). 5. After truncation, resume recovery runs (§6.5): dangling `tool_use` blocks are closed with `ToolCancelled{reason:"resume-recovery"}`. ### 5.4 Versioning - Every line carries `v` (schema version), per-line — a log may legitimately contain mixed versions after an upgrade (ADR-3: version events from day one; never a v1/v2 dual architecture). - **Reads migrate, writes are current:** the store applies pure upgrade functions `migrate_v1_to_v2(line) → line` at read time; new events are always written at the current version. Log files are never rewritten in place. - Additive payload changes (new optional field) do **not** bump `v`; readers must tolerate unknown fields. Renames/semantic changes bump `v` and ship a migration. - Unknown `type` at read time (from a newer KHAELOR): the event is preserved and surfaced as an opaque timeline item; projections that don't recognize it skip it. Resume is refused only if an unknown event is *load-bearing* (declared via a `critical: true` envelope extension reserved for future use). --- ## 6. Projections All projections are folds: `state = events.reduce(apply, initial)`. The same reducer maintains live in-memory state (as events are published) and rebuilds on resume (as events are replayed) — one code path, two feeds (ADR-2/3). Each projection tolerates deletion of its cache and rebuilds from the log. ### 6.1 Timeline (conversation state → TUI view-model) Fold rules: - `UserMessageCreated` → user message item. `SteeringQueued` → `Queued instruction` chip; `SteeringInjected` re-parents the chip to its injection point. - `ModelTextBlockCompleted` / `ModelThinkingBlockCompleted` → assistant text / collapsed thinking items, grouped per `requestId`. - `ToolRequested` → collapsed tool card (`▸ Read src/kernel/agent.ts`); `ToolStarted`/`ToolCompleted`/`ToolFailed`/`ToolCancelled` update its status/summary from `ui` metadata; `FileModified` attaches diff stats + expandable diff. - `PermissionRequested` without a matching `PermissionGranted/Denied` → active permission panel (this is how a pending approval survives restart). - `ProcessStarted`/`ProcessExited` → `/processes` list state. - `ContextCompacted` → subtle `— context compacted —` divider; checkpoint inspectable via `/context`. - `Interrupted` → turn marked interrupted. `TaskCompleted` → completion summary from `evidence`. - **Live streaming state is layered on top** by the coalescer (§7): ephemeral deltas mutate only the *live tail* of the view-model and are discarded once the corresponding durable settled event arrives. ### 6.2 LlmHistory (Anthropic `messages[]`) — byte-stable The most invariant-critical projection. Fold rules, in `seq` order: 1. `UserMessageCreated.text` → `{role:"user", content:[{type:"text", ...}]}`. 2. Per `requestId`: `ModelThinkingBlockCompleted` (+signature), `ModelTextBlockCompleted`, and `ToolRequested` assemble into one `{role:"assistant"}` message, **ordered by `blockIndex`**. 3. `ToolCompleted.modelText` / `ToolFailed.modelText` (is_error) / `ToolCancelled.modelText` → `tool_result` blocks in a `{role:"user"}` message, in the order the `tool_use` blocks appeared. 4. `SteeringInjected` → the queued text is appended as an additional text block **inside the tool-result user message at `afterSeq`** (pre-model-call seam: appended to the last user message) — never a new bare user message mid-alternation (ADR-11). 5. `ContextPruned` → for each listed `toolUseId`, the `tool_result` content is replaced by the recorded `placeholder` string. Deterministic: same event ⇒ same bytes, every rebuild. 6. `ContextCompacted` → all messages derived from events with `cut.fromSeq ≤ seq ≤ cut.toSeq` are replaced by a single synthetic user message containing `checkpointYaml` (a fixed template wrapper). Multiple compactions apply in `seq` order; a later compaction may consume an earlier checkpoint message. Replay-deterministic by construction — the checkpoint text and cut range live in the event, not in engine code (ADR-6). 7. `ModelChanged` marks a cache-lineage boundary (no message mutation). 8. Volatile per-turn context (git status, process list, mention contents) is **not** in this projection — the Context Engine attaches it to the API copy of the current message only (ADR-7). LlmHistory is exactly the stable replayable prefix. **Byte-stability contract:** rebuilding LlmHistory from the log at any time yields byte-identical message content to what was previously sent (the ADR-7 test: serialize → compare). ### 6.3 UsageTotals (cost counters) Fold: sum `ModelResponseCompleted.usage` fields, keyed by model id (main vs aux priced separately). Cost = tokens × configured pricing table. Incremental counters cached in `meta.json` are a pure cache (OpenCode's trick as projection, never truth — ADR-3). `/cost` and the status bar read this projection; if any usage field is absent, the display says so — nothing is estimated silently (Absolute Rule #4). ### 6.4 FileChangeSet Fold: `BaselineRecorded` fixes the attribution baseline; `FileModified` accumulates `{path → {operations, cumulative diffStats, toolUseIds}}`; `FileRead.mtimeMs` feeds external-modification warnings. Consumers: `/diff`, the verification gate (`needsVerification`: code files changed since last `CheckResult` evidence, documentation-only filtered), and attribution (`khaelor` vs `preExisting` — ADR-15). A `ContextCompacted` never erases this projection — file changes remain first-class even when their conversational context is summarized. ### 6.5 Pairing safety (tool_use / tool_result) **Invariant:** in LlmHistory, every `tool_use` block has exactly one `tool_result`, and the Anthropic role alternation is valid — at all times, including mid-crash and post-compaction. Enforcement, at three points: 1. **Runtime (ADR-11):** interrupt closes every in-flight or pending `toolUseId` with `ToolCancelled` before the turn ends. 2. **Resume recovery:** after replay (and §5.3 truncation), any `ToolRequested` lacking a terminal event (`ToolCompleted`/`ToolFailed`/`ToolCancelled`) gets a synthetic `ToolCancelled{reason:"resume-recovery"}` **appended durably at resume time** — recovery is itself an event, so the next replay needs no recovery. 3. **Compaction cuts:** `ContextCompacted.cut.toSeq` may only land where every `tool_use` at `seq ≤ toSeq` has its `tool_result` at `seq ≤ toSeq` (OpenHands' `manipulation_indices` discipline, ADR-6). The Context Engine computes candidate cut points from the projection; the store validates the invariant before appending the event (violation = internal error, refused). --- ## 7. Delta Coalescing Contract (TUI, ~16 ms) The bus delivers ephemeral events synchronously; the TUI's `Coalescer` is the single buffering point (ADR-4, ADR-14). ```ts export interface CoalescedFrame { textAppends: Map; // concatenated ModelTextDelta / ModelThinkingDelta toolInputPreviews: Map; // latest accumulated partial JSON per toolUseId toolOutputAppends: Map; // concatenated ToolOutput per toolUseId processOutputAppends: Map; // concatenated ProcessOutput per processId durables: DurableEvent[]; // durable events in this window, in seq order } export interface Coalescer { subscribe(onFrame: (f: CoalescedFrame) => void): Unsubscribe; } ``` Contract: 1. **Flush cadence:** a frame is flushed at most once per ~16 ms window (timer armed on first buffered event; nothing buffered ⇒ no timer, no idle wake-ups). One render per frame regardless of event rate. 2. **Coalescing is concatenation** for text-like deltas (order-preserving per block/tool/process key); consecutive deltas for the same key collapse into one string append. 3. **Ordering with durables:** when a durable event arrives that *settles* a streaming key (e.g. `ModelTextBlockCompleted` for a block with buffered deltas), the coalescer **flushes immediately** — buffered deltas for that key are delivered in the same frame, *before* the durable event in `durables`. The TUI thus always sees deltas-then-settlement, never settlement-then-stale-deltas. 4. **Settlement replaces:** on receiving the settled block, the TUI discards its accumulated delta string for that key and renders the durable content (byte-authoritative). Deltas are UX, never truth. 5. **Backpressure/caps:** buffered append strings are capped per key per frame (default 16 KiB); overflow within a window truncates the *visual* preview with a marker — the durable settled event restores full fidelity. The live-render region caps (ADR-14) apply downstream. 6. **Interrupt flushes:** `Interrupted` forces an immediate flush so the UI freezes at the last real state. --- ## 8. Event Bus API ```ts // shared/bus.ts export interface EventBus { /** Append (write-ahead, §5.2) then publish. Assigns v/id/sessionId/seq/ts. * Returns the full envelope. Throws only on unrecoverable store failure * (disk full is classified and surfaced actionably). */ publishDurable(input: DurableEventInput): DurableEvent; /** Fire-and-forget to live subscribers. Never persisted. */ publishEphemeral(input: EphemeralEventInput): void; /** Typed subscription — handler parameter narrows by `type`. */ on( type: T, handler: (e: Extract) => void, ): Unsubscribe; /** Wildcard (projections, logging, coalescer). */ onAny(handler: (e: KhaelorEvent) => void): Unsubscribe; } ``` Semantics and backpressure notes: - **In-process only** (ADR-17). The bus vocabulary is the system's only "API"; if the engine later moves to a `worker_thread`, this interface is what crosses the boundary. - **Synchronous fan-out, in `seq` order per session.** Handlers must be fast and non-throwing; a handler exception is caught, logged, and never blocks other subscribers or the kernel. Handlers needing async work schedule it — they do not make the bus async. - **No internal queues:** the coalescer (§7) is the only sanctioned buffering point; projection folds are O(small) per event by design. If a subscriber is measurably slow, fix the subscriber — not the bus (measure first, CLAUDE.md §18). - **Replay uses the same reducers, not the bus:** `SessionStore.open()` feeds replayed events directly to projection reducers; live subscribers (TUI, logging) receive only live events plus the rebuilt state handed to them at attach time. This keeps "replay" from re-triggering side effects. - Subscriptions return `Unsubscribe`; the TUI and kernel release all subscriptions on shutdown (leak check in tests). --- ## 9. Test Obligations (Phase 2–6 definition of done) 1. **Round-trip:** every event type serializes → parses → deep-equals (property test over the union). 2. **Byte-stability:** LlmHistory rebuilt from a log equals the recorded request bytes, including after `ContextPruned`/`ContextCompacted` and `SteeringInjected` (ADR-7). 3. **Pairing invariant:** fuzzed interrupt/crash points never yield a dangling `tool_use` after resume recovery (§6.5). 4. **Torn-write recovery:** truncating a log at every byte offset of the final line still opens cleanly (§5.3). 5. **Coalescer:** delta storms produce ≤1 frame per 16 ms; settlement ordering (deltas before durable) holds under race. 6. **Version tolerance:** unknown optional fields and unknown non-critical event types don't break replay (§5.4). --- *Author: Simon-Pierre Boucher · contact@spboucher.ai*