lane-handoff's lifecycle_begin reads the lane's current state and hands it straight back as the compare-and-swap's expected value:
lcb_now="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-state "$lane" 2>/dev/null \
| awk -F"\t" '$1 == "state" { print $2; exit }')" || lcb_rc=$?
...
[ -n "$lcb_now" ] || lcb_now=none
...
lcb_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" set-lane-state "$lane" SWAPPING \
--expect "$lcb_now" --owner "${uuid:-none}" ...)"
--expect asks has the lane moved since I read it and nothing else, so every state is a legal source for SWAPPING — CLOSED included. A handoff run on a lane whose lifecycle says CLOSED takes it to SWAPPING, polls it, records an inventory under that operation, appends a PAUSED record and finishes at SWAPPED: a terminal lane reopened, with a lifecycle that now says a swap is what last happened to it.
CLOSED is written by lane_state_follow for an ENDED or a RETIRED line — the two verbs that end a lane — and the manual's own table reads it as the lane is finished. The lifecycle has no transition classes at all: which word may follow which is expressed only in what each caller happens to pass.
The reachable shape is not exotic: lane-end closes a lane, and a /handoff typed (or a skill run) in that pane afterwards is an ordinary mistake — the pane, the window name and the register row are all still there.
Why this is filed rather than fixed on #97
Copilot review rounds are capped at two per pull request (Brett Heap's ruling of 2026-09-16); #97 is at round six and took only the two safety holes it could not land with. And the fix here is a DESIGN decision rather than a guard: R-A11-11 is that a swap is never left unwritten, and the lifecycle on that branch is bound by it — a control root that cannot be derived, a fence another act moved, a snapshot that cannot be written: each of them costs the LIFECYCLE and never the record. A handoff that REFUSES on a closed lane would be the first time the lifecycle stopped the swap, and a handoff that silently declines the transition would leave a lane whose record says PAUSED and whose snapshot says CLOSED. Which of those is right is Brett's to rule.
What's open
Name the legal sources of every transition (a transition table, rather than each caller's --expect), and decide what a handoff on a CLOSED lane does: refuse before any handoff work, take an explicit recovery/reopen path that is visible as one, or record the swap and leave the lifecycle closed with the divergence reported by lane-reconcile.
Filed from Copilot round 6 on #97 — #97 (comment) — and named in openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.md section 7.
lane-handoff'slifecycle_beginreads the lane's current state and hands it straight back as the compare-and-swap's expected value:--expectasks has the lane moved since I read it and nothing else, so every state is a legal source forSWAPPING—CLOSEDincluded. A handoff run on a lane whose lifecycle says CLOSED takes it toSWAPPING, polls it, records an inventory under that operation, appends aPAUSEDrecord and finishes atSWAPPED: a terminal lane reopened, with a lifecycle that now says a swap is what last happened to it.CLOSEDis written bylane_state_followfor anENDEDor aRETIREDline — the two verbs that end a lane — and the manual's own table reads it as the lane is finished. The lifecycle has no transition classes at all: which word may follow which is expressed only in what each caller happens to pass.The reachable shape is not exotic:
lane-endcloses a lane, and a/handofftyped (or a skill run) in that pane afterwards is an ordinary mistake — the pane, the window name and the register row are all still there.Why this is filed rather than fixed on #97
Copilot review rounds are capped at two per pull request (Brett Heap's ruling of 2026-09-16); #97 is at round six and took only the two safety holes it could not land with. And the fix here is a DESIGN decision rather than a guard:
R-A11-11is that a swap is never left unwritten, and the lifecycle on that branch is bound by it — a control root that cannot be derived, a fence another act moved, a snapshot that cannot be written: each of them costs the LIFECYCLE and never the record. A handoff that REFUSES on a closed lane would be the first time the lifecycle stopped the swap, and a handoff that silently declines the transition would leave a lane whose record says PAUSED and whose snapshot says CLOSED. Which of those is right is Brett's to rule.What's open
Name the legal sources of every transition (a transition table, rather than each caller's
--expect), and decide what a handoff on aCLOSEDlane does: refuse before any handoff work, take an explicit recovery/reopen path that is visible as one, or record the swap and leave the lifecycle closed with the divergence reported bylane-reconcile.Filed from Copilot round 6 on #97 — #97 (comment) — and named in
openspec/changes/add-crash-consistent-lane-worktree-recovery/tasks.mdsection 7.