carlos / platform Public

hostclaim: BeginMove, Touch, ReleaseMove and the replay rule (moves K2, K8, K12)

ReleaseMove leaves the released move's id in the serving op's journal;
RequireReleasedMove is the crash-boundary check built on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c6bfe4a5601276d50d524fffa20f46994d9ab19a parent 20df680
2 files changed, +384 −0
  • internal/hostclaim/move.go +109 −0
  • internal/hostclaim/move_test.go +275 −0
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go
new file mode 100644
index 000000000..44fdd2fe9
--- /dev/null
+++ b/internal/hostclaim/move.go
@@ -0,0 +1,109 @@
+package hostclaim
+
+import (
+ "fmt"
+ "time"
+)
+
+// Move phases on the claim's op (moves spec §7.5, §19.1). The record
+// carries the detailed journal; the claim carries only what the flip,
+// the commit and a rollback compare.
+const (
+ PhaseMoving = "" // moving, before the seal
+ PhaseSealed = "sealed" // moving, after the cut's staged phase
+ PhaseAborting = "aborting" // moving, the interim abort has begun
+)
+
+// journalReleasedMove is the key under which a serving op remembers the
+// move that released it (K8).
+const journalReleasedMove = "released_move"
+
+// BeginMove is K2: the request door CASes serving → moving, with the move
+// id, both participants and exec = the source box. The source must already
+// be the executor; nothing moves exec here. Touched is not widened: K12
+// has every write site add its own region or box first (Touch). Every
+// other claim field (holder, admissions, generation, chores) is left as
+// found.
+func BeginMove(opID, by, sourceBox, targetBox, targetRegion string, now time.Time) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := StartGuard(c, []string{targetRegion}, []string{targetBox}); err != nil {
+ return err
+ }
+ if opID == "" || sourceBox == "" || targetBox == "" || targetRegion == "" {
+ return fmt.Errorf("%w: a move needs an op id, two boxes and a target region", ErrMalformed)
+ }
+ if c.Op.Exec != sourceBox {
+ return fmt.Errorf("%w: exec is %q, the move's source is %q", ErrWrongState, c.Op.Exec, sourceBox)
+ }
+ c.Op = Op{Kind: KindMoving, ID: opID, Phase: PhaseMoving, By: by, StartedAt: now.UTC(), Exec: sourceBox,
+ Participants: []string{sourceBox, targetBox},
+ Intent: map[string]string{"to": targetRegion, "source": sourceBox, "target": targetBox}}
+ return nil
+ }
+}
+
+// Touch is K12: a write site adds its region or box to touched before its
+// first write there, under the op it serves. Idempotent.
+func Touch(opID string, regions, boxes []string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ c.Touched.Regions = union(c.Touched.Regions, regions)
+ c.Touched.Boxes = union(c.Touched.Boxes, boxes)
+ return nil
+ }
+}
+
+// RequireMoveOp is K5's revalidation in one closure: op id, kind and phase
+// must be what the step expects.
+func RequireMoveOp(opID, kind, phase string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ if c.Op.Kind != kind || c.Op.Phase != phase {
+ return fmt.Errorf("%w: op is %s/%q, step expects %s/%q", ErrWrongState, c.Op.Kind, c.Op.Phase, kind, phase)
+ }
+ return nil
+ }
+}
+
+// ReleaseMove is K8: the interim abort hands the host back to serving. It
+// is legal only from moving, before the seal (phase "" or "aborting"), and
+// only once no obligation under this op remains. The serving op keeps
+// exec and records which move released it: operation-bound completion
+// evidence for the abort's crash boundary (RequireReleasedMove).
+func ReleaseMove(opID, execBox string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ if c.Op.Kind != KindMoving || (c.Op.Phase != PhaseMoving && c.Op.Phase != PhaseAborting) {
+ return fmt.Errorf("%w: op is %s/%q, a release needs moving before the seal", ErrWrongState, c.Op.Kind, c.Op.Phase)
+ }
+ if execBox == "" {
+ return fmt.Errorf("%w: a release names the executor", ErrMalformed)
+ }
+ for _, o := range c.Obligations {
+ if o.Op == opID {
+ return fmt.Errorf("%w: %s %s%s is still owed under this move", ErrPendingChore, o.Kind, o.Region, o.Box)
+ }
+ }
+ c.Op = Op{Kind: KindServing, Exec: execBox, Journal: map[string]string{journalReleasedMove: opID}}
+ return nil
+ }
+}
+
+// RequireReleasedMove is the replay rule for a crashed abort: nil when
+// the claim is serving and its op records that move opID released it,
+// else ErrStaleOp. A plain serving claim, or one released by another
+// move, does not satisfy it.
+func RequireReleasedMove(opID string) func(*Claim) error {
+ return func(c *Claim) error {
+ if opID == "" || c.Op.Kind != KindServing || c.Op.Journal[journalReleasedMove] != opID {
+ return fmt.Errorf("%w: the claim does not record move %q as released", ErrStaleOp, opID)
+ }
+ return nil
+ }
+}
diff --git a/internal/hostclaim/move_test.go b/internal/hostclaim/move_test.go
new file mode 100644
index 000000000..65614ae2e
--- /dev/null
+++ b/internal/hostclaim/move_test.go
@@ -0,0 +1,275 @@
+package hostclaim
+
+import (
+ "encoding/json"
+ "errors"
+ "reflect"
+ "testing"
+ "time"
+)
+
+const (
+ moveA = "mv_abcdefghijklmnopqrstuvwxyz"
+ moveB = "mv_bcdefghijklmnopqrstuvwxyza"
+)
+
+// movingTestClaim is this file's fixture (delete_test.go already defines
+// servingClaim(t, ctx, s) with another shape). It is a VALID claim: the
+// holder's key, incarnation and location follow the grammar Validate
+// enforces, and the fixture asserts Validate() so it can never drift from
+// the real grammar.
+func movingTestClaim(t *testing.T) *Claim {
+ t.Helper()
+ created := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ inc, err := Incarnation("bab", "app", "a.go.example.com", created)
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := &Claim{V: schemaVersion, Host: "a.go.example.com", Gen: 3,
+ Holder: &Holder{Family: "instance", Key: "apps/bab/app/instances/a.go.example.com.json", Account: "bab", App: "app", Incarnation: inc, CreatedAt: created, Location: "instances/a.go.example.com/" + inc, Stamp: 3},
+ Op: Op{Kind: KindServing, Exec: "ie-box"},
+ Touched: Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"ie-box"}}}
+ if err := c.Validate(); err != nil {
+ t.Fatalf("fixture must validate: %v", err)
+ }
+ return c
+}
+
+func TestBeginMoveFromServingOnly(t *testing.T) {
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ c := movingTestClaim(t)
+ if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil {
+ t.Fatal(err)
+ }
+ if c.Op.Kind != KindMoving || c.Op.ID != moveA || c.Op.Exec != "ie-box" || c.Op.By != "ops" || !c.Op.StartedAt.Equal(now) || len(c.Op.Participants) != 2 || c.Op.Participants[0] != "ie-box" || c.Op.Participants[1] != "uk-box" || c.Op.Intent["to"] != "uk" {
+ t.Fatalf("op after BeginMove: %+v", c.Op)
+ }
+ if c.Op.Phase != PhaseMoving {
+ t.Fatalf("phase after BeginMove: %q", c.Op.Phase)
+ }
+ if err := c.Validate(); err != nil {
+ t.Fatalf("claim after BeginMove must validate: %v", err)
+ }
+ // K12: nothing is touched yet; the source adds the region before its
+ // first staging round.
+ if len(c.Touched.Regions) != 1 || len(c.Touched.Boxes) != 1 {
+ t.Fatalf("touched grew at BeginMove: %+v", c.Touched)
+ }
+ // Not from serving.
+ if err := BeginMove(moveB, "ops", "ie-box", "uk-box", "uk", now)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("second move: %v", err)
+ }
+ // exec must be the source.
+ d := movingTestClaim(t)
+ if err := BeginMove(moveA, "ops", "us-box", "uk-box", "uk", now)(d); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("source not exec: %v", err)
+ }
+ // A pending chore in the target region refuses (CC11).
+ e := movingTestClaim(t)
+ e.Obligations = []Obligation{{ID: "o1", Op: "x", Kind: "park-replica", Region: "uk", Retirement: "r", Owner: "region:uk"}}
+ if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(e); !errors.Is(err, ErrPendingChore) {
+ t.Fatalf("pending chore: %v", err)
+ }
+ // ... and in the target box.
+ f := movingTestClaim(t)
+ f.Obligations = []Obligation{{ID: "o1", Op: "x", Kind: "park-local", Box: "uk-box", Retirement: "r", Owner: "box:uk-box"}}
+ if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(f); !errors.Is(err, ErrPendingChore) {
+ t.Fatalf("pending box chore: %v", err)
+ }
+ // A claim with no holder is not movable.
+ g := movingTestClaim(t)
+ g.Holder = nil
+ if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(g); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("no holder: %v", err)
+ }
+ // Missing inputs are malformed.
+ for _, args := range [][4]string{{"", "ie-box", "uk-box", "uk"}, {moveA, "", "uk-box", "uk"}, {moveA, "ie-box", "", "uk"}, {moveA, "ie-box", "uk-box", ""}} {
+ h := movingTestClaim(t)
+ if err := BeginMove(args[0], "ops", args[1], args[2], args[3], now)(h); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("args %v: %v", args, err)
+ }
+ if h.Op.Kind != KindServing {
+ t.Fatalf("a refused BeginMove changed the op: %+v", h.Op)
+ }
+ }
+}
+
+func TestTouchAppendsOnceUnderTheOp(t *testing.T) {
+ c := movingTestClaim(t)
+ if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", time.Now())(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := Touch(moveA, []string{"uk"}, nil)(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := Touch(moveA, []string{"uk"}, []string{"uk-box"})(c); err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Touched.Regions) != 2 || len(c.Touched.Boxes) != 2 || c.Touched.Boxes[1] != "uk-box" {
+ t.Fatalf("touched: %+v", c.Touched)
+ }
+ if err := Touch("mv_other0000000000000000000000", []string{"se"}, nil)(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("touch under another op: %v", err)
+ }
+ if len(c.Touched.Regions) != 2 {
+ t.Fatalf("a refused Touch changed touched: %+v", c.Touched)
+ }
+ if err := Touch("", []string{"se"}, nil)(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("touch under no op: %v", err)
+ }
+ if err := RequireMoveOp(moveA, KindMoving, "")(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := RequireMoveOp(moveA, KindLanding, "")(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("wrong kind: %v", err)
+ }
+ if err := RequireMoveOp(moveA, KindMoving, PhaseSealed)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("wrong phase: %v", err)
+ }
+ if err := RequireMoveOp(moveB, KindMoving, "")(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("wrong op: %v", err)
+ }
+ c.Op.Phase = PhaseSealed
+ if err := RequireMoveOp(moveA, KindMoving, PhaseSealed)(c); err != nil {
+ t.Fatalf("sealed: %v", err)
+ }
+}
+
+func TestReleaseMoveFromMovingNeedsNoChores(t *testing.T) {
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ for _, phase := range []string{PhaseMoving, PhaseAborting} {
+ c := movingTestClaim(t)
+ if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil {
+ t.Fatal(err)
+ }
+ c.Op.Phase = phase
+ // A chore under this op blocks the release.
+ c.Obligations = []Obligation{{ID: "o1", Op: moveA, Kind: "park-replica", Region: "uk", Retirement: "r", Owner: "region:uk"}}
+ if err := ReleaseMove(moveA, "ie-box")(c); !errors.Is(err, ErrPendingChore) {
+ t.Fatalf("phase %q with a chore: %v", phase, err)
+ }
+ if c.Op.Kind != KindMoving {
+ t.Fatalf("a refused release changed the op: %+v", c.Op)
+ }
+ // A chore under another op does not.
+ c.Obligations[0].Op = "someone-else"
+ if err := ReleaseMove(moveB, "ie-box")(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("release under another op: %v", err)
+ }
+ if err := ReleaseMove(moveA, "ie-box")(c); err != nil {
+ t.Fatalf("phase %q: %v", phase, err)
+ }
+ if c.Op.Kind != KindServing || c.Op.ID != "" || c.Op.Exec != "ie-box" || c.Op.Journal["released_move"] != moveA || c.Op.Phase != "" || len(c.Op.Participants) != 0 {
+ t.Fatalf("op after release: %+v", c.Op)
+ }
+ if err := c.Validate(); err != nil {
+ t.Fatalf("claim after release must validate: %v", err)
+ }
+ }
+ // Not from serving, and not from a sealed move.
+ s := movingTestClaim(t)
+ if err := ReleaseMove(moveA, "ie-box")(s); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("release from serving: %v", err)
+ }
+ m := movingTestClaim(t)
+ _ = BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(m)
+ m.Op.Phase = PhaseSealed
+ if err := ReleaseMove(moveA, "ie-box")(m); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("release from sealed: %v", err)
+ }
+ // Another kind under the same id is not a move.
+ l := movingTestClaim(t)
+ _ = BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(l)
+ l.Op.Kind = KindLanding
+ if err := ReleaseMove(moveA, "ie-box")(l); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("release from landing: %v", err)
+ }
+ // The executor is named.
+ e := movingTestClaim(t)
+ _ = BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(e)
+ if err := ReleaseMove(moveA, "")(e); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("release without exec: %v", err)
+ }
+}
+
+func TestReleaseMoveLeavesEvidence(t *testing.T) {
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ plain := movingTestClaim(t)
+ if err := RequireReleasedMove(moveA)(plain); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("a plain serving claim satisfies the replay rule: %v", err)
+ }
+ c := movingTestClaim(t)
+ if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := RequireReleasedMove(moveA)(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("a moving claim satisfies the replay rule: %v", err)
+ }
+ if err := ReleaseMove(moveA, "ie-box")(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := RequireReleasedMove(moveA)(c); err != nil {
+ t.Fatalf("evidence after release: %v", err)
+ }
+ if err := RequireReleasedMove(moveB)(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("another move's id: %v", err)
+ }
+ if err := RequireReleasedMove("")(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("empty id: %v", err)
+ }
+ // A non-serving kind carrying the journal is not evidence.
+ c.Op.Kind, c.Op.ID = KindMoving, moveB
+ if err := RequireReleasedMove(moveA)(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("moving claim with a stale journal: %v", err)
+ }
+}
+
+// BeginMove and ReleaseMove own only the op: the holder (session, writer,
+// admissions, generation and their writes_lost), touched, inventory,
+// obligations of other ops and the rest are carried through unchanged.
+func TestMoveRoundTripPreservesTheRestOfTheClaim(t *testing.T) {
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ c := movingTestClaim(t)
+ c.Holder.Generation = "gen-7"
+ c.Holder.Session = 9
+ c.Holder.WriterBox = "ie-box"
+ c.Holder.Admissions = []AdmissionRecord{{Region: "ie", Session: 9, Box: "ie-box", Generation: "gen-7", Mode: "admitted", Coverage: "covered", Covered: true,
+ WritesLost: &WritesLost{Box: "ie-box", Generation: "gen-6", Session: 8, RemoteMaxTXID: 41},
+ AdditionalWritesLost: []WritesLost{{Box: "ie-box", Generation: "gen-5", Session: 7, RemoteMaxTXID: 12}}}}
+ c.Obligations = []Obligation{{ID: "o1", Op: "old", Kind: "park-replica", Region: "se", Retirement: "r", Owner: "region:se"}}
+ c.Lost = []string{"se"}
+ c.RetiredIncarnations = []string{"abc"}
+ c.QuietAfter = map[string]time.Time{"se": now}
+ c.Inventory = map[string]InventoryEntry{"ie-box": {Layout: "nested", Paths: map[string]uint64{"/data/x": 3}}}
+ if err := c.Validate(); err != nil {
+ t.Fatalf("fixture: %v", err)
+ }
+ before := mustJSON(t, c)
+ if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := ReleaseMove(moveA, "ie-box")(c); err != nil {
+ t.Fatal(err)
+ }
+ // Only the op differs.
+ var round Claim
+ if err := json.Unmarshal(before, &round); err != nil {
+ t.Fatal(err)
+ }
+ round.Op = c.Op
+ if !reflect.DeepEqual(round, *c) {
+ t.Fatalf("round trip changed more than the op:\nbefore %s\nafter %s", before, mustJSON(t, c))
+ }
+ if err := c.Validate(); err != nil {
+ t.Fatalf("after round trip: %v", err)
+ }
+}
+
+func mustJSON(t *testing.T, c *Claim) []byte {
+ t.Helper()
+ b, err := json.Marshal(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}