carlos / platform Public

hostclaim: flip names the source's own identity, revert confined to its lane, exec-to-none needs a box, revert replays (moves task 2 fix round 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 87a20a32100f277e17ca2832ec106e596e0e865c parent 909c6bd
2 files changed, +145 −13
  • internal/hostclaim/move.go +36 −9
  • internal/hostclaim/move_test.go +109 −4
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go
index 965ce4751..fdcb41036 100644
--- a/internal/hostclaim/move.go
+++ b/internal/hostclaim/move.go
@@ -3,7 +3,6 @@ package hostclaim
import (
"fmt"
"strconv"
- "strings"
"time"
)
@@ -172,8 +171,8 @@ func ExecToNone(opID, box, evidenceKey, journalKey string) func(*Claim) error {
if err := RequireOp(opID)(c); err != nil {
return err
}
- if evidenceKey == "" || (journalKey != JournalEvidenceSource && journalKey != JournalEvidenceTarget) {
- return fmt.Errorf("%w: execution is given up only with evidence", ErrMalformed)
+ if box == "" || evidenceKey == "" || (journalKey != JournalEvidenceSource && journalKey != JournalEvidenceTarget) {
+ return fmt.Errorf("%w: execution is given up by a named executor, citing evidence", ErrMalformed)
}
if c.Op.Exec == "" && c.Op.Journal[journalKey] == evidenceKey {
return nil
@@ -221,6 +220,12 @@ func Flip(opID, targetBox string, sourceStamp uint64, srcGen string) func(*Claim
if c.Op.Exec != "" || c.Op.Journal[JournalEvidenceSource] == "" {
return fmt.Errorf("%w: the flip needs exec none and the source's evidence", ErrWrongState)
}
+ // The first flip names the source's own installation: its stamp S and
+ // generation are what a rollback returns to (§6.4, §7.8), so they
+ // must be what the claim holds now, before the stamp is overwritten.
+ if sourceStamp != c.Holder.Stamp || srcGen != c.Holder.Generation {
+ return fmt.Errorf("%w: the flip names stamp %d generation %q, the holder is at %d and %q", ErrWrongState, sourceStamp, srcGen, c.Holder.Stamp, c.Holder.Generation)
+ }
if len(c.Op.Participants) != 2 || targetBox == "" || c.Op.Participants[1] != targetBox {
return fmt.Errorf("%w: %q is not the move's target", ErrWrongState, targetBox)
}
@@ -235,14 +240,32 @@ func Flip(opID, targetBox string, sourceStamp uint64, srcGen string) func(*Claim
// Revert is §7.4 reverting: the source takes execution back with its own
// evidence, before the flip. The target region's staging park is queued in
// the same write (§7.11: the revert's `opened` waits for it), owned by the
-// target region and scoped to the staging lane with the writers' horizon
-// the caller computed (G7); nothing here may park the live location. A
-// park already owed under this op for the region is not queued twice.
+// target region and scoped to this move's own lane, moves/<host>/<op>, with
+// the holder's incarnation and the writers' horizon the caller computed
+// (G7). Nothing here may park the live location, the host-wide staging
+// prefix or another move's lane. A park already owed under this op for the
+// region is not queued twice.
+//
+// A replay after a crash finds the claim already reverting, with this op's
+// source, region, rev_phase "evidence" and the same staging park: that is
+// a no-op and allocates no ids.
func Revert(opID, sourceBox, targetRegion string, stagingScope *ParkScope, newID func() string) func(*Claim) error {
return func(c *Claim) error {
if err := RequireOp(opID)(c); err != nil {
return err
}
+ if c.Op.Kind == KindReverting {
+ if c.Op.Exec == sourceBox && sourceBox != "" && len(c.Op.Participants) > 0 && c.Op.Participants[0] == sourceBox &&
+ c.Op.Journal[JournalEvidenceSource] != "" && c.Op.Journal[JournalRevPhase] == "evidence" &&
+ targetRegion != "" && c.Op.Intent["to"] == targetRegion && stagingScope != nil {
+ for _, o := range c.Obligations {
+ if o.Op == opID && o.Kind == "park-replica" && o.Region == targetRegion && o.Park != nil && sameParkScope(*o.Park, *stagingScope) {
+ return nil
+ }
+ }
+ }
+ return fmt.Errorf("%w: the op is reverting, and not with this revert's source, region and staging park", ErrWrongState)
+ }
if c.Op.Kind != KindMoving || c.Op.Exec != "" || c.Op.Journal[JournalEvidenceSource] == "" {
return fmt.Errorf("%w: revert needs a moving op with exec none and the source's evidence", ErrWrongState)
}
@@ -252,14 +275,14 @@ func Revert(opID, sourceBox, targetRegion string, stagingScope *ParkScope, newID
if targetRegion == "" || c.Op.Intent["to"] != targetRegion {
return fmt.Errorf("%w: %q is not the move's target region", ErrWrongState, targetRegion)
}
- if stagingScope == nil || newID == nil {
+ if stagingScope == nil || newID == nil || c.Holder == nil {
return fmt.Errorf("%w: a revert names the staging lane it leaves behind", ErrMalformed)
}
if err := stagingScope.Validate(c.Host); err != nil {
return err
}
- if !strings.HasPrefix(stagingScope.Location, "moves/"+c.Host) {
- return fmt.Errorf("%w: the revert's park must be scoped to the staging lane, not %q", ErrMalformed, stagingScope.Location)
+ if stagingScope.Location != "moves/"+c.Host+"/"+opID || stagingScope.Incarnation != c.Holder.Incarnation {
+ return fmt.Errorf("%w: the revert's park must be this move's own lane and this holder's incarnation", ErrMalformed)
}
c.Op.Kind, c.Op.Exec = KindReverting, sourceBox
if c.Op.Journal[JournalRevPhase] == "" {
@@ -275,3 +298,7 @@ func Revert(opID, sourceBox, targetRegion string, stagingScope *ParkScope, newID
return nil
}
}
+
+func sameParkScope(a, b ParkScope) bool {
+ return a.Location == b.Location && a.Incarnation == b.Incarnation && a.EvidenceAt.Equal(b.EvidenceAt) && a.RequestLimit == b.RequestLimit
+}
diff --git a/internal/hostclaim/move_test.go b/internal/hostclaim/move_test.go
index da724862c..e42c65c47 100644
--- a/internal/hostclaim/move_test.go
+++ b/internal/hostclaim/move_test.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"reflect"
+ "strings"
"testing"
"time"
@@ -290,6 +291,7 @@ const stoppedSource = "moves/bab/app/h/mv/stopped-source.json"
func movingToSealed(t *testing.T) *Claim {
t.Helper()
c := movingTestClaim(t)
+ c.Holder.Generation = "gen-7" // the source's generation, which the flip must name
for i, tr := range []func(*Claim) error{
BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", time.Now()),
ExecToNone(moveA, "ie-box", stoppedSource, JournalEvidenceSource),
@@ -336,6 +338,17 @@ func TestCutClaimCASesFollowK3AndK4(t *testing.T) {
if err := ExecToNone(op, "ie-box", "moves/other.json", JournalEvidenceSource)(c); !errors.Is(err, ErrWrongState) {
t.Fatalf("replay with other evidence: %v", err)
}
+ // With exec already none, an empty box is nobody: it must neither
+ // replay nor replace the evidence.
+ given := mustJSON(t, c)
+ for _, ev := range []string{stoppedSource, "moves/other.json"} {
+ if err := ExecToNone(op, "", ev, JournalEvidenceSource)(c); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("empty box with %q: %v", ev, err)
+ }
+ if got := mustJSON(t, c); string(got) != string(given) {
+ t.Fatalf("empty box with %q mutated the claim: %s", ev, got)
+ }
+ }
// Exec none and evidence, but not sealed: still no flip.
if err := Flip(op, "uk-box", 3, "")(c); !errors.Is(err, ErrWrongState) {
t.Fatalf("flip before the seal phase: %v", err)
@@ -379,6 +392,22 @@ func TestCutClaimCASesFollowK3AndK4(t *testing.T) {
func TestFlipIsIdempotentForEitherWriter(t *testing.T) {
c := movingToSealed(t)
c.Gen = 9
+ // The first flip must name the source's own stamp and generation: a
+ // wrong identity would be journalled as the rollback's return.
+ sealed := mustJSON(t, c)
+ for name, fn := range map[string]func(*Claim) error{
+ "first flip, wrong stamp": Flip(moveA, "uk-box", 4, "gen-7"),
+ "first flip, zero stamp": Flip(moveA, "uk-box", 0, "gen-7"),
+ "first flip, wrong generation": Flip(moveA, "uk-box", 3, "gen-8"),
+ "first flip, empty generation": Flip(moveA, "uk-box", 3, ""),
+ } {
+ if err := fn(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("%s: %v", name, err)
+ }
+ if got := mustJSON(t, c); string(got) != string(sealed) {
+ t.Fatalf("%s mutated the claim:\nbefore %s\nafter %s", name, sealed, got)
+ }
+ }
if err := Flip(moveA, "uk-box", 3, "gen-7")(c); err != nil {
t.Fatal(err)
}
@@ -390,15 +419,23 @@ func TestFlipIsIdempotentForEitherWriter(t *testing.T) {
if c.Holder.Stamp != 10 || !reflect.DeepEqual(c.Op, op) {
t.Fatalf("replay moved the claim: %+v stamp %d", c.Op, c.Holder.Stamp)
}
+ // The replay is checked against the journal, not the holder: the holder
+ // stamp is now F (10), not S (3).
+ flipped := mustJSON(t, c)
for name, fn := range map[string]func(*Claim) error{
- "another stamp": Flip(moveA, "uk-box", 4, "gen-7"),
- "another generation": Flip(moveA, "uk-box", 3, "gen-8"),
- "another target": Flip(moveA, "us-box", 3, "gen-7"),
- "another op": Flip(moveB, "uk-box", 3, "gen-7"),
+ "another stamp": Flip(moveA, "uk-box", 4, "gen-7"),
+ "the new holder stamp": Flip(moveA, "uk-box", 10, "gen-7"),
+ "another generation": Flip(moveA, "uk-box", 3, "gen-8"),
+ "an empty generation": Flip(moveA, "uk-box", 3, ""),
+ "another target": Flip(moveA, "us-box", 3, "gen-7"),
+ "another op": Flip(moveB, "uk-box", 3, "gen-7"),
} {
if err := fn(c); err == nil {
t.Fatalf("%s: a different flip was accepted", name)
}
+ if got := mustJSON(t, c); string(got) != string(flipped) {
+ t.Fatalf("%s mutated the claim", name)
+ }
}
// Once the landing has moved past checking, the replay is stale.
c.Op.Phase = "committing"
@@ -465,6 +502,28 @@ func TestRevertNeedsEvidenceAndReturnsExecToSource(t *testing.T) {
if err := Revert(op, "ie-box", "uk", &live, newID)(c); !errors.Is(err, ErrMalformed) {
t.Fatalf("revert whose park would reach the live location: %v", err)
}
+ // The scope is this move's own lane and this holder's incarnation: not
+ // the host-wide staging prefix, not another op's lane, not another
+ // installation's incarnation.
+ hostWide := scope
+ hostWide.Location = "moves/" + c.Host
+ otherOp := scope
+ otherOp.Location = "moves/" + c.Host + "/" + moveB
+ otherInc := scope
+ otherInc.Incarnation = strings.Repeat("ab", 32)
+ preRevert := mustJSON(t, c)
+ for name, sc := range map[string]ParkScope{"host-wide": hostWide, "another op's lane": otherOp, "another incarnation": otherInc} {
+ sc := sc
+ if err := Revert(op, "ie-box", "uk", &sc, newID)(c); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("revert with a %s scope: %v", name, err)
+ }
+ if got := mustJSON(t, c); string(got) != string(preRevert) {
+ t.Fatalf("a refused revert (%s scope) mutated the claim", name)
+ }
+ }
+ if ids != 0 {
+ t.Fatalf("a refused revert allocated %d ids", ids)
+ }
if c.Op.Kind != KindMoving || len(c.Obligations) != 0 {
t.Fatalf("a refused revert left changes: %+v", c.Op)
}
@@ -575,3 +634,49 @@ func TestCutTransitionsPreserveTheRestOfTheClaim(t *testing.T) {
})
}
}
+
+// A revert replayed after a crash finds the claim already reverting with
+// the same op, region, scope and rev_phase: nil, no new ids, one park.
+func TestRevertReplayIsIdempotent(t *testing.T) {
+ c := movingTestClaim(t)
+ if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", time.Now())(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := ExecToNone(moveA, "ie-box", stoppedSource, JournalEvidenceSource)(c); err != nil {
+ t.Fatal(err)
+ }
+ scope := ParkScope{Location: "moves/" + c.Host + "/" + moveA, Incarnation: c.Holder.Incarnation, EvidenceAt: time.Now().UTC(), RequestLimit: time.Minute}
+ ids := 0
+ newID := func() string { ids++; return fmt.Sprintf("id%d", ids) }
+ if err := Revert(moveA, "ie-box", "uk", &scope, newID)(c); err != nil {
+ t.Fatal(err)
+ }
+ after, usedIDs := mustJSON(t, c), ids
+ if err := Revert(moveA, "ie-box", "uk", &scope, newID)(c); err != nil {
+ t.Fatalf("replay: %v", err)
+ }
+ if got := mustJSON(t, c); string(got) != string(after) || ids != usedIDs || len(c.Obligations) != 1 {
+ t.Fatalf("replay changed the claim or allocated ids (%d, was %d): %s", ids, usedIDs, mustJSON(t, c))
+ }
+ // A replay that is not the same revert is refused, and changes nothing.
+ later := scope
+ later.EvidenceAt = scope.EvidenceAt.Add(time.Second)
+ for name, fn := range map[string]func(*Claim) error{
+ "another scope": Revert(moveA, "ie-box", "uk", &later, newID),
+ "another region": Revert(moveA, "ie-box", "se", &scope, newID),
+ "another source": Revert(moveA, "uk-box", "uk", &scope, newID),
+ "another op": Revert(moveB, "ie-box", "uk", &scope, newID),
+ } {
+ if err := fn(c); err == nil {
+ t.Fatalf("%s: accepted as a replay", name)
+ }
+ if got := mustJSON(t, c); string(got) != string(after) || ids != usedIDs {
+ t.Fatalf("%s changed the claim or allocated ids", name)
+ }
+ }
+ // Once the revert has moved past evidence, the replay is stale.
+ c.Op.Journal[JournalRevPhase] = "opened"
+ if err := Revert(moveA, "ie-box", "uk", &scope, newID)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("replay after the revert moved on: %v", err)
+ }
+}