04
Product
16
Backend
09
Auth
12
iOS
07
Infra
02
Real-Time

Use git-like modification tree for versioning

ADR-0046 ACCEPTED · 2026-03-26
Use Git-Like Modification Tree for Trip Versioning

Context

ADR-0033 established that trips are derived state, generated by replaying modifications (event sourcing). The modification history already supports going back in time — you can always restore to an earlier point and build forward. But that's linear: you can only be in one place on the timeline.

Trip planning is inherently exploratory. You have a trip you're happy with, and you want to try a fundamentally different approach — not just tweak a detail, but rearrange the whole itinerary. "What if we drove the coast instead of flying to Florence?" is a different shape of trip, not an incremental edit. You want to explore that idea fully, compare it with what you had, and maybe show both to your travel partner.

Plain modification history can't do this. If you rewind to before Florence and start adding coastal towns, you've overwritten your Florence branch. You can't switch back and forth between "the Italy trip with Florence" and "the Italy trip along the coast" — you have to pick one timeline.

Plans (versions) solve this by letting multiple trip ideas coexist. Each plan is a complete trip you can switch to instantly. The original is always preserved. You can compare, iterate on each independently, and choose later. It's the difference between one notebook with an eraser and having multiple notebooks open side by side.

Design principles

  • Everything is always saved. No unsaved state, no draft mode, no commit step. Every modification persists immediately.
  • Plans are named positions, not copies. They share history up to the fork point. Creating a plan is cheap (one row), not expensive (duplicating data).
  • "New plan" is forward-looking. You're not "saving what you have" — you're "starting an exploration." The existing trip is automatically preserved as "Original."
  • Progressive disclosure. Most users never create a second plan. The plan UI is hidden until they explicitly create one. No cognitive overhead for the common case.

Open UX questions

The distinction between "modify the current plan" and "create a new plan" is subtle. A user typing "skip Florence" into the AI input doesn't necessarily know whether they want to permanently change their trip or explore an alternative. Currently, the app asks them to decide upfront via a separate "New Plan" action. A future improvement might detect intent from context — if the change is large or reversible, suggest creating a plan; if it's small, just apply it. For now, the explicit action is the right starting point.

Requirements

  • Supports branching: multiple alternative modification histories from a shared root
  • Makes switching between alternatives instant and cheap
  • Never loses data: all branches remain reachable
  • Avoids "unsaved changes" anxiety: every state is always persisted
  • Keeps state reconstruction deterministic and simple

Rejected alternatives

Copy-on-branch (full duplication): Copy the entire modification list when branching. Simple but wasteful — shared history is duplicated, and keeping branches in sync up to the fork point is fragile.

