Timothy July 10, 2026 ~7 min read

Timothy Part 6: Sessions That Survive, Event Sourcing the Backend

The Phase 0 design stored recent turns in a simple in-memory ring buffer per session. A restart of the brain forgot everything after the last ledger entry. Context grew linearly with every turn. There was no durable search, no reliable resume, and no way to keep provider prompt caches warm across turns.

The sessions milestone replaced that buffer with a durable, queryable, context-disciplined event log. The key decisions were driven by four constraints:

  • History must survive process restarts and client disconnects.
  • Context sent to the model must not grow without bound.
  • Provider prompt caches must keep hitting (byte-identical prefixes between turns).
  • Consumers of the transcript must be able to show the full history, including what was summarized or interrupted.

Append-only event log (D-006)

Why an append-only log instead of a mutable messages table?

A conventional messages table allows UPDATE and DELETE. That immediately creates several failure modes:

  • A failed provider call after the user message is written can leave an orphaned user row.
  • Compaction would have to rewrite or delete old rows, destroying the audit trail.
  • Concurrent writers (or retries) can produce duplicate or out-of-order rows unless complex locking is added everywhere.
  • Full-text search and paging become harder to make consistent with the LLM view.

Instead, every session is an append-only sequence:

-- migrations/0004_sessions.sql
session_events(
  session_id uuid NOT NULL REFERENCES sessions(id),
  seq bigint NOT NULL,
  kind text NOT NULL,
  payload jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (session_id, seq)
)

Sequence numbers are generated inside the transaction under a row lock:

// internal/brain/session/store.go
tx.QueryRow(ctx,
    `INSERT INTO session_events (session_id, seq, kind, payload)
     SELECT $1, COALESCE(MAX(seq), 0) + 1, $2, $3
     FROM session_events WHERE session_id = $1
     RETURNING seq`, ...)

User messages are appended before the gateway call. The assistant turn (or pending_state) is appended after. Archive is a flag on the sessions row only; the event log is never mutated.

This decision directly implements D-006: “Sessions are event-sourced… Nothing is ever UPDATEd or DELETEd.”

Dual projections from one log

Why two separate projections instead of a single conversation document?

The model and the UI have fundamentally different requirements:

  • The LLM needs a flat, token-budgeted sequence of user/assistant messages. It must never see raw tool traces after the turn ends, and it benefits from summaries of the distant past.
  • The UI must show everything that happened, including reasoning blocks, compaction points, and turns that were cut off mid-stream. It must also support search and resume.

A single structure cannot satisfy both without leaking concerns.

The implementation keeps two pure functions:

  • LLMContext(events)[]provider.Message (what the model sees)
  • UITranscript(events)[]TranscriptItem (what the browser renders)

See internal/brain/session/projection.go. The same event log is the source of truth for both. This separation is the core of D-006.

Prefix stability (D-018)

Why must the LLM context be a stable prefix?

Provider prompt caches are keyed on the exact bytes of the conversation prefix. Any rewrite of earlier messages changes the cache key for the entire request.

The rule (D-018) is strict:

Between turns, the projected context is a stable prefix plus appended new content. The prefix changes only at explicit compaction events.

LLMContext never trims or rewrites earlier messages. A compaction_applied event is the only thing that replaces a prefix with a summary. Appending a new user_message or assistant_turn can only extend the list.

A property test in projection_test.go (TestLLMContextPrefixStability) grows a log one event at a time and asserts every prior message remains byte-identical.

This is not an optimization; it is a cost control. Violating it would make every turn pay full price for the entire history.

TurnMemory distillation (D-007)

Why distill to a small structured residue instead of keeping raw traces or the full prior context?

Within a turn the model needs the complete tool traffic. Across turns, raw traces cause two problems:

  • Linear (or worse) growth in tokens.
  • Loss of signal: the model has to re-attend to low-value details on every turn.

D-007 requires replacing the raw trace with a compact, structured record:

