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:
_Session.parent_session_id: str | None— session id this was forked from.Nonefor root sessions._Session.forked_at_index: int | None— the parent turn index at which the fork happened.Nonefor roots. Inclusive-exclusive rule: the fork owns turns at indices>= forked_at_index; indices< forked_at_indexbelong to the parent._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
_Sessionrecords load withparent_session_id = None,forked_at_index = None. Root sessions writebranch = "main"explicitly. - Per-turn triple count grows by 1 (new
branchpredicate). - 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.deletecascade? Warn? Refuse if children exist? Bias today is refuse-if-children, but needs a dedicated UX pass alongside aSession.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 assignchild.principalbeforepersist. - 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_turnsis the obvious fix; deferred because the depth guard already bounds worst-case work.