M3: complete non-destructive history and active-head export

The head-cursor model landed in 903fa7a and stopped there: the backend
moved the head instead of deleting turns, but a bundle still reopened at
its newest row, the browser had no way forward, and five inherited tests
still asserted the contract Undo had just stopped honouring. This is the
rest of the milestone, plus the one unsafe operation the review found.

Export now writes headDepth, and it belongs on the other side of the rule
app/bundle.py states about itself. The head depth used to be derived —
the tip of the head branch, a fact about the nodes that arrived with it —
and that was true while Undo deleted, because the newest row was the only
place a story could be read. It is a decision now: the same tree exports
identically whether the user undid three turns or none, so the file has
to say. An import that ignored it would silently Redo the story to its
newest retained turn, which is the Phase 0B export finding this milestone
exists to close. A file with no headDepth is opened at the tip, which is
not a fallback but the position such a file recorded; a file naming a
depth its own rows do not reach is refused in plan(), before a row is
written, for the reason that module gives about half-written trees.

Which branches the story has left goes into the file for the same reason.
Every row of an abandoned line arrives on an import either way, so the
disposition is the only thing telling it apart from an active one, and a
restored backup that had lost it would have nothing for the later cleanup
and recovery screens to select on. Both keys or neither: a time with no
depth cannot say what was displaced.

The browser gets a Redo button beside Undo, on Ctrl+Shift+Z, and both are
enabled from can_undo/can_redo rather than from the transcript. Neither
is derivable on the client — Undo stops at the campaign opening, which
may be off the top of the loaded window, and Redo depends on the retained
future, which the client is never sent — so the flags now ride on every
window the server hands back, including a scrolled-up page and the
response to an import. Moving the head also refreshes the state panels,
which undo never did: it has rolled the world state back since long
before M3 and the drawer kept showing the old numbers.

An in-place edit is now refused when story descends from the turn and is
not on screen. Editing rewrites one row and re-evaluates nothing, which
is what makes it a correction rather than a continuation, and that is
harmless while everything below the turn is visible — the reader can see
what their change has to agree with. It stops being harmless when the
continuation is undone, or was left behind by a divergence, because the
edit then silently changes the words an invisible stretch of story was
written from. That was the one way M3's retained history could be made to
contradict itself. Refusing is deliberately the whole of the fix: making
such an edit fork is STORY-BRANCH-SEMANTICS.md §14-15, and §15 wants the
state the edited prose implies re-evaluated, which is M5's extraction
pass. The requirement is not weakened, only deferred, and §14A now says
so.

The predicate asks one question rather than two. A descendant is
invisible either because it is past the head on this lineage or because
it is past a fork on a branch the story left, and both are "a live node,
deeper than this one, descending from it, off the path being read". A
first attempt scoped the search to branches other than the active one and
failed the divergence case, correctly: the departed branch is usually an
ancestor of the branch now being read. Only the deepest live node on each
descending branch is examined, because visibility is monotone in depth.

Five inherited tests are rewritten rather than deleted, because what they
were protecting is still worth protecting and only the mechanism changed.
The undo-state pair keeps its state assertions and swaps "the rows are
gone" for "the rows are all here and the story is read from earlier". The
memory test stops asserting that undo prunes memories and starts
asserting the property that replaced it: a memory past the head is
unreachable, still on disk, and retrievable again after Redo, with no
re-embedding. The attempt-group test still proves the group moves as one,
out of the story rather than out of the database. And the fork test
reverses: Undo used to refuse at a fork point because it deleted rows the
parent branch was also reading, and with nothing deleted there is nothing
to protect the parent from, so it now walks into the story the branch
inherits and stops at the campaign opening instead.

