# spacesh SP2 — Persistent Event Log + Read-Model — Design

**Status:** Design (approved for plan authoring)
**Date:** 2026-06-10
**Scope:** Sub-project SP2 of the "frontend mocks → backend" decomposition. Backs the Event Center tabs (All/Unread/Errors), per-entry read state, and the top-bar `bell` badge with real daemon-owned data.

---

## Problem

Today the Event Center feed lives only in GUI memory (`App.tsx`): it is rebuilt from incoming `state`/`exit` events and is lost on every GUI restart. The `Unread` tab and the `bell` badge are mocked. The daemon — the single source of truth that outlives the GUI — keeps no event history at all.

SP2 moves the event log into the daemon, persists it to disk so it survives a cold daemon restart, and gives every event a real read-flag so the Unread/Errors filters and the bell badge reflect actual state.

## Decisions (locked during brainstorming)

| Question | Decision |
|---|---|
| Persistence depth | **Persist to disk** — survives cold daemon restart. |
| Read-model | **Explicit + focus** — read set on entry click, on focusing the event's surface, or "Mark all read". Badge = unread count. Unread tab = unread only. |
| Logged events | **done / wait / error + exit** — `work`/`idle` excluded as noise. |
| Retention | **1000 global ring buffer** — oldest evicted. |
| Storage approach | **Dedicated `EventStore` + `~/.spacesh/events.json`** (separate from `state.json`), mirroring the existing debounced atomic-write persister. |

## Architecture invariants honored

- **Daemon is the single source of truth.** The event log and all read flags live in `spaceshd`; GUI and CLI mirror it and never hold authoritative state.
- **One socket, one protocol.** All additions are new `Cmd`/`Evt` variants in `spacesh-proto`; no new transport.
- **Extend through the bus.** New behavior = new commands/events/subscribers; the spine does not move.
- **Files that change together live together.** The fast-changing event log gets its own module and file rather than bloating the slow-changing structure snapshot (`PersistState` / `state.json`).

---

## Components

### `spacesh-proto` (new types + wire additions)
A new module `event.rs` (re-exported from `lib.rs`):

```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EventKind { Done, Wait, Error, Exit }

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EventRecord {
    pub id: u64,                      // monotonic, survives restart
    pub surface_id: SurfaceId,
    pub workspace_id: WorkspaceId,
    pub workspace_name: String,       // denormalized — feed survives workspace close
    pub agent_label: Option<String>,  // denormalized — feed survives surface close
    pub kind: EventKind,
    pub ts: u64,                      // unix epoch millis
    pub read: bool,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "target", content = "value", rename_all = "snake_case")]
pub enum MarkReadTarget {
    All,
    Ids(Vec<u64>),
    Surface(SurfaceId),
}
```

Additions to the existing `Cmd` enum (`message.rs`):
- `EventLog { #[serde(default)] limit: Option<u32> }` → response data `{ "events": [EventRecord, ...], "unread": u32 }`, most-recent-first.
- `MarkRead { target: MarkReadTarget }` → `ok`.

Additions to the existing `Evt` enum:
- `Event { record: EventRecord }` — pushed when a new loggable event is recorded.
- `EventsRead { ids: Vec<u64> }` — pushed when records transition to read (carries the affected ids, including the full set for "mark all").

### `spaceshd/src/event_log.rs` (new — in-memory model)
```rust
pub struct EventLog {
    records: VecDeque<EventRecord>,  // cap 1000, oldest at front
    next_id: u64,
    cap: usize,
}
```
Methods (pure, unit-testable; no I/O):
- `record(&mut self, surface_id, workspace_id, workspace_name, agent_label, kind, ts) -> EventRecord` — assigns `next_id`, pushes back, evicts front if over `cap`, returns the record (clone) for broadcast.
- `mark_read(&mut self, target: &MarkReadTarget) -> Vec<u64>` — flips matching records to `read=true`, returns the ids that actually changed (empty if none).
- `unread_count(&self) -> u32`.
- `recent(&self, limit: Option<u32>) -> Vec<EventRecord>` — most-recent-first, capped.
- `snapshot(&self) -> EventLogState` / `restore(state)` — for persistence (carries `records` + `next_id`).

### `spaceshd/src/event_store.rs` (new — persistence)
Mirrors `state_store.rs`:
```rust
#[derive(Default, Serialize, Deserialize)]
pub struct EventLogState {
    pub version: u32,
    pub next_id: u64,
    pub records: Vec<EventRecord>,
}

pub trait EventStore: Send + Sync {
    fn load(&self) -> Result<EventLogState>;
    fn save(&self, state: &EventLogState) -> Result<()>;
}

pub struct JsonEventStore { path: PathBuf }  // ~/.spacesh/events.json
```
Same durability mechanics as `JsonStateStore`: write to `.tmp`, `fsync`, atomic `rename`; on parse failure back up to `events.corrupt-<ts>` and start empty. A dedicated debounced persister task is spawned with the same shape as `persist::spawn` (it may be generalized or duplicated — the plan decides; duplication is acceptable to keep the two stores independent).

