carlos / platform Public

hostclaim: ExecToNone, Flip, Revert, SetMovePhase, SetJournal (moves K3, K4, §7.5)

Flip is idempotent for either participant (G12); Revert queues the
target region's staging park scoped to the lane, never the live location.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 496fc29fffc72eba09c27c9038fe733e328543a1 parent cec8b33
2 files changed, +460 −0
  • internal/hostclaim/move.go +163 −0
  • internal/hostclaim/move_test.go +297 −0
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go
index 1f5c78fdd..965ce4751 100644
--- a/internal/hostclaim/move.go
+++ b/internal/hostclaim/move.go
@@ -2,6 +2,8 @@ package hostclaim
import (
"fmt"
+ "strconv"
+ "strings"
"time"
)
@@ -14,6 +16,19 @@ const (
PhaseAborting = "aborting" // moving, the interim abort has begun
)
+// Journal keys the cut and the flip write on the claim's op (moves spec
+// §7.4, §7.5). The record carries the detail; the claim carries what the
+// flip, the commit and a rollback compare.
+const (
+ JournalSeal = "seal" // the sealed cut's summary
+ JournalSrcGen = "src_gen" // the source's generation, journalled at the flip
+ JournalSourceStamp = "source_stamp" // the source installation's stamp S, journalled at the flip
+ JournalCutSession = "cut_session" // the cut's session
+ JournalRevPhase = "rev_phase" // a revert's own phase
+ JournalEvidenceSource = "evidence_source" // the source's stop evidence (K3)
+ JournalEvidenceTarget = "evidence_target" // the target's stop evidence (K3)
+)
+
// journalReleasedMove is the key under which a serving op remembers the
// move that released it (K8).
const journalReleasedMove = "released_move"
@@ -112,3 +127,151 @@ func RequireReleasedMove(opID string) func(*Claim) error {
return nil
}
}
+
+func setJournal(c *Claim, k, v string) {
+ if c.Op.Journal == nil {
+ c.Op.Journal = map[string]string{}
+ }
+ c.Op.Journal[k] = v
+}
+
+// SetJournal records move journal entries under the op (D1): what the
+// claim half must read with the same fresh read as an admission. It names
+// the op only, so any kind may use it.
+func SetJournal(opID string, kv map[string]string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ for k, v := range kv {
+ setJournal(c, k, v)
+ }
+ return nil
+ }
+}
+
+// SetMovePhase moves the op's phase one step, under its kind. A replay
+// finds the phase already moved and is refused (ErrWrongState).
+func SetMovePhase(opID, kind, from, to string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireMoveOp(opID, kind, from)(c); err != nil {
+ return err
+ }
+ c.Op.Phase = to
+ return nil
+ }
+}
+
+// ExecToNone is K3: the participant that holds execution and has written
+// stop evidence for this op gives it up, citing the evidence under
+// journalKey (evidence_source or evidence_target). Nobody moves exec from
+// one box to another. A replay after a crash finds exec already empty and
+// the same evidence journalled: that is idempotent, not a conflict.
+func ExecToNone(opID, box, evidenceKey, journalKey string) func(*Claim) error {
+ return func(c *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 c.Op.Exec == "" && c.Op.Journal[journalKey] == evidenceKey {
+ return nil
+ }
+ if c.Op.Exec != box {
+ return fmt.Errorf("%w: exec is %q, not %q", ErrWrongState, c.Op.Exec, box)
+ }
+ c.Op.Exec = ""
+ setJournal(c, journalKey, evidenceKey)
+ return nil
+ }
+}
+
+// phaseLandingChecking is the landing's first phase, the one the flip writes.
+const phaseLandingChecking = "checking"
+
+// Flip is the claim half of the record's flip (§7.5, K4): moving/sealed to
+// landing/checking, exec none to the target, citing the source's evidence.
+// holder.stamp becomes the gen this CAS produces (the target's installation
+// stamp F; Update sets gen = observed + 1 after the transition runs, as
+// CompleteCreate relies on); the source's stamp S and generation are
+// journalled for a rollback's return. holder.generation is not touched.
+//
+// Either participant may write it (G12: the target after a source crash),
+// so a second write of the same flip is a no-op: the claim is already
+// landing/checking for this op and target with the same journalled S and
+// generation. It does not restamp the holder. Any other flip over an
+// already-flipped claim is refused.
+func Flip(opID, targetBox string, sourceStamp uint64, srcGen string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ if c.Op.Kind == KindLanding && c.Op.Phase == phaseLandingChecking && c.Op.Exec == targetBox &&
+ len(c.Op.Participants) == 2 && c.Op.Participants[1] == targetBox &&
+ c.Op.Journal[JournalSourceStamp] == strconv.FormatUint(sourceStamp, 10) && c.Op.Journal[JournalSrcGen] == srcGen {
+ return nil
+ }
+ if err := RequireMoveOp(opID, KindMoving, PhaseSealed)(c); err != nil {
+ return err
+ }
+ if c.Holder == nil {
+ return fmt.Errorf("%w: the flip needs the holder", ErrWrongState)
+ }
+ if c.Op.Exec != "" || c.Op.Journal[JournalEvidenceSource] == "" {
+ return fmt.Errorf("%w: the flip needs exec none and the source's evidence", ErrWrongState)
+ }
+ 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)
+ }
+ c.Op.Kind, c.Op.Phase, c.Op.Exec = KindLanding, phaseLandingChecking, targetBox
+ setJournal(c, JournalSourceStamp, strconv.FormatUint(sourceStamp, 10))
+ setJournal(c, JournalSrcGen, srcGen)
+ c.Holder.Stamp = c.Gen + 1
+ return nil
+ }
+}
+
+// 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.
+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 != 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)
+ }
+ if len(c.Op.Participants) == 0 || sourceBox == "" || c.Op.Participants[0] != sourceBox {
+ return fmt.Errorf("%w: %q is not the move's source", ErrWrongState, sourceBox)
+ }
+ 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 {
+ 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)
+ }
+ c.Op.Kind, c.Op.Exec = KindReverting, sourceBox
+ if c.Op.Journal[JournalRevPhase] == "" {
+ setJournal(c, JournalRevPhase, "evidence")
+ }
+ for _, o := range c.Obligations {
+ if o.Op == opID && o.Kind == "park-replica" && o.Region == targetRegion {
+ return nil
+ }
+ }
+ s := *stagingScope
+ c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-replica", Region: targetRegion, Retirement: newID(), Owner: "region:" + targetRegion, Park: &s})
+ return nil
+ }
+}
diff --git a/internal/hostclaim/move_test.go b/internal/hostclaim/move_test.go
index c796fd79f..da724862c 100644
--- a/internal/hostclaim/move_test.go
+++ b/internal/hostclaim/move_test.go
@@ -1,11 +1,15 @@
package hostclaim
import (
+ "context"
"encoding/json"
"errors"
+ "fmt"
"reflect"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/release"
)
const (
@@ -278,3 +282,296 @@ func mustJSON(t *testing.T, c *Claim) []byte {
}
return b
}
+
+const stoppedSource = "moves/bab/app/h/mv/stopped-source.json"
+
+// movingToSealed walks a fresh moving claim to the state the flip needs:
+// source evidence journalled, exec none, phase sealed.
+func movingToSealed(t *testing.T) *Claim {
+ t.Helper()
+ c := movingTestClaim(t)
+ 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),
+ SetJournal(moveA, map[string]string{JournalSeal: `{"n":7}`, JournalCutSession: "5"}),
+ SetMovePhase(moveA, KindMoving, PhaseMoving, PhaseSealed),
+ } {
+ if err := tr(c); err != nil {
+ t.Fatalf("step %d: %v", i, err)
+ }
+ }
+ return c
+}
+
+func TestCutClaimCASesFollowK3AndK4(t *testing.T) {
+ c := movingTestClaim(t)
+ op := moveA
+ if err := BeginMove(op, "ops", "ie-box", "enr-ie", "uk-box", "uk", time.Now())(c); err != nil {
+ t.Fatal(err)
+ }
+ // Only the executor gives execution up, and only citing evidence.
+ if err := ExecToNone(op, "uk-box", stoppedSource, JournalEvidenceSource)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("non-exec to none: %v", err)
+ }
+ if err := ExecToNone(op, "ie-box", "", JournalEvidenceSource)(c); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("no evidence: %v", err)
+ }
+ if err := ExecToNone(op, "ie-box", stoppedSource, JournalSeal)(c); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("evidence under a non-evidence key: %v", err)
+ }
+ if err := ExecToNone(moveB, "ie-box", stoppedSource, JournalEvidenceSource)(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("another op: %v", err)
+ }
+ // The flip needs the seal phase and exec none.
+ if err := Flip(op, "uk-box", 3, "")(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("flip before sealed: %v", err)
+ }
+ if err := ExecToNone(op, "ie-box", stoppedSource, JournalEvidenceSource)(c); err != nil || c.Op.Exec != "" {
+ t.Fatalf("to none: %v %+v", err, c.Op)
+ }
+ // A crash replay of the same give-up is idempotent; other evidence is not.
+ if err := ExecToNone(op, "ie-box", stoppedSource, JournalEvidenceSource)(c); err != nil {
+ t.Fatalf("replay: %v", err)
+ }
+ if err := ExecToNone(op, "ie-box", "moves/other.json", JournalEvidenceSource)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("replay with other evidence: %v", err)
+ }
+ // 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)
+ }
+ if err := SetJournal(op, map[string]string{JournalSeal: `{"n":7}`, JournalCutSession: "5"})(c); err != nil || c.Op.Journal[JournalSeal] != `{"n":7}` {
+ t.Fatalf("journal: %v", err)
+ }
+ if err := SetJournal(moveB, map[string]string{JournalSeal: "x"})(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("journal under another op: %v", err)
+ }
+ if err := SetMovePhase(op, KindLanding, PhaseMoving, PhaseSealed)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("phase under the wrong kind: %v", err)
+ }
+ if err := SetMovePhase(op, KindMoving, PhaseMoving, PhaseSealed)(c); err != nil || c.Op.Phase != PhaseSealed {
+ t.Fatalf("phase: %v", err)
+ }
+ if err := SetMovePhase(op, KindMoving, PhaseMoving, PhaseSealed)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatal("phase replay")
+ }
+ // The flip's target must be the move's target.
+ if err := Flip(op, "us-box", 3, "")(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("flip to a stranger: %v", err)
+ }
+ before := c.Holder.Stamp
+ c.Gen = 9 // what Update will have set
+ if err := Flip(op, "uk-box", before, "")(c); err != nil {
+ t.Fatal(err)
+ }
+ if c.Op.Kind != KindLanding || c.Op.Phase != "checking" || c.Op.Exec != "uk-box" || c.Op.Journal[JournalSourceStamp] != "3" || c.Op.Journal[JournalSrcGen] != "" || c.Holder.Stamp != 10 {
+ t.Fatalf("after flip: %+v stamp %d", c.Op, c.Holder.Stamp)
+ }
+ // holder.generation is untouched by the flip (section 7.5).
+ if c.Holder.Generation != "" {
+ t.Fatal("flip moved the generation")
+ }
+}
+
+// Flip is written by either participant (G12): the target may write it
+// after a source crash. A second write of the same flip is a no-op that
+// does not restamp the holder; a different flip is refused.
+func TestFlipIsIdempotentForEitherWriter(t *testing.T) {
+ c := movingToSealed(t)
+ c.Gen = 9
+ if err := Flip(moveA, "uk-box", 3, "gen-7")(c); err != nil {
+ t.Fatal(err)
+ }
+ c.Gen = 10 // the replay observes the claim the flip produced
+ op := c.Op
+ if err := Flip(moveA, "uk-box", 3, "gen-7")(c); err != nil {
+ t.Fatalf("replay: %v", err)
+ }
+ if c.Holder.Stamp != 10 || !reflect.DeepEqual(c.Op, op) {
+ t.Fatalf("replay moved the claim: %+v stamp %d", c.Op, c.Holder.Stamp)
+ }
+ 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"),
+ } {
+ if err := fn(c); err == nil {
+ t.Fatalf("%s: a different flip was accepted", name)
+ }
+ }
+ // Once the landing has moved past checking, the replay is stale.
+ c.Op.Phase = "committing"
+ if err := Flip(moveA, "uk-box", 3, "gen-7")(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("replay after the landing moved on: %v", err)
+ }
+}
+
+// The flip's holder.stamp is the gen the CAS produces: run it through the
+// real Update and compare.
+func TestFlipStampIsTheGenUpdateProduces(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ c := movingToSealed(t)
+ key, err := Key(c.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, key, mustJSON(t, c), ""); err != nil {
+ t.Fatal(err)
+ }
+ o, err := Read(ctx, s, c.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = Update(ctx, s, o, Flip(moveA, "uk-box", c.Holder.Stamp, "gen-7"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Claim.Holder.Stamp != o.Claim.Gen || o.Claim.Gen != c.Gen+1 {
+ t.Fatalf("stamp %d gen %d, want both %d", o.Claim.Holder.Stamp, o.Claim.Gen, c.Gen+1)
+ }
+ if o.Claim.Op.Journal[JournalSourceStamp] != "3" || o.Claim.Op.Journal[JournalSrcGen] != "gen-7" {
+ t.Fatalf("journal: %v", o.Claim.Op.Journal)
+ }
+}
+
+func TestRevertNeedsEvidenceAndReturnsExecToSource(t *testing.T) {
+ c := movingTestClaim(t)
+ op := moveA
+ if err := BeginMove(op, "ops", "ie-box", "enr-ie", "uk-box", "uk", time.Now())(c); err != nil {
+ t.Fatal(err)
+ }
+ scope := ParkScope{Location: "moves/" + c.Host + "/" + op, Incarnation: c.Holder.Incarnation, EvidenceAt: time.Now(), RequestLimit: 120 * time.Second}
+ ids := 0
+ newID := func() string { ids++; return fmt.Sprintf("id%d", ids) }
+ if err := Revert(op, "ie-box", "uk", &scope, newID)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("revert while exec held: %v", err)
+ }
+ if err := ExecToNone(op, "ie-box", stoppedSource, JournalEvidenceSource)(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := Revert(op, "uk-box", "uk", &scope, newID)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("revert to the target: %v", err)
+ }
+ if err := Revert(op, "ie-box", "se", &scope, newID)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("revert naming a region the move never targeted: %v", err)
+ }
+ if err := Revert(op, "ie-box", "uk", nil, newID)(c); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("revert with no staging scope: %v", err)
+ }
+ live := scope
+ live.Location = c.Holder.Location
+ 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)
+ }
+ if c.Op.Kind != KindMoving || len(c.Obligations) != 0 {
+ t.Fatalf("a refused revert left changes: %+v", c.Op)
+ }
+ if err := Revert(op, "ie-box", "uk", &scope, newID)(c); err != nil || c.Op.Kind != KindReverting || c.Op.Exec != "ie-box" || c.Op.Journal[JournalRevPhase] != "evidence" {
+ t.Fatalf("revert: %v %+v", err, c.Op)
+ }
+ if len(c.Obligations) != 1 || c.Obligations[0].Kind != "park-replica" || c.Obligations[0].Region != "uk" || c.Obligations[0].Park == nil ||
+ c.Obligations[0].Owner != "region:uk" || c.Obligations[0].Op != op || c.Obligations[0].Park.Location != scope.Location {
+ t.Fatalf("revert queued no target staging park: %+v", c.Obligations)
+ }
+ c.Gen = 9
+ if err := c.Validate(); err != nil {
+ t.Fatalf("claim after revert must validate: %v", err)
+ }
+}
+
+// A revert keeps rev_phase if a replay already set it, and queues the
+// staging park once even when one is already owed under the op.
+func TestRevertKeepsRevPhaseAndQueuesTheParkOnce(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(), RequestLimit: time.Minute}
+ c.Obligations = []Obligation{{ID: "o1", Op: moveA, Kind: "park-replica", Region: "uk", Retirement: "r1", Owner: "region:uk", Park: &scope}}
+ if err := SetJournal(moveA, map[string]string{JournalRevPhase: "staging"})(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := Revert(moveA, "ie-box", "uk", &scope, counter())(c); err != nil {
+ t.Fatal(err)
+ }
+ if c.Op.Journal[JournalRevPhase] != "staging" || len(c.Obligations) != 1 {
+ t.Fatalf("rev_phase %q, obligations %+v", c.Op.Journal[JournalRevPhase], c.Obligations)
+ }
+}
+
+// The cut's transitions own only the op, the journal, the revert's staging
+// park and (the flip) the holder's stamp; every other field is carried
+// through unchanged.
+func TestCutTransitionsPreserveTheRestOfTheClaim(t *testing.T) {
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ fixture := func() *Claim {
+ 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)
+ }
+ return c
+ }
+ scope := ParkScope{Location: "moves/a.go.example.com/" + moveA, Incarnation: fixture().Holder.Incarnation, EvidenceAt: now, RequestLimit: time.Minute}
+ begin := func() []func(*Claim) error {
+ return []func(*Claim) error{
+ BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now),
+ ExecToNone(moveA, "ie-box", stoppedSource, JournalEvidenceSource),
+ }
+ }
+ cases := map[string]struct {
+ steps []func(*Claim) error
+ restamp bool // the flip restamps the holder
+ parks int // staging parks the transition queues
+ }{
+ "flip": {restamp: true, steps: append(begin(),
+ SetJournal(moveA, map[string]string{JournalSeal: `{"n":7}`}),
+ SetMovePhase(moveA, KindMoving, PhaseMoving, PhaseSealed),
+ Flip(moveA, "uk-box", 3, "gen-7"))},
+ "revert": {parks: 1, steps: append(begin(), Revert(moveA, "ie-box", "uk", &scope, counter()))},
+ }
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ c := fixture()
+ before := mustJSON(t, c)
+ for i, tr := range tc.steps {
+ if err := tr(c); err != nil {
+ t.Fatalf("step %d: %v", i, err)
+ }
+ }
+ if len(c.Obligations) != 1+tc.parks || !reflect.DeepEqual(c.Obligations[0], fixture().Obligations[0]) {
+ t.Fatalf("obligations of other ops changed: %+v", c.Obligations)
+ }
+ var round Claim
+ if err := json.Unmarshal(before, &round); err != nil {
+ t.Fatal(err)
+ }
+ round.Op, round.Obligations = c.Op, c.Obligations
+ if tc.restamp {
+ round.Holder.Stamp = c.Holder.Stamp
+ }
+ if !reflect.DeepEqual(round, *c) {
+ t.Fatalf("%s changed more than it owns:\nbefore %s\nafter %s", name, before, mustJSON(t, c))
+ }
+ c.Gen++ // what Update would do
+ if err := c.Validate(); err != nil {
+ t.Fatalf("after %s: %v", name, err)
+ }
+ })
+ }
+}