Skip to content

ADR-0018a: Session forks, branches, and read-only replay

  • Status: Accepted
  • Date: 2026-04-14
  • Amends: ADR-0018 (§Phase 4 — Session persistence to KG)
  • Target milestone: M9 Phase 4

Context

ADR-0018 Phase 4 landed Session.persist / load / list / delete (commit 0a3504f). The design note flagged forks / branches / replay as the next open tension: an agent run that wants to explore two different continuations from the same prompt needs branching primitives that don't cost O(history-size) per fork.

The ADR-0018 sketch mentioned Session.replay(graph_iri) as a read-only reconstruction tool but left forking undefined. This amendment closes that gap before any external app locks in a shape.

Decision

Three additions — two schema fields and one branch discriminator — together with Session.fork / Session.branches / Session.replay:

  1. _Session.parent_session_id: str | None — session id this was forked from. None for root sessions.
  2. _Session.forked_at_index: int | None — the parent turn index at which the fork happened. None for roots. Inclusive-exclusive rule: the fork owns turns at indices >= forked_at_index; indices < forked_at_index belong to the parent.
  3. _SessionTurn.branch: str — defaults to "main". Forks write their divergent turns with a unique branch label (fork-<uuid8>), so parent and child rows on the same (session_id, index) pair are disjoint by construction.

Share, don't copy

The shared prefix is NOT copied to the fork. The fork's persisted rows are exactly its divergent tail; load() walks parent_session_id back up the chain, collects the parent's main turns 0..forked_at_index-1, then stitches its own branch turns on top. Three reasons for share-over-copy:

  • Cost. A 5k-turn session forked 10 times costs 5k × 10 = 50k rows under copy semantics, versus 10 rows + 5k parent rows under share. Trails is KG-backed; write amplification shows up directly in store size, backup time, and SPARQL latency.
  • Immutability honesty. The parent's prefix is the parent's history. Copying and mutating it independently invents a second truth the provenance layer (ADR-0009) cannot reconcile.
  • Matches git's mental model. Branches reference a common ancestor; they do not duplicate it. Users already understand this shape.

The trade-off: deleting the parent breaks the child. We surface this with a clear TrailsError on load rather than silently losing the prefix; the ops remediation is either re-root the fork (future enhancement: Session.detach()) or restore the parent.

Fork depth guard

MAX_FORK_DEPTH = 32. Load walks the chain recursively and raises if it exceeds the guard — cheap insurance against cycles (accidental via bad migration) or pathological nesting (a bot forks every turn for a week). Thirty-two levels covers every realistic interactive use; bulk exploration workflows should flatten instead of nest.

Replay vs load

replay(ctx, up_to_index=...) returns a list of plain dicts (role, content, index, branch). It does not mutate the in-memory Session, does not re-emit PROV, and does not hydrate turns into a TokenWindow. load() reconstructs an editable Session; replay is for audit, diff, and test assertions.

Consequences

  • Backwards compatible. Pre-fork _Session records load with parent_session_id = None, forked_at_index = None. Root sessions write branch = "main" explicitly.
  • Per-turn triple count grows by 1 (new branch predicate).
  • Forks pay O(divergent-tail) writes, not O(history). Loading a fork pays O(depth × avg-prefix) reads, bounded by MAX_FORK_DEPTH.

Open questions (Phase 5)

  • Orphan cleanup. When a parent is deleted, its children become unloadable. Should Session.delete cascade? Warn? Refuse if children exist? Bias today is refuse-if-children, but needs a dedicated UX pass alongside a Session.detach() primitive that materialises the prefix into the fork before severing the link.
  • Principal inheritance. Forks currently inherit the parent's principal via the in-memory constructor, but a fork created by an agent on behalf of a different user may legitimately want to re-set it. Explicit fork(..., principal=...) override is the likely Phase 5 shape; today the caller can assign child.principal before persist.
  • Cycle detection beyond depth. Depth catches pathological chains but a hand-crafted cycle (A → B → A via store manipulation) would loop until the guard fires. A set-based visited check inside _assemble_turns is the obvious fix; deferred because the depth guard already bounds worst-case work.