# segmented history

a room's history is sections of events with durable gaps between them. the sdk
event cache keeps the sections, gap identity, boundaries and each gap's cursor,
encrypted at rest, across room close and restart. macltok shows gaps inline and
fills them on request.

## opening a room

opening loads retained history and its gaps, then starts a recent load in the
background, so opening never waits on the network. the recent load fetches up
to four pages of 50 events from a fresh server position. if it reaches the
newest retained event the sections join. if it doesn't, a gap is left between
the fresh window and what was retained.

`Event::RecentHistoryChanged` reports it, one event per edge.

- `Loading` as it starts
- `Loaded`, `Cancelled` or `Failed(message)` as it settles
- `Idle` when the room closes, whatever was happening

`Failed` stays until `HistoryRepairCommand::RetryRecent` or a reopen. a failed
load falls back to paginating `EngineConfig::initial_history_limit` events, so
the room isn't left empty.

## filling a gap

`HistoryRepairCommand::FillGap { room_id, gap_id }` walks one gap backwards, up
to four pages of 50, each page committing its events and the gap's new cursor
together. `Event::HistoryRepairChanged` carries a `HistoryRepairReport`, once
with phase `Bridging` as the fill starts and once more as it settles.

- `Ready`, continuity `Verified`. the older section was reached and the gap is gone
- `Paused` with a stop. `Budget` after four pages, `CursorStalled` when the
  server repeats a cursor, `HistoryUnavailable` (continuity `AccessibleStart`)
  when accessible history ends first, `Cancelled`
- `Paused` with a `failure`, and the gap row goes `Failed`

a gap that is already joined or unavailable answers at once and counts no page.

## one operation per room

a room runs one history operation at a time. `RetryRecent` joins a recent load
that is already running. everything else that overlaps, a fill during a recent
load, a recent load during a fill, a second fill, answers `RoomBusy`.

`HistoryRepairCommand::Cancel` stops whatever is running. its own answer is
`Ok`, the cancelled command answers `Interrupted`, and the published outcome is
`Cancelled`, never a failure. pages already committed stay.

## the timeline

gaps appear in `TimelineState.items` as `TimelineItem::HistoryGap`, between the
sections they separate. a row keeps its id while its lifecycle changes, and is
removed once the gap joins. joined gaps never appear.

history lands as `TimelineChanged` diffs, not a `TimelineReset`. the sdk
currently replays the whole live timeline when it applies committed history,
so those diffs can span the timeline until it gets a non-replacing fence.

a command's answer comes after its outcome is published, and after the mirror
has applied it when one is attached.

## known holes

a limited sync for a room with no events in memory records a plain gap instead
of a history gap (review 2026-10-25 S1), so an interval can still render as
continuous. both `prev_batch` drops in the sdk's `handle_sync` remain.

## wire shapes

serde enums are externally tagged.

```json
{ "HistoryRepair": { "FillGap": { "room_id": "!room:example.org", "gap_id": "opaque" } } }
{ "HistoryRepair": { "RetryRecent": { "room_id": "!room:example.org" } } }
{ "HistoryRepair": { "Cancel": { "room_id": "!room:example.org" } } }
```

a gap row.

```json
{
  "HistoryGap": {
    "id": "opaque",
    "older_boundary": "$older:example.org",
    "newer_boundary": "$newer:example.org",
    "lifecycle": "Idle",
    "failure": null
  }
}
```

lifecycle is `Idle`, `Paused`, `Failed` or `Unavailable`. `failure` is set with
`Failed`.

a settled fill.

```json
{
  "room_id": "!room:example.org",
  "gap_id": "opaque",
  "pages": 2,
  "events": 63,
  "phase": "Ready",
  "continuity": "Verified",
  "stop": null,
  "failure": null
}
```

the mirror keeps `State.recent_history`, a room without an entry being idle,
and `State.repairs`, the last fill report per room. closing a room drops both.