### GUI
- **`socketBridge.ts`**: `getEventLog(limit?)` → `{ events, unread }`; `markEventsRead(target)`; new `DaemonEvt` variants `event` and `events_read` wired through `onDaemonEvent`.
- **`App.tsx`**: feed is seeded from `getEventLog()` on connect and on reconnect; live-appended on `event`; read flags updated on `events_read`. `maybeNotify` fires on incoming `event`. Unread count derived from the mirror and passed to `TopBar`.
- **`EventCenter.tsx`**: tabs filter the real data — `Unread` = `!read`, `Errors` = `kind === "error"`. "Mark all read" → `markEventsRead({target:"all"})`. Entry click → `focusSurface(id)` (already marks the surface read server-side) **and** `markEventsRead({target:"ids", value:[entryId]})`.
- **`TopBar.tsx`**: `bell` shows a numeric badge = unread count (hidden at 0).

---

## Data flow

1. The status engine emits `Evt::State` / `Evt::Exit` exactly as today (unchanged).
2. A **recorder hook** in the server's event-dispatch path inspects each outgoing event: for `State` with kind ∈ {done, wait, error} or any `Exit`, it builds an `EventRecord` via `EventLog.record(...)` (denormalizing the surface's `agent_label` and the workspace's `name` from the registry at record time), calls `persister.mark_dirty(snapshot)`, and broadcasts `Evt::Event { record }`.
3. The `Focus` command handler additionally calls `EventLog.mark_read(Surface(id))`; if any ids changed it broadcasts `Evt::EventsRead { ids }` and marks dirty.
4. `MarkRead { target }` → `mark_read(target)` → broadcast `EventsRead` + mark dirty (no-op broadcast skipped when nothing changed).
5. On GUI connect: one `EventLog` request seeds the feed; thereafter live `Event` / `EventsRead` keep it in sync.
6. On daemon boot: `EventStore.load()` restores `records` + `next_id` into the `EventLog`.

`unread` is derivable from the records; the `EventLog` response includes it as a convenience, and the GUI also recomputes it from its mirror so the badge stays correct as live events arrive.

---

## Error handling & edge cases

- **Corrupt `events.json`** → backed up to `events.corrupt-<ts>`, log starts empty (mirrors `state_store`).
- **Closed surface/workspace** → records persist with their denormalized labels and remain displayable. Clicking focus on a dead surface returns a daemon error; GUI ignores it (the `markEventsRead` for that id still applies).
- **Monotonic ids** → `next_id` is persisted with the log, so ids are never reused after a restart; `EventsRead` references stay valid.
- **Ring overflow** → the oldest record is evicted on push; if it was unread the badge decreases naturally (GUI recomputes from its mirror after the eviction is reflected — evicted records simply drop off the next full `EventLog` fetch; live mirrors prune to the cap).
- **No-op mark-read** → when `mark_read` changes nothing, no event is broadcast and no dirty mark is made.

---

## Testing strategy

**Unit (`spacesh-proto`)**
- serde round-trip for `EventRecord`, `EventKind`, `MarkReadTarget`, and the new `Cmd`/`Evt` variants (tag/format assertions like the existing tests).

**Unit (`spaceshd/event_log.rs`)**
- push beyond cap evicts oldest, length stays at cap;
- `mark_read` by `Surface`, by `Ids`, by `All` flips exactly the right records and returns changed ids;
- `unread_count` correctness across record/mark cycles;
- id monotonicity preserved across `snapshot` → `restore`.

**Unit (`spaceshd/event_store.rs`)**
- save→load round-trip;
- corrupt file is backed up and load returns empty;
- atomic write leaves no partial file.

**Integration (`spaceshd`)**
- a `done`/`error`/`exit` event produces an `Evt::Event` broadcast and a persisted record;
- `Focus` produces `Evt::EventsRead` for that surface's events;
- cold restart (drop + reload via `EventStore`) restores the log and `next_id`.

**Manual** — add a scenario to `DOCS/RUNNING.md`: drive a panel to done/error, see the entry; restart the GUI → feed intact; cold-restart the daemon → feed still intact; click an entry / focus a panel → unread badge drops.

---

## Out of scope (SP2)

- External notification delivery (Telegram/MAX) — that is SP5.
- The top-bar `search` / `settings` / account menu — search is SP3; settings/account are out of v1 per `CLAUDE.md`.
- Configurable event-kind filtering — fixed set (done/wait/error/exit) for SP2.
- Per-workspace retention — global 1000 cap only.

## Performance

Event recording is O(1) (deque push + optional pop). Persistence is debounced exactly like the structure snapshot. Events are low-frequency relative to the PTY output path, so this never touches the keypress/render budgets.
