Lesson 23 — Version history: what changed, and going back
Goal: read a fn's version timeline across branches, and put an earlier version back — without leaving the editor, and without losing the version you replaced.
Concepts: identity plane vs version plane, :fn-version row,
⌛ history popover, restore, switch to branch, append-only
history.
Prefer to be shown? This lesson exists as a guided in-editor tour: open the demo with the tour running (no sign-up), or pick “Interactive tutorial” in the editor's account menu.
Every edit writes a row
Lesson 20 introduced branches; this is the other half of the same machinery. A fn has two planes:
- the identity plane — one row, created once: the fn's
id, itsparent-ids, its namespace. This is what other fns point at, and it is the same row on every branch. - the version plane — one row per edit, per branch, holding the
fn's own top-level fields:
name,description,return-type,constraint,expects-effects, and thedeleted-attombstone. This is what a read RESOLVES for the branch you are on.
So editing a description does not overwrite anything. It appends a
:fn-version row, and the branch's reads start resolving to it. The
old row is still there — which is what makes the rest of this lesson
possible.
The ⌛ popover
Open any fn's row-actions (⋯) and click ⌛. The popover lists
every version row for that fn, newest first, across every branch —
not just the one you are on:
main 2026-08-22 09:48 restore second
main 2026-08-22 09:48 restore first
feat-x 2026-08-21 17:02 switch restore
Each row carries:
- the branch it was written on, so a fn you edited on
feat-xshows both timelines in one list; - restore — write this version's fn-level fields onto the branch you are currently on;
- switch — move the editor to that row's branch (only on rows whose branch is not the current one).
Click a row to expand it: the executions recorded against that exact version load underneath (lesson 12's history, sliced by version rather than by fn). That is how you answer "did this break when I renamed it / changed its declared type?" — the runs sit on either side of the edit.
One caveat worth carrying: only edits to the fn's OWN fields cut a new
:fn-version. Rebinding a slot writes a :binding-version and leaves
the fn's version id unchanged, so binding-only edits all land in the
same bucket here. The timeline is per-fn-row, not per-behaviour —
VERSIONING.md § :fn-version ≠ "functional behaviour"
is explicit about the trade.
Restore is an edit, not a rewind
Click restore on an older row. The confirm names the branch and the timestamp you are restoring from, and says what will happen:
This writes a new version row with the historic fn-level fields (description, return-type, constraint, …) on the current branch.
Read that literally. Restore does not delete the versions after the one you picked, and it does not move a pointer backwards. It appends a new version carrying the old values. Right after restoring "first" over "second", the timeline reads:
main 09:48 first ← the restore
main 09:48 second
main 09:48 first
main 09:48 (created)
History stays append-only, so a restore is itself undoable — restore the row you were on before, and you are back. There is no state in which you have lost work by pressing this button.
What restore does NOT touch
Fn-level fields only — the ones listed above. Bindings are not restored. A binding is its own entity with its own version rows (lesson 03), so:
- restoring a fn after you rebound one of its slots leaves the new binding in place;
- a value you want back comes from that binding's own history, edited the same way you edited it in the first place.
This is a deliberate line, not an omission: bindings are what a fn means, and silently reverting them from a fn-level action would undo edits the reader never named. See VERSIONING.md § Subtleties.
Deletion is a version too
Deleting a fn writes a tombstone version — deleted-at set — rather
than removing rows. That is why lesson 01's cleanup says "deletes are
soft, nothing is lost for good", and why a deleted fn's name is free
again immediately (the name-collision check reads the resolved view,
not the history).
It also means the ⌛ popover is where a deleted fn's past still lives, for as long as you can reach its id.
Try it
- Pick any fn-def of yours —
:greetfrom lesson 01 does — and edit its description twice (⋯ →i), so the timeline has something to show. - Open ⋯ → ⌛. Three rows: the two edits and the create.
- Click the OLDEST row to expand it. If that version ever ran, its executions are listed; a version that never ran says so.
- Click restore on the middle row and confirm. The Inspector's description changes back — and a FOURTH row appears at the top of the popover. Nothing was removed.
- Now bind a value on the fn (lesson 03), restore an older version again, and note the binding is untouched. Fn-level fields and binding values have separate histories.
Where this shows up next
- Branches (Lesson 20) — the same version rows, read through a
different branch chain. A merge picks version rows; a
:branch-local?fn's rows deliberately do not travel. - Debugging (lesson 15) — a trace names the version it ran, so a failed run points at the exact row in this popover.