0004: Timeline branching for save/reload consistency¶
Context¶
Skyrim players save, die, reload, and roll back constantly. Chronicle's
event-sourced log (0002-event-sourcing.md) is append-only, but a naive
single linear log breaks the moment a player reloads a save from before
some event was recorded: the log would contain events (an assassination,
its succession contest, every derived rumor) that never happened in the
timeline the player is now actually in.
Research (docs/research/05-save-reload-sync-protocol.md,
docs/research/06-save-reload-timeline-sync.md) surveyed how Mantella,
CHIM/HerikaServer, and SkyrimNet handle this today and found none of them
solve it cleanly: Mantella ignores it (memory drift across reloads and
same-named characters), CHIM prunes globally by comparing Skyrim's internal
clock (gamets) — which is reseeded identically on every new save and
can't disambiguate two forks sharing a clock value — and SkyrimNet's
in-process design has no cross-process fork mechanism at all (or, per a
less-confirmed source, an unpublished internal cleanup protocol; see
open-questions.md).
Decision¶
Skyrim's save topology is a directed acyclic graph, not a line. Model it as one:
- Every event carries a branch key:
(save_uuid, generation). save_uuidis generated once per playthrough (new game) and persists across saves within that character's timeline;generationincrements each time the player reloads a save that is behind the branch's current head — i.e., each fork.- Never roll back. Loading an earlier save never deletes or rewrites
events. It forks: a new
generationis opened with the loaded save's state as its starting point, and the old suffix becomes an abandoned branch rather than being erased. - State derivation is a path traversal: to compute current world state,
fold over events from the root along the lineage to the active
(save_uuid, generation)head — not "all events ever recorded." - Every event carries both time coordinates — bitemporal, mandatory,
never
NULL:gamets(Skyrim's in-game clock at the moment of the event — valid time, when the fact is true in the modeled reality) andwall_ts(real-world time the event was durably stored — transaction time). Queries need both: "Lydia's beliefs as of branch B, game-time T" is a valid-time query; "what did the service know when the player saved S17" is a transaction-time query. This is not optional metadata — CHIM's HerikaServer had both columns and still broke (PR #572,docs/research/09-save-sync-forensics.md) because validity was a bare nullable field rather than a mandatory, always-populated one: manually- edited rows leftgametsunstamped were indistinguishable from "never anchored" and got wiped by the next rollback sweep. Every write path — including future admin/dashboard/debug tools — must stamp both fields or be rejected, not silently defaulted. - Garbage collection is reachability-based, never timestamp-based, with
a grace period and soft-delete first. A branch is live if and only if
some surviving
.ess/co-save on disk references its(save_uuid, generation)— mirroring both the "orphaned co-save" test community tools (SSE Engine Fixes) already use, and git's own model (objects collectable only when unreachable from any ref; the reflog keeps abandoned tips reachable for a grace period beforegit gcprunes them). Abandoned branches are tombstoned (abandoned_at) first, kept through a retention window so an accidental reload stays recoverable, and only hard-purged after that window, never inline at reload time. This rule exists because of a documented failure, not by analogy: SkyrimNet issue #487 (docs/research/09-...) is a 100%-reproducible bug where a GC routine hard-deleted 237 externally-created memories — no recovery path — because its liveness criterion was an internal, non-inspectable timestamp that a second write path (the dashboard/MCP API) stamped differently. Reachability-from-a-live-reference cannot fail this way; a timestamp comparison can and did. - User-curated content is a protected stream class, exempt by construction, not by heuristic. Hand-authored bios, pinned rumors, or any record a person (not the simulation) created directly must live in a stream class the fork/rollback/GC machinery never touches — mirroring SkyrimNet's own Beta20 fix ("user-curated world knowledge entries are no longer deleted when loading older saves") and CHIM's Playthrough Manager archive, both retrofits of a rule Chronicle should have from the start. "Exempt by class" means the exemption is a property of which stream the record lives in, not a special-cased conditional buried in the GC pass — the retrofit history in report 09 is evidence that heuristic exemptions get missed.
Rationale¶
- Four independent research reports converged on this design without
prompting each other (
docs/research/05-...,06-...,07-skyrimnet-substrate.md, the last of which re-derived the same DAG model — down to a concreteChronicleSync::OnGameSave/OnGameLoadC++ sketch — while researching an unrelated question, SkyrimNet's platform risk; and09-save-sync-forensics.md, which grounds the same model in actual repository history — CHIM/SkyrimNet PRs and issues — rather than architecture alone). This is the same model the broader event-sourcing community uses for branching (commit≈event, branch≈stream, reload≈ checkout-then-new-commits — see report 05 §3), and report 09 adds git's own reachability/reflog/grace-period GC model as a second, independent precedent for the same discipline. - It's strictly more capable than every existing mod's approach: it
disambiguates multiple characters/save slots (unlike Mantella's
name-keyed files), doesn't require a global clock comparison that can
collide (unlike CHIM's
gamets-only reconciliation), and doesn't require staying in-process to work (unlike SkyrimNet's architecture). - Fork-don't-rollback preserves data: nothing about a player's play session
is destroyed by a reload, which matters both for debuggability (the
dashboard's causality timeline,
docs/architecture.md) and for eventual features like "what would have happened" comparisons across branches.
Consequences¶
chronicle/events.py'sEventbase type now carriessave_uuid: strandgeneration: intfields;EventLog(or its successor) must support querying/folding along a specific branch's lineage, not just "all events." See the accompanying code change andchronicle/tests/test_events.py::test_forking_a_branch_excludes_the_abandoned_suffix.- Snapshotting per branch at each save becomes necessary once logs get long enough that replaying from the root is expensive — out of scope for the current skeleton, but the branch key is designed to make that addition non-breaking later.
- The SKSE-side half of this (generating
save_uuid, writing the co-save record, detecting forks at load time, notifying the service) is specified in0005-sync-handshake.mdand lives entirely inadapters/skyrim/—chronicle/only needs to know branch keys exist, never how they're produced. - FormIDs must never be persisted raw inside event payloads (see
docs/architecture.md's FormID rule) — a load-order change would silently corrupt every event referencing an actor/item by raw FormID, which would be indistinguishable from a genuine timeline fork bug.
Implementation-risk notes¶
See docs/decisions/open-questions.md — CHIM's fork-trigger threshold is
reconstructed, not confirmed; the save-embedded-UUID pattern has no
confirmed Skyrim precedent (nearest prior art is a different engine); the
.skse/.ess pairing is atomic by convention only. Report 09's specific
GC grace-period recommendation (≥7 days, informed by git's 2-week/30-day/
90-day precedents) is a reasonable starting default, not a verified
Skyrim-specific constant — same caveat as every other numeric threshold
in this ADR's source reports.