Forked-at timestamps: Mark modifications as "forked" when superseded (the original #155 design). Required careful bookkeeping — a forked_at timestamp plus partial indexes, and complex "fork-on-write" logic that had to mark existing modifications as inactive before inserting new ones. Fragile state transitions.

Detached "what-if" mode: A separate backend mode (is_what_if flag on the trip) where modifications accumulated in a temporary space, requiring an explicit exit action (keep/discard/save-as-new). This created the exact anxiety the feature was supposed to eliminate — users worried about unsaved work, didn't know when to exit, and feared losing changes if the app backgrounded. Implemented in #155, removed in #180.

Decision

The git object model, adapted for trips

Modifications form a tree via a parent_id pointer (adjacency list). The trip's head_modification_id tracks the current tip. Named versions are pointers to specific modifications — like git branches pointing to commits.

Data structures

┌─────────────────────────────────┐
│ trips                           │
│   head_modification_id  ───────────► current tip modification
│   active_version_id     ───────────► which version is "selected"
└─────────────────────────────────┘

┌─────────────────────────────────┐
│ trip_modifications              │
│   id                            │
│   parent_id  ──────────────────────► previous modification (NULL for root)
│   user_input                    │
│   effects    (JSON)             │
│   applied_at                    │
└─────────────────────────────────┘

┌─────────────────────────────────┐
│ trip_versions                   │
│   id                            │
│   modification_id  ────────────────► the modification this version points to
│   name                          │
│   description                   │
└─────────────────────────────────┘

State reconstruction

To build the current trip state, walk from HEAD to root via parent_id, reverse to chronological order, then replay all effects forward:

HEAD ──parent_id──► M4 ──parent_id──► M2 ──parent_id──► M1 ──parent_id──► NULL

Reverse to: [M1, M2, M4, HEAD]

Trip = apply(apply(apply(apply(empty, M1.effects), M2.effects), M4.effects), HEAD.effects)

This is implemented as a recursive CTE:

WITH RECURSIVE chain AS (
    SELECT id, parent_id, ...
    FROM trip_modifications
    WHERE id = (SELECT head_modification_id FROM trips WHERE id = $1)
    UNION ALL
    SELECT m.id, m.parent_id, ...
    FROM trip_modifications m
    JOIN chain c ON m.id = c.parent_id
)
SELECT ... FROM chain ORDER BY applied_at ASC

Branching (fork-on-write)

When switching to an older version and making a new modification, the new modification's parent_id points to the old version's modification. This naturally creates a branch in the tree — no special forking logic needed.

M1 ◄── M2 ◄── M3 ◄── M4          "Original" version points here
                 ▲
                 └── M5 ◄── M6    "Skip Florence" version points here

Both branches share M1–M3. Each version's pointer determines which branch fetch_modifications walks. Switching versions just moves head_modification_id and active_version_id — a single UPDATE, no data is copied or moved.

Version semantics (save-slot model)

Versions are auto-advancing save slots, not frozen snapshots:

  • First modification on a trip auto-creates an "Original" version
  • Every subsequent modification advances the active version's modification_id to the new HEAD
  • Creating a new version forks from the current HEAD position
  • Switching versions moves HEAD to that version's modification — instant, no prompts
  • All versions are equal peers — no "main" version, no privileged branch

"What if..." exploration is a client-side state only. The backend has no concept of exploration mode — it just receives modifications and advances versions. If exploration produces a modification, the client creates a new version first, then the modification auto-advances that version.

The git analogy

Git Trip versions
Commit Modification
parent pointer parent_id
Branch (ref pointing to a commit) Version (modification_id pointing to a modification)
HEAD (which branch is checked out) active_version_id (which version is selected)
git checkout branch switch_version (moves HEAD to version's modification)
Commit on a branch (advances ref) New modification (advances active version's pointer)
Detached HEAD + commit = new branch Modify from old version = fork-on-write

Consequences

Positive

  • No data loss by design — modifications are append-only; branches are never deleted, only potentially unreachable if their version is deleted
  • Cheap branching — creating a version is an INSERT; switching is an UPDATE; no data duplication
  • No "unsaved changes" — every modification immediately persists and the active version tracks it
  • Simple state reconstruction — one recursive CTE, deterministic replay, same as the flat list but starting from a different HEAD
  • Familiar mental model — engineers and users who know git understand branches and switching instantly

Negative

  • Recursive CTE cost — walking the parent chain is O(n) in chain length per fetch; acceptable for trips (typically <100 modifications) but would need caching or materialized paths for much longer chains
  • Orphaned modifications — deleting a version doesn't delete its modifications; the tree accumulates dead branches over time (acceptable for trip-scale data; could add periodic cleanup if needed)
  • No merge — there's no mechanism to combine two branches; versions are independent alternatives, not collaborative branches (this matches the product intent — versions represent different trip ideas, not parallel work to be reconciled)

Related Decisions

  • ADR-0033: Trip model as destinations with effect-based modifications (the foundation this builds on)
  • ADR-0045: Newtype IDs (ModificationId, VersionId, TripId) prevent pointer mix-ups in this tree structure

References

  • Issue #155: Original implementation (snapshots with fork-on-write)
  • Issue #180: Evolution to save-slot version model
  • PR #181: Implementation (rename snapshot→version, remove what-if mode)