type TurnMemory struct {
    FilesChanged []string   `json:"files_changed,omitempty"`
    Failures     []Failure  `json:"failures,omitempty"`
    KeyFindings  []string   `json:"key_findings,omitempty"`
}

It is produced by a mini call after the turn, stored inside the assistant_turn payload, and rendered into later LLM contexts by renderAssistant. Raw traffic never re-enters the context.

The same structure is intended to feed long-term memory later.

Automatic incremental compaction

Why automatic, incremental summarization instead of “keep everything” or a manual full-history summary?

Unbounded growth violates the token budget. A one-time full summary at some arbitrary point loses too much and is not repeatable.

The design:

  • Trigger on the projected token count exceeding 60 % of the model’s context window (sourced dynamically from the gateway via gwclient.ModelWindows, cached 30 s).
  • Summarize only the oldest ~half of the live turns.
  • The summarize system prompt explicitly requires preservation of names, dates, numbers, commitments, decisions, and open questions.
  • The boundary is chosen so it never ends on an unanswered user message.
  • The result is a compaction_applied event carrying replaces_through_seq.
  • Post-compaction turns remain verbatim.

Compaction runs in two places: right after the durable user-message append (pre-send, before the provider call) to guarantee the context actually sent stays under budget even on the turn that crosses the limit, and again after the assistant turn persists (in persistTurn). The pre-send path is usually a cheap no-op. Repeated calls converge because each pass halves the live prefix.

planCompaction and the live-pending counting logic are deliberately pure and heavily tested (compactor_test.go).

Token estimation uses tiktoken o200k_base (D-022) only for the pre-send budget decision. Actual usage and cost always come from the ledger.

Crash safety with pending_state

Why persist partial turns as events, and why the specific splice rule?

A mid-stream crash or disconnect must not lose the user’s message or the partial answer that was already generated.

While the stream runs, a ticker flushes the accumulated text as pending_state every 2 s. On any abnormal end (ctx.Done, upstream close without done, etc.), relay flushes and then persists using a detached context.

In the next request, livePendingSeq + the projection logic splices the newest unsuperseded pending in as an interrupted assistant message.

Crucially:

A user message does not supersede a live pending.

The follow-up question must still see the partial that was cut off. Early versions that dropped the pending on the next user message caused the model to invent a different continuation.

http.Server.BaseContext is derived from the signal context so SIGTERM reaches in-flight handlers (internal/platform/httpserver/server.go).

Paging and the composite cursor

Why (updated_at, id) instead of a simple timestamp or sequence number for list pagination?

updated_at is touched on every append, so it is the natural “most recently active” order. Pure timestamps have ties when multiple sessions are updated in the same second (or during bulk operations).

A composite cursor (updated_at, id) with a stable tie-breaker (id as UUID) gives strict < ordering. The API requires both halves of the cursor (before + before_id) and rejects incomplete ones. The first page uses a future sentinel + max UUID.

This appears in both the SQL (WHERE (updated_at, id) < ($1, $2::uuid)) and the web client.

Supporting discipline: separate deadlines

Durability writes and post-turn LLM work (distill, compaction, title) must not share a single deadline. A slow model previously starved the append of the assistant turn. Each phase now has its own timeout (persistTimeout, distillBudget, compactBudget).

What the decisions protect

The combination of append-only events, dual projections, prefix stability, TurnMemory, incremental compaction, pending splice rules, and composite cursors gives:

  • No data loss on restart or kill.
  • Context that stays within budget without rewriting history.
  • Prompt cache hits between turns.
  • Searchable, resumable history.
  • An honest transcript that shows interruptions and summaries.

These are the invariants future changes must preserve.

Next: the other half of the same log, the UI that replays it: resume, live checkpoints, and a transcript that hides nothing.


Core files: internal/brain/session/{store.go,projection.go,compactor.go,events.go}, internal/brain/chat/chat.go, internal/brain/api/api.go. Design decisions: D-006, D-007, D-018, D-022.

// end of article — process exited with code 0

// share:Twitter / XLinkedIn

// comments

loading...