From 042b2ce4d0c7623fab397f16cc4ff817077de953 Mon Sep 17 00:00:00 2001
From: Joseph Ferano
Date: Sun, 13 Sep 2026 09:14:46 +0700
Subject: [PATCH] A restart is not a transaction, and nothing rolls back
If a frame mutates a global and then signals, taking a retry re-runs the
mutation. Control resumes at the restart-case and runs forward; nothing is
undone. Common Lisp has the same property and offers no help either, so this
is written down rather than fixed.
The discipline is that the author chooses where the retry boundary is: a
restart-case above the mutations re-runs them, one below re-runs only what
follows. Put the restart before anything mutates, make the retried section
idempotent, or snapshot what will be re-applied.
It matters more here than in most Lisps because the intended use is a game
loop, and a bad index signalling BoundsError rather than ending the process
made abandoning and retrying a frame an ordinary thing to do.
conditions.org and web/index.html already carried the mechanical half as a
one-line gotcha; those are rewritten in place rather than gaining a second
bullet beside them. spec-conditions.md takes it in section 5, which already
enumerates what a transfer does and does not do. No numbered case changed
meaning.
---
BUILT.md | 24 ++++++++++++++++++++++++
NEXT.md | 15 ---------------
conditions.org | 19 ++++++++++++++++---
spec-conditions.md | 22 ++++++++++++++++++++++
web/index.html | 17 ++++++++++++++---
5 files changed, 76 insertions(+), 21 deletions(-)
diff --git a/BUILT.md b/BUILT.md
index 8b7cad6..0ecf9e3 100644
--- a/BUILT.md
+++ b/BUILT.md
@@ -4445,3 +4445,27 @@ everything else there is arithmetic over a container header.
failure — the region a container lived in was released, and there is no frame to go back to that would not read freed
memory — and the map path was left alone rather than converted half-way. Written down here so it is a known edge
rather than a discovery.
+
+## A restart is not a transaction
+
+Written down in three places rather than fixed, because it is a property and not a defect. If a frame mutates a global
+and then signals, taking a `retry` **re-runs the mutation**. Control resumes at the `restart-case` and runs forward
+from there; nothing is undone. Common Lisp has exactly this property and offers no help either — rollback would mean
+journalling every store, which is a different language.
+
+The discipline is that **the author chooses where the retry boundary is**. A `restart-case` at the top of a frame
+re-runs everything including mutations already applied; one placed after the mutations re-runs only what follows them.
+So: put the restart before anything mutates, make the retried section idempotent, or snapshot what will be re-applied.
+§3 of `spec-conditions.md` places a restart *syntactically* — every clause body and the body share a type — and
+nothing places it semantically.
+
+It matters more here than in most Lisps because the intended use is a **game loop**, where the plan is to skip a frame
+and carry on rather than die. That stopped being hypothetical when a failed bounds check began signalling
+`BoundsError` instead of ending the process (see "An index out of range is a condition"): a frame can now be abandoned
+and retried, which is exactly the case a non-idempotent mutation spoils.
+
+Both `conditions.org` and `web/index.html` already carried the mechanical half — "a restart re-runs whatever sits
+between it and the target" — as a one-line gotcha. Those were rewritten in place into the full statement rather than
+having a second bullet added beside them, and the same reasoning went into `spec-conditions.md` §5, which is the
+section that already enumerates what a transfer does and does not do: it runs `defer`s, it skips `errdefer`s, and it
+does not undo. No numbered case changed meaning, so the freeze holds.
diff --git a/NEXT.md b/NEXT.md
index d70afce..4b56725 100644
--- a/NEXT.md
+++ b/NEXT.md
@@ -5,21 +5,6 @@ that is what makes it checkable against the header — and the layer is where a
already exist by hand in `vendor/raylib/raylib.flan` and are the shape to copy: `collision-point-poly?` takes a slice
and `collision-lines` answers with an `Option`, each wrapping a `-raw` binding of the same name.
-## Queued: a restart is not a transaction, and the docs must say so
-
-Raised by the author, and it is a real sharp edge rather than a gap. **If a frame mutates a global and then signals,
-taking a `retry` re-runs the mutation.** Nothing rolls back. Common Lisp has exactly this property and offers no help
-either — restarts are explicitly not transactional.
-
-The discipline is that **the author chooses where the retry boundary is**: a `restart-case` at the top of a frame
-re-runs everything including mutations already applied; one placed after the mutations re-runs only what follows. So
-either put the restart before anything mutates, make the retried section idempotent, or snapshot what will be
-re-applied.
-
-This matters more here than in most Lisps because the intended use is a **game loop**, where the author's plan is to
-skip a frame and carry on rather than die — exactly the case where a non-idempotent mutation bites. Write it into
-`conditions.org` and `spec-conditions.md`'s prose, and into `web/index.html` beside the restart documentation.
-
## Queued: a dev-build allocation registry — address to type
Raised in conversation and wanted. **What it is:** in a dev build only, every allocation records what type it was
diff --git a/conditions.org b/conditions.org
index 23ce07e..b0fa0a2 100644
--- a/conditions.org
+++ b/conditions.org
@@ -81,9 +81,22 @@ the agent's socket instead.
- *A handler closes over nothing.* It is lifted into its own function.
Accumulate into a global, or put the value on the condition.
-- *A restart re-runs whatever sits between it and the target.* Control resumes
- at the ~restart-case~, so a ~retry~ repeats side effects after it. Put the
- ~restart-case~ where re-entry is safe.
+- *A restart is not a transaction.* Control resumes at the ~restart-case~ and
+ runs forward from there, so a ~retry~ repeats every side effect between it
+ and the target. Nothing rolls back: a global the frame already set stays set,
+ and gets set again. Common Lisp has exactly this property and offers no help
+ either — restarts are not transactional there and are not here.
+
+ So *you* choose where the retry boundary is. A ~restart-case~ at the top of a
+ frame re-runs everything, mutations included; one placed after the mutations
+ re-runs only what follows. Put the restart before anything mutates, make the
+ retried section idempotent, or snapshot what will be re-applied.
+
+ This bites harder here than in most Lisps because the point is a *game loop*
+ — skip the frame, carry on, don't die. Since a bad index signals
+ ~BoundsError~ rather than ending the process, abandoning a frame and retrying
+ it is a real thing to do, and a non-idempotent mutation is what makes it go
+ wrong.
- *An unknown restart name is a hard stop.* No ~find-restart~ to test with.
- *So are the wrong arguments*, and for the same reason: nothing static can
know what a name will find. The message names both signatures.
diff --git a/spec-conditions.md b/spec-conditions.md
index 250729d..5e20abb 100644
--- a/spec-conditions.md
+++ b/spec-conditions.md
@@ -117,6 +117,28 @@ Invoking a restart transfers control outward past zero or more frames.
the signalling frame dies. Anything a handler keeps must be copied out
(conditions are value structs, so `(push errors c)` copies).
+**A restart is not a transaction.** The list above is the whole of what a
+transfer does: it runs `defer`s and it moves control. It does not undo. Control
+resumes at the `restart-case` and runs forward from there, so a `retry` re-runs
+every effect between the restart and the target — a global the frame already
+assigned stays assigned, and is assigned again. This is not a gap to be closed
+later. Common Lisp has exactly this property and offers no help either; rollback
+would mean journalling every store, which is a different language.
+
+What follows is a discipline rather than a mechanism: **the author chooses where
+the retry boundary is.** A `restart-case` at the top of a frame re-runs
+everything, mutations included; one placed after the mutations re-runs only what
+follows them. So either put the restart before anything mutates, make the
+retried section idempotent, or snapshot what will be re-applied.
+
+It matters more here than in most Lisps because of the intended use. A game loop
+means to skip a frame and carry on rather than die, and a failed bounds check
+signalling `BoundsError` rather than ending the process makes abandoning and
+retrying a frame an ordinary thing to do — which is precisely the case where a
+non-idempotent mutation bites. §3's rule that every clause body and the body
+share a type places the restart syntactically; nothing places it *semantically*,
+and that choice is the author's.
+
## 6. Crossing compiler-generated frames
Transfer is lowered **explicitly** — result propagation plus branch targets — not
diff --git a/web/index.html b/web/index.html
index 01cc815..9180a1b 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1024,9 +1024,20 @@ a guard after each call.
of its own, because it runs from wherever the signal was. Accumulate into a global, or
put the value on the condition. A reference to an enclosing local is refused for that
reason rather than reported as an unknown name.
- A restart re-runs whatever sits between it and the target. Control
- resumes at the restart-case, so a retry repeats the side
- effects after it. Put the restart-case where re-entry is safe.
+ A restart is not a transaction. Control resumes at the
+ restart-case and runs forward from there, so a retry repeats
+ every side effect between it and the target. Nothing rolls back — a global the frame
+ already set stays set, and is set again. Common Lisp has exactly this property and
+ offers no help either.
+ So the author chooses where the retry boundary is. A restart-case at the
+ top of a frame re-runs everything including mutations already applied; one placed after
+ the mutations re-runs only what follows. Put the restart before anything mutates, make
+ the retried section idempotent, or snapshot what will be re-applied.
+ This matters more here than in most Lisps because the intended use is a
+ game loop, where the plan is to skip a frame and carry on rather than die. Now
+ that a bad index signals BoundsError instead of ending the process,
+ abandoning a frame and retrying it is a real thing to do — and that is exactly the case
+ a non-idempotent mutation spoils.
An unknown restart name is a hard stop — a located runtime error.
There is no find-restart to test with yet.
No supertype, so nothing can say "any condition".