Skip to content

Event-sourced state: in-memory projection on top of an append-only log

Status: Adopted 2026-04-25. Not yet fully implemented (entries cache is the planned first step).

  • Event stream (the durable layer) — append-only JSONL logs in R2 (when signed in) or local filesystem (otherwise). Every change to the world — capture, edit, soft-delete, interaction, firing, session record — is one line appended to the appropriate log. This is what gets synced across devices (planned: Dropbox between Worker and a future second device); this is what survives app restarts and OS process kills.
  • In-memory state (the runtime layer) — a tauri::State-managed projection of the event stream into the data shape the app actually queries: deduped entries, tombstone-filtered, with SRS metadata derived. Lives in process memory for the lifetime of the app; rebuilt by replay on each launch.

The log is the source of truth. Memory is a derived view that exists for speed.

  • Reads are O(1). EntriesList, the carry-this surface, the Detail page — all hit memory, no network, no R2 round-trip per request.
  • Writes are race-free at the log level. Append-only JSONL has no read-modify-write loop. Multi-device sync just needs to merge two log tails (or let Dropbox do it via .conflict.N files).
  • Full audit trail by construction. Every state mutation is one log line. “What was the entry’s shape last Tuesday?” is answered by replaying through that timestamp.
  • The log doesn’t care about projection shape. We can re-derive EntryView (or some future RichEntryView) by adding a reducer; existing JSONL data needs no migration. Schema evolution lives in the projection, not in the persisted records.
  • Aligns with the existing design. We already have list_entries doing dedup + tombstone filtering on every read; that’s the current “cold projection.” This rule formalizes it: hot projection in memory, cold log on disk.
  • Aligns with the side-effect orchestration rule (docs/architecture/side-effect-orchestration.md) — Rust owns the writes that mutate domain state. Now Rust also owns the projected view of that state, and Elm queries it through core_dispatch.
  1. Tauri starts. AppState is constructed with an empty in-memory state and a hydration job pending.
  2. The hydration job reads every relevant JSONL log (entries, interactions, firings, sessions) via AppendLog::read_all.
  3. Each log is replayed through its reducer to build the in-memory projection (e.g. dedup entries by id, drop tombstones; project interactions onto SRS metadata).
  4. core_dispatch responses gate on hydration: dispatches that read state wait for hydration; writes can append immediately, but must also be applied through the reducer once hydration completes (or, simpler: block writes until hydrated).

For solo prototype, we can block on hydration synchronously at boot — the user expects a brief launch delay anyway.

Command::EntriesList (and its peers) hit the in-memory projection directly. No network, no disk read, no reducer pass.

Writes are two-phase:

  1. Append to log. The JSONL line goes to disk / R2. This is the durability anchor — power-loss between phases 1 and 2 leaves the log as the truth, and the next boot’s hydration picks it up.
  2. Apply reducer to memory. The same record is fed into the reducer that updates the in-memory projection. This keeps the runtime view current without needing to re-replay the whole log.

If phase 2 fails (panic, poisoned lock), the log is still correct and the next boot recovers. We never want to apply phase 2 before phase 1.

Same write path. The classify task (per docs/architecture/side-effect-orchestration.md) appends a supersede record to the log, then the reducer updates the in-memory entry’s shape. Elm sees the change on its next read with no network round-trip — and we can later add a Tauri event push to nudge Elm to refetch immediately, since the cache update is the natural notification trigger.

Each log gets one reducer. Sketch (pseudocode, not real Rust):

struct EntriesProjection {
by_id: HashMap<String, Entry>, // latest-wins on edits
tombstoned: HashSet<String>, // soft-deleted ids
// ...
}
impl EntriesProjection {
fn apply(&mut self, line: &str) {
let entry: Entry = serde_json::from_str(line).unwrap_or_else(/* skip malformed */);
if entry.is_deleted {
self.tombstoned.insert(entry.id.clone());
}
self.by_id.insert(entry.id.clone(), entry);
}
fn live_entries(&self) -> Vec<&Entry> {
self.by_id.values().filter(|e| !self.tombstoned.contains(&e.id)).collect()
}
}

Same pattern for interactions, firings, sessions. The current domain::list_entries and list_entry_views logic ports over essentially unchanged — it just runs once at hydration and incrementally on each write, instead of fresh on every read.

When we add Dropbox sync (or any external mutation source):

  • The log on disk can change beneath us. Detect it (mtime watch, Dropbox callback, etc.).
  • On detection, re-hydrate (or reconcile) the projection.
  • Conflict resolution stays in the log layer — .conflict.N files from Dropbox or merge-on-append for our own resolver.

This is a real problem when it lands but doesn’t change the core architecture. The projection just needs an “invalidate and re-read” path.

  • Memory. Hundreds-to-thousands of entries × small JSON each = KB-scale. Trivial today; even at 10× growth still trivial.
  • Boot cost. Replaying all logs on launch adds milliseconds-to-low-seconds depending on log size. For a multi-year personal log, this could become real — but at that point a snapshot+tail strategy (periodic projection dumps + replay just the recent tail) is the standard fix.
  • Cache invalidation. Within a single process: nonexistent (only Rust mutates the projection, and it does so atomically with the log append). Across processes / devices: see “cross-device sync” above.
  • Test surface. Reducer unit tests are easy (input log, expected projection). Integration tests against TauriDevice need a hydration step that the test harness can drive.
  1. Define the EntriesProjection (and per-log peers). Port list_entries logic into the reducer.
  2. Add the projection to AppState (or to TauriDevice if we want the dev-web core_server to share it).
  3. Wire hydration on Tauri startup (and on dev-web core_server startup).
  4. Update Command::EntriesList (and related read commands) to read from the projection.
  5. Update write commands (EntriesAppend, EntriesSoftDelete, etc.) to two-phase: append log, apply reducer.
  6. Update classify_and_supersede (the Rust-orchestrated background task) to use the same two-phase write.
  7. Drop the on-Detail-open EntriesList refetch in Elm — reads are now cheap, but they’re also unnecessary because the cache is always fresh.

Each step is independently shippable and testable.

  • 2026-04-25 conversation that adopted this rule. The trigger was: chunk 4’s silent classify-supersede landed an on-Detail-open EntriesList refetch that hits R2 every time (~$0 today, but wasteful in latency). User noticed, proposed a tauri::State cache. Generalized to event-sourcing because the existing JSONL design is already append-only — making the projection in-memory was a small extension, not a new architecture.