carlos / platform Public

offline-move: the release item refuses a target live link to another build (final review HIGH)

A return move after an upgrade (A to B on v1, v2 on B, B back to A) found
A's root-owned live link still naming v1, which retirement leaves. The
channel names v2, so the landing passed, and a wake before ordinary
adoption could exec v1 against the sealed v2 database.

The release item now also reads <opt>/live/<host> on the target: absent
is fine (nothing runs until adoption stages the sealed build); a link
that does not resolve to the sealed version's VersionPath fails the item
with MV-O177 (pending copy). open re-verifies every landing item, so it
keeps the hold. The link is only read; root gains no code path.

Runbook P3 says to abort such a return move and to read the target's
link before starting one. Tests: the return-move regression (landing
fails at release, a scheduled tick on the held row is refused, open
keeps the hold, the link untouched, a link naming the sealed build
opens), a link appearing before open, and the fresh-target premise.

The Plan 14 root read census changes (landRun.release, new
landRun.liveNotSealed, the internal/host envelope) and is NOT re-pinned:
it waits for the census owner's ruling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8dc2a5443e5ca2b5d7621127bece33a13df898ea parent 026c0ef
5 files changed, +168 −5
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +2 −1
  • docs/superpowers/plans/2026-10-20-offline-operator-move.md +2 −0
  • internal/host/movesoffline_land.go +42 −3
  • internal/host/movesoffline_land_test.go +121 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 36666a1a1..47f478a5b 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -104,7 +104,7 @@ var movesCopyIDs = []string{
"MV-O126", "MV-O127", "MV-O128", "MV-O129", "MV-O130", "MV-O131", "MV-O132", "MV-O133", "MV-O134", "MV-O135", "MV-O136", "MV-O137", "MV-O138", "MV-O139", "MV-O140", "MV-O141",
"MV-O142", "MV-O143", "MV-O144", "MV-O145", "MV-O146", "MV-O147", "MV-O148", "MV-O149", "MV-O150", "MV-O151", "MV-O152", "MV-O153", "MV-O154", "MV-O155", "MV-O156", "MV-O157",
"MV-O158", "MV-O159", "MV-O160", "MV-O161", "MV-O162", "MV-O163", "MV-O164", "MV-O165", "MV-O166", "MV-O167", "MV-O168", "MV-O169", "MV-O170", "MV-O171",
- "MV-O172", "MV-O173", "MV-O174", "MV-O175", "MV-O176",
+ "MV-O172", "MV-O173", "MV-O174", "MV-O175", "MV-O176", "MV-O177",
}
// copyRow is one row of the record. The File cell is the file the sentence is
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 633163f8b..0d2e8c798 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -610,7 +610,7 @@ The sealer's item reasons, which `carlos move offline status` shows as MV-O55's
| MV-O97 | internal/host/movesoffline_seal.go | replication "<rung>" | pending |
| MV-O98 | internal/host/movesoffline_seal.go | next due <unix time> | pending |
-The landing's item reasons (`internal/host/movesoffline_land.go`, Task 5 fix round 1), which `carlos move offline status` shows as MV-O55's `<detail>` for the target's items. The copy gate reads every Check reason, so each has a row. A reason never carries an error's text: the error behind a reason that waits or fails is the box's log line. `<names>` is the list of variables a failed env pin finds different, each as `<name> (<kind>)` from `moves.CompareEnv`, never a value. MV-O110 to MV-O176.
+The landing's item reasons (`internal/host/movesoffline_land.go`, Task 5 fix round 1), which `carlos move offline status` shows as MV-O55's `<detail>` for the target's items. The copy gate reads every Check reason, so each has a row. A reason never carries an error's text: the error behind a reason that waits or fails is the box's log line. `<names>` is the list of variables a failed env pin finds different, each as `<name> (<kind>)` from `moves.CompareEnv`, never a value. MV-O110 to MV-O177. MV-O177 is the `release` item's failure when the target box keeps a live link for the host that names another build than the sealed one (final review, return moves).
| id | file | text | status |
|---|---|---|---|
@@ -681,3 +681,4 @@ The landing's item reasons (`internal/host/movesoffline_land.go`, Task 5 fix rou
| MV-O174 | internal/host/movesoffline_land.go | not checked: the row is not held for this move | pending |
| MV-O175 | internal/host/movesoffline_land.go | not checked: the seal could not be used | pending |
| MV-O176 | internal/host/movesoffline_land.go | the row could not be read back | pending |
+| MV-O177 | internal/host/movesoffline_land.go | the target box still holds an older build of this instance. see the offline move runbook | pending |
diff --git a/docs/superpowers/plans/2026-10-20-offline-operator-move.md b/docs/superpowers/plans/2026-10-20-offline-operator-move.md
index bd14adf70..f45dab1db 100644
--- a/docs/superpowers/plans/2026-10-20-offline-operator-move.md
+++ b/docs/superpowers/plans/2026-10-20-offline-operator-move.md
@@ -577,6 +577,8 @@ Every step waits for Paul's go. Nothing touches production before P2.
**P3. After `open`, if something is wrong.** The target is the only live copy and may have taken writes, so going back is a new offline move from `uk` to the home, which needs UK's staging grant (P0.4). Its landing on the IE box lifts any stale tombstone and finds no warm cache (the source half parked at `open`). If the grant is not in place: fix forward on the UK box, and file the gap.
+- A return move's landing fails the `release` item with MV-O177 ("the target box still holds an older build of this instance") when the box the instance left still keeps its live link `/opt/carlos/live/<host>` naming the build it ran then, and the app has been promoted since. Retirement leaves that link because it is root-owned, and nothing in this tool or in ordinary adoption repoints it while the row is held, so there is no safe road to clear it from here: abort the move (safe before `open`), and the instance serves again from where it is. Before starting a return move, read the link on the target box (`readlink /opt/carlos/live/<host>`): if it names a different version from the one the instance runs now, do not start, and take it to Paul.
+
**Cleanup recipe for an abandoned move** (documented, rarely needed): read `instances/<host>/lock.json` in the source data bucket with `lease.LockState`; if the owner is `moved:<id>` for a move that is no longer in flight, delete it. The 7-day expiry frees it anyway.
## Interaction with the host claim
diff --git a/internal/host/movesoffline_land.go b/internal/host/movesoffline_land.go
index 487bad3fb..8ac434d5a 100644
--- a/internal/host/movesoffline_land.go
+++ b/internal/host/movesoffline_land.go
@@ -1171,9 +1171,10 @@ func (r *landRun) replication() {
r.ok("replication")
}
-// release: the app's channel resolves to the build the seal recorded. A
-// promote during the move fails it; the build itself arrives after open
-// through ordinary adoption.
+// release: the app's channel resolves to the build the seal recorded, and
+// the target holds no live link to another build of the host
+// (liveNotSealed). A promote during the move fails it; the build itself
+// arrives after open through ordinary adoption.
func (r *landRun) release(ctx context.Context) {
cfg := r.cfg()
if r.m.Source.Release != "" && r.m.Source.Release != r.seal.Release {
@@ -1194,5 +1195,43 @@ func (r *landRun) release(ctx context.Context) {
r.landFail("release", "the channel points at another build than the sealed one")
return
}
+ if err := r.liveNotSealed(); err != nil {
+ r.landFailErr("release", "the target box still holds an older build of this instance. see the offline move runbook", err)
+ return
+ }
r.ok("release")
}
+
+// liveNotSealed is the release item's second half: nil when the target has
+// no live link for the host, or when the link names the sealed build. The
+// link (<opt>/live/<host>) is the one file ExecBackend.ensureLiveLink and
+// the activator exec. A box the instance once left keeps it, root-owned and
+// still naming the build it ran then (retirePaths leaves it); a wake before
+// ordinary adoption execs whatever it names, since ensureLiveLink accepts
+// any executable and default update settings gate no version. So on a
+// return move after an upgrade the old build could open the sealed
+// database. Any other link fails the item, and open, which verifies every
+// item again, never releases the hold over it. No link is fine: nothing can
+// run until adoption stages the sealed build. The link is only read here,
+// never changed. The error is the box's log line.
+func (r *landRun) liveNotSealed() error {
+ optDir := r.cfg().OptDir
+ if optDir == "" {
+ optDir = release.OptDir()
+ }
+ link := release.LiveLink(optDir, r.host)
+ if _, err := os.Lstat(link); errors.Is(err, os.ErrNotExist) {
+ return nil
+ } else if err != nil {
+ return err
+ }
+ id, err := release.ResolveLiveLink(optDir, r.host)
+ if err != nil {
+ return err
+ }
+ want := release.VersionPath(optDir, release.RouteAccount(*r.rt), r.seal.Release, r.rt.App)
+ if id.Version != r.seal.Release || id.StagedPath != want {
+ return fmt.Errorf("%s names build %q at %s, the seal recorded %q at %s", link, id.Version, id.StagedPath, r.seal.Release, want)
+ }
+ return nil
+}
diff --git a/internal/host/movesoffline_land_test.go b/internal/host/movesoffline_land_test.go
index f97d76596..011d743fa 100644
--- a/internal/host/movesoffline_land_test.go
+++ b/internal/host/movesoffline_land_test.go
@@ -9,6 +9,8 @@ import (
"encoding/json"
"errors"
"io/fs"
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"reflect"
@@ -779,6 +781,11 @@ func TestOfflineLandFailsEachMismatch(t *testing.T) {
func TestOfflineLandOpenReleasesOnlyWhenEveryItemVerifies(t *testing.T) {
lw := newLandWorld(t)
ctx := context.Background()
+ // A fresh target: no live link for the host, which the release item
+ // accepts (nothing can run until adoption stages the sealed build).
+ if lexists(release.LiveLink(lw.uk.opt, offHost)) {
+ t.Fatal("fixture: the target already has a live link")
+ }
lw.flipAndLand(1)
lw.requireLanded()
lw.wd.open()
@@ -1135,3 +1142,117 @@ func TestOfflineLandANestedSourceWithLaunchFiles(t *testing.T) {
}
}
}
+
+// --- the target's own live build (final review, return moves) --------------------
+
+const landOlderBuild = "the target box still holds an older build of this instance. see the offline move runbook"
+
+// stageLive gives the target box a live link for the host naming version v
+// of the app, as a box the instance once left keeps it after retirement: a
+// staged executable in the version store and the link to it.
+func (lw *landWorld) stageLive(v string) string {
+ t := lw.t
+ t.Helper()
+ writeFile(t, release.VersionPath(lw.uk.opt, lw.wd.acct, v, "app"), "#!/bin/sh\nexit 0\n")
+ if err := release.PointLive(lw.uk.opt, offHost, lw.wd.acct, v, "app"); err != nil {
+ t.Fatal(err)
+ }
+ target, err := os.Readlink(release.LiveLink(lw.uk.opt, offHost))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return target
+}
+
+// requireNoWake: the row is still held landing by the move, the edges file
+// does not list it (so the edge never hands it to the activator), and a
+// scheduled tick for it, the one wake source that reaches a held row, is
+// answered with the maintenance page without reaching the exec backend.
+func (lw *landWorld) requireNoWake(when string) {
+ t := lw.t
+ t.Helper()
+ rt := lw.uk.row(offHost)
+ if rt == nil || rt.Status != "landing" || rt.HoldOp != offID {
+ t.Fatalf("%s: the target row %+v is not held by the move", when, rt)
+ }
+ lw.uk.edges()
+ if lw.uk.lists(offHost) {
+ t.Fatalf("%s: the edges file lists the host", when)
+ }
+ proxied := false
+ rec := httptest.NewRecorder()
+ lw.uk.svc.ServeScheduled(rec, httptest.NewRequest("POST", "http://"+offHost+"/jobs/remind", nil), *rt,
+ http.HandlerFunc(func(http.ResponseWriter, *http.Request) { proxied = true }))
+ if proxied || rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("%s: a scheduled tick on the target was answered %d (proxied %v)", when, rec.Code, proxied)
+ }
+ if rt := lw.uk.row(offHost); rt == nil || rt.Status != "landing" || rt.HoldOp != offID {
+ t.Fatalf("%s: after the tick the target row is %+v", when, rt)
+ }
+}
+
+// A return move after an upgrade: the target box is one the instance left
+// on an older build, and it still keeps that build's live link (retirement
+// leaves the root-owned link). The channel names the sealed build, so only
+// the link can tell. The landing fails the release item with MV-O177 and
+// every other item lands; the record stays offline-landing; nothing can
+// wake the row; the link is left exactly as it was. Open, reached even so,
+// verifies the item again and keeps the hold. A link that names the sealed
+// build passes, and open then releases the row.
+func TestOfflineLandRefusesAnOlderLiveBuildOnTheTarget(t *testing.T) {
+ lw := newLandWorld(t)
+ old := lw.stageLive("v0")
+ lw.flipAndLand(2)
+ if st := lw.state(); st != provision.OfflineLanding {
+ t.Fatalf("state %s with an older live build on the target", st)
+ }
+ r := lw.report()
+ if r.Phase != moves.OfflinePhaseFailed || r.Items["release"].OK || r.Items["release"].Reason != landOlderBuild {
+ t.Fatalf("phase %s, release %+v", r.Phase, r.Items["release"])
+ }
+ for id, c := range r.Items {
+ if id != "release" && !c.OK {
+ t.Fatalf("%s not OK: %+v", id, c)
+ }
+ }
+ lw.requireNoWake("after the landing")
+ if got, err := os.Readlink(release.LiveLink(lw.uk.opt, offHost)); err != nil || got != old {
+ t.Fatalf("the live link is %q (%v), want it left at %q", got, err, old)
+ }
+
+ // Open, forced past the door's landed gate, verifies again and refuses.
+ lw.wd.landed()
+ lw.wd.open()
+ lw.uk.tick()
+ lw.uk.tick()
+ if r := lw.report(); r.Phase == moves.OfflinePhaseOpened || r.Items["release"].Reason != landOlderBuild {
+ t.Fatalf("open with an older live build: %s %+v", r.Phase, r.Items["release"])
+ }
+ lw.requireNoWake("at open")
+ lw.requireNoTargetLease()
+
+ // The same link naming the sealed build: open releases the row.
+ lw.stageLive(sealVersion)
+ lw.uk.tick()
+ if rt := lw.uk.row(offHost); rt == nil || rt.Status != "ready" || rt.HoldOp != "" {
+ t.Fatalf("not opened with the sealed live build: %+v (%+v)", rt, lw.report().Items)
+ }
+ if r := lw.report(); r.Phase != moves.OfflinePhaseOpened {
+ t.Fatalf("phase %s", r.Phase)
+ }
+}
+
+// A live link that names another build fails at open too: the landing was
+// clean (no link), and the link appears before open.
+func TestOfflineLandOpenRefusesALiveBuildThatAppeared(t *testing.T) {
+ lw := newLandWorld(t)
+ lw.flipAndLand(1)
+ lw.requireLanded()
+ lw.stageLive("v0")
+ lw.wd.open()
+ lw.uk.tick()
+ if r := lw.report(); r.Phase == moves.OfflinePhaseOpened || r.Items["release"].Reason != landOlderBuild {
+ t.Fatalf("open with another live build: %s %+v", r.Phase, r.Items["release"])
+ }
+ lw.requireNoWake("at open")
+}