tests/test_head_cursor.py is the milestone's acceptance contract, named
by the items it discharges: D01-D10, E01-E04, I01-I03, I07, L01-L02, and
the invariant they all rest on — Undo deletes zero accepted turns,
asserted on row ids over the whole retained tree. E02 has both controls,
because a negative control alone would pass if memory retrieval were
simply broken. L01 records what it does not claim: the head does move by
one on a failed turn, onto the player's retained input, which is A05
rather than a gap. Two of the edit-guard tests exist to prove the guard
stays out of the way — a correction at the tip and a correction mid-story
with everything visible must both still work.

638 backend tests pass. Frontend lint is unchanged at seven pre-existing
warnings, none in the files touched; the bundle builds at 395.85 kB; the
production image builds.

Verified at runtime against the trusted-LAN Ollama over HTTPS: three
turns, two Undos, a Redo, a Retry, an Undo, a divergent continuation,
Redo correctly refused with 400, Undo back to the opening, export, a
process restart that reopened the campaign still undone, and an import
that opened at the same position with its retained future intact. The
browser click-through of that sequence has not been run — no session in
this milestone had a browser to drive — so the Redo control itself is
verified by its endpoint and its lint and build, not by a click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
This commit is contained in:
JesseMarkowitz
2026-09-03 13:54:43 -04:00
co-authored by Claude Opus 5
parent 903fa7a74f
commit 7f082b61d8
11 changed files with 1305 additions and 56 deletions
+117 -15
View File
@@ -32,8 +32,19 @@ the import recomputes them:
* `lineage` is a cache of `parent` plus `fork_depth`. Shipping it as well would
put a second source of truth for one fact into a file anyone can hand-edit,
and the two could then disagree without any read reporting it.
* The head depth is the tip of the head branch, which is a fact about the nodes
that arrived with it.
* The head depth used to be derived the same way — the tip of the head branch,
a fact about the nodes that arrived with it. M3 moved it to the other side of
the rule. Undo no longer deletes, so a story can be read at a position behind
its retained tip, and where the reader stopped is a decision nobody can
recompute from the rows: the same tree exports identically whether the user
undid three turns or none. `headDepth` is therefore written, and an import
that ignored it would silently Redo the story to its newest retained turn,
which is the Phase 0B export finding this milestone exists to close.
A bundle written before M3 has no `headDepth` key, and there is nothing lost in
that: at the time it was written the head could not sit behind the tip, so the
derived answer *was* the recorded one. Such a file imports by deriving, exactly
as it always did.
Every hand-editable coordinate is therefore checked before a row is written, in
`plan`, rather than repaired afterwards. An import that fails partway leaves an
@@ -117,6 +128,9 @@ def export(db: Session, adventure: models.Adventure) -> dict:
# belongs to is the root. `_local` places its nodes there.
"branches": [_exported_branch(b, local) for b in branches] or [_ROOT],
"headBranch": local.get(adventure.head_branch_id, 0),
# M3. Where the story is being read, which is not always where it
# ends. See the head-depth rule at the top of this module.
"headDepth": adventure.head_depth,
"memoryCursor": _exported_anchor(adventure, cursors.MEMORY, local),
"summaryCursor": _exported_anchor(adventure, cursors.SUMMARY, local),
"memories": [_exported_memory(m, local) for m in adventure.memories],
@@ -151,6 +165,15 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
# unnamed tree byte-identical to the one SP6 wrote.
if branch.name:
out["name"] = branch.name
# M3. A branch the story left behind is a decision too — the depth a
# divergent write departed at — so it goes in the file by the same rule that
# puts the fork points in it. A restored backup that could not tell abandoned
# history from active history would have lost the only thing distinguishing
# them, since every row of both arrives either way. Omitted when the branch
# is active, which keeps a file for a never-undone tree as it was.
if branch.superseded_at is not None:
out["supersededAt"] = branch.superseded_at.isoformat()
out["supersededDepth"] = branch.superseded_depth
return out
@@ -252,11 +275,16 @@ def plan(bundle: dict, version: str) -> dict:
_planned_nodes(bundle, len(branches)) if version == FORMAT
else _planned_v1_nodes(bundle)
)
head = _as_index(bundle.get("headBranch"), len(branches), default=0)
return {
"branches": branches,
"nodes": nodes,
"memories": _planned_memories(bundle, len(branches)),
"head": _as_index(bundle.get("headBranch"), len(branches), default=0),
"head": head,
# None means the file does not say, which is every version 1 bundle and
# every version 2 bundle written before M3. `_point_the_head` derives it
# then, which is what those files were written expecting.
"headDepth": _planned_head_depth(bundle, branches, nodes, head),
# Version 2 records where the derived work reached. Version 1 counted
# it, and a count cannot become a node until the nodes exist. See
# `settle`.
@@ -268,6 +296,58 @@ def plan(bundle: dict, version: str) -> dict:
}
def _derived_tip(branches: list[dict], nodes: list[dict], head: int) -> int:
"""Returns where the head branch's story ends, which is where a file that
does not state a head depth is opened.
This is the rule `tree.refresh_head` applies and the one every bundle
written before M3 was exported under: the deepest node that arrived on the
head branch, or, for a branch that carries none of its own, the fork point
it inherits its story up to.
"""
depths = [n["depth"] for n in nodes if n["branch"] == head]
if depths:
return max(depths)
fork_depth = branches[head].get("forkDepth")
return fork_depth if fork_depth is not None else lineage.NO_DEPTH
def _planned_head_depth(
bundle: dict, branches: list[dict], nodes: list[dict], head: int
) -> int | None:
"""Returns the active head depth the file states, or None if it states none.
Checked here rather than repaired later, for the reason the module docstring
gives: a coordinate that disagrees with the rows is not a value any read can
be given, and an import that discovers it afterwards has already written
half a tree.
Two bounds. A head past the retained tip is a file claiming the story is
read somewhere it does not reach — the shape a truncated or hand-edited
export takes, and the one that would silently move the reader forward.
Below, `NO_DEPTH` is the floor, because an adventure with no story at all
sits one step in front of its first node.
A head *behind* the tip is not an error. It is the whole point: it is a
story the user undid and did not redo, and it must import undone.
"""
stated = bundle.get("headDepth")
if stated is None:
return None
tip = _derived_tip(branches, nodes, head)
if not _is_int(stated) or stated < lineage.NO_DEPTH:
raise HTTPException(
400, f"The file gives the active head depth as {stated!r}."
)
if stated > tip:
raise HTTPException(
400,
f"The file reads its story at depth {stated}, but branch {head} "
f"ends at {tip}.",
)
return stated
def _planned_branches(bundle: dict) -> list[dict]:
raw = bundle.get("branches")
entries = [b for b in raw if isinstance(b, dict)] if isinstance(raw, list) else []
@@ -277,8 +357,9 @@ def _planned_branches(bundle: dict) -> list[dict]:
for i, entry in enumerate(entries):
parent = entry.get("parent")
name = _planned_branch_name(entry, i)
left = _planned_supersession(entry)
if parent is None:
specs.append(dict(_ROOT, **({"name": name} if name else {})))
specs.append(dict(_ROOT, **({"name": name} if name else {}), **left))
continue
# A branch may fork only from a branch listed before it. The export
# writes them that way, because branches are numbered in creation order
@@ -302,10 +383,27 @@ def _planned_branches(bundle: dict) -> list[dict]:
specs.append({
"parent": parent, "forkDepth": fork_depth,
**({"name": name} if name else {}),
**left,
})
return specs
def _planned_supersession(entry: dict) -> dict:
"""Returns the branch's disposition as the file gives it, or nothing.
Both keys or neither. A time with no depth cannot say what was displaced and
a depth with no time is not a record of anything having happened, so a file
carrying one of them is treated as carrying neither rather than half of a
fact — no read depends on these columns, and inventing the missing half
would be the only way to get a wrong answer out of them.
"""
at = _as_time(entry.get("supersededAt"))
depth = entry.get("supersededDepth")
if at is None or not _is_int(depth):
return {}
return {"supersededAt": at, "supersededDepth": depth}
def _planned_branch_name(entry: dict, i: int) -> str | None:
"""Returns the name a branch entry carries, or `None` if nobody named it.
@@ -479,6 +577,8 @@ def _write_branches(
lineage=[],
name=spec.get("name"),
created_at=models.utcnow(),
superseded_at=spec.get("supersededAt"),
superseded_depth=spec.get("supersededDepth"),
)
).inserted_primary_key[0]
entries = [[new_id, None]]
@@ -568,22 +668,24 @@ def _write_memories(
def _point_the_head(
adventure: models.Adventure, story: dict, ids: list[int]
) -> None:
"""Sets which branch the story is played on, and how deep it goes.
"""Sets which branch the story is played on, and where it is being read.
The branch comes from the file and the depth does not. The tip of a branch is
whatever arrived on it, and a branch with no nodes of its own sits at its
fork point, which is the last node its story contains. That node is borrowed
but it is still the tip. This is the rule `tree.refresh_head` applies, run
here before a flush.
Both come from the file now (M3). A bundle that states its head depth is
opened exactly where its owner left it, undone turns and all, and the turns
past that point arrive as the retained future they were exported as.
A file that states none is opened at the tip of its head branch — whatever
arrived on it, or, for a branch carrying no nodes of its own, the fork point
that is the last node its story contains. That is the rule
`tree.refresh_head` applies and the only answer a pre-M3 bundle can be given,
because at the time it was written the head could not be anywhere else.
"""
head = story["head"]
adventure.head_branch_id = ids[head]
depths = [n["depth"] for n in story["nodes"] if n["branch"] == head]
if depths:
adventure.head_depth = max(depths)
if story["headDepth"] is not None:
adventure.head_depth = story["headDepth"]
return
fork_depth = story["branches"][head].get("forkDepth")
adventure.head_depth = fork_depth if fork_depth is not None else lineage.NO_DEPTH
adventure.head_depth = _derived_tip(story["branches"], story["nodes"], head)
def _write_anchors(
+67
View File
@@ -228,6 +228,73 @@ def can_undo(db: Session, adventure: models.Adventure) -> bool:
return undo_target(db, adventure) is not None
def displaced_history_under(
db: Session, adventure: models.Adventure, node: models.Action
) -> bool:
"""Returns whether story the reader cannot see descends from `node`.
This is the question an in-place edit has to ask. Editing rewrites one row
and re-evaluates nothing, which is what makes it a correction rather than a
new continuation. That is harmless while everything descending from the row
is on screen: the reader can see what their correction has to stay
consistent with. It stops being harmless the moment a continuation descends
from the row and is *not* on screen, because the edit then silently changes
the words an invisible stretch of story was written from. That is the one
way M3's retained history can be made to contradict itself.
Refusing is deliberately the whole of the fix. Making such an edit fork, so
the original text and its future stay whole, is
`STORY-BRANCH-SEMANTICS.md` §14-15 — and §15 requires re-evaluating the
state the edited prose implies, which is M5's extraction pass. Neither is
started here.
The question is asked as one shape rather than two, because the two ways a
descendant becomes invisible turn out to be the same fact. An undone future
sits past the head on this very lineage; a displaced line sits past a fork
on a branch the story left. In both cases there is a live node, deeper than
this one, that descends from it and is not on the path being read — and the
departed branch is usually an *ancestor* of the branch now being read, which
is why "branches other than the active one" is the wrong set to look at.
Only the deepest live node on each descending branch is examined. Whether a
node is on the read path is monotone in depth: a branch is on the path with
a cap, and a node is visible when its depth is at or under that cap. So if
the deepest one is visible, every shallower one is too, and if it is not,
the answer is already yes.
A node that is not live has no descendants of its own — a take the story
moved past keeps a continuation only by being forked, and that fork is a
branch this loop asks about anyway — so editing one is always safe.
"""
if not node.live or node.depth is None:
return False
read = lineage.path_of(db, adventure)
branches = (
db.query(models.Branch)
.filter(models.Branch.adventure_id == adventure.id)
.all()
)
for branch in branches:
# Uncapped: the question is what this branch's story descends from, not
# how much of it the reader is currently being shown.
if not lineage.Path(lineage.entries_of(branch)).contains(node):
continue
deepest = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.branch_id == branch.id,
models.Action.live.is_(True),
models.Action.depth > node.depth,
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.first()
)
if deepest is not None and not read.contains(deepest):
return True
return False
# ------------------------------------------------------------------ writing
def move_to(db: Session, adventure: models.Adventure, depth: int) -> None:
+2 -1
View File
@@ -40,7 +40,7 @@ from . import ( # noqa: F401
from ... import limits # noqa: F401 `adventures.limits` is patched by tests.
from .crud import SNIPPET_MAX, _snippet
from .paging import ACTION_PAGE
from .takes import retry_action, undo_turn
from .takes import redo_turn, retry_action, undo_turn
from .turns import world_delta_of
__all__ = [
@@ -48,6 +48,7 @@ __all__ = [
"SNIPPET_MAX",
"_snippet",
"limits",
"redo_turn",
"retry_action",
"router",
"undo_turn",
+27 -1
View File
@@ -8,7 +8,7 @@ coordinate through `nodes.delete_turn`.
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import attempts, models, schemas, tree
from ... import attempts, head, models, schemas, tree
from ...database import get_db
from . import turns
@@ -41,6 +41,11 @@ def list_actions(
],
total=total,
has_more=has_more,
# Every page carries them, not just the newest window: the client reads
# the flags off whichever page arrived last, and scrolling up must not
# be able to grey out a Redo that is still available (M3).
can_undo=head.can_undo(db, adventure),
can_redo=head.can_redo(db, adventure),
)
@@ -55,6 +60,27 @@ def update_action(
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# An edit rewrites this row and re-evaluates nothing after it, which is what
# makes it a correction rather than a new continuation. That is safe while
# everything descending from the row is on screen, and unsafe the moment
# something descends from it that is not — an undone future, or a line a
# divergence left behind. The reader cannot see that story, so they cannot
# see what their correction has just contradicted (M3).
#
# Refusing is the whole of the fix, deliberately. Making the edit fork, so
# that the original text and its future stay whole, is
# `STORY-BRANCH-SEMANTICS.md` §14-15 — and §15 requires re-evaluating the
# state the edited prose implies, which is M5's extraction pass. Neither is
# started here. What is closed is the one case where the application could
# produce retained history that silently disagrees with itself.
if head.displaced_history_under(db, adventure, action):
raise HTTPException(
400,
"This turn has a later story that is not on screen — undone, or "
"left behind by a new continuation. Editing it here would change "
"the words that story was written from. Redo to bring it back "
"first, or play the turn again to start a new line from here.",
)
# One row holds one text. Nothing mirrors it now, so nothing else has to be
# updated. The edit used to have to be written into the live variant entry
# as well, or paging away and back reverted it.
+9 -2
View File
@@ -7,7 +7,7 @@ only check ownership and hand the work over.
from fastapi import Body, Depends, Request
from sqlalchemy.orm import Session
from ... import bundle, limits, models, schemas
from ... import bundle, head, limits, models, schemas
from ...database import get_db
from .deps import CurrentUser, current_adventure, router
@@ -62,7 +62,14 @@ def import_adventure(
db.commit()
db.refresh(adventure)
# A campaign exported while undone imports undone (M3), so the history
# controls have to be right on the response that opens it — otherwise the
# first thing the reader sees about a story with a retained future is a
# greyed-out Redo.
out = schemas.AdventureOut.model_validate(adventure)
out.can_undo = head.can_undo(db, adventure)
out.can_redo = head.can_redo(db, adventure)
# This is not a funnel step. A returning player imports a bundle, so it
# says nothing about how far a first-time visitor got. It is counted anyway,
# because it is the clearest evidence that anyone uses the export format.
return adventure
return out