carlos / platform Public

region HA 1b-next R9/J3 fix round 1: ReadPin binds the release; real conversion in the worker test

- R9-1: ReadPin refuses a pin object whose release is not the journaled
  copy's (or is empty), so install_release can only be the object's own.
- J3-5: the worker test now drives an admitted move through the claim's
  own transitions to a probed landing, terminates the target and converts
  it with ConvertCommittedLanding, so the worker is proven against the
  conversion's real intent, journal and obligations (kept byte for byte
  through to installing). The refusal table gains "claim committed,
  record not committed".
- J3-6: the final phase check in the conversion CAS is kept and marked
  defensive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev b250a1fc0f3ecf6e70d3d3a76322ece3fc570af3 parent 2ced0c5
5 files changed, +102 −22
  • internal/hostclaim/relocating.go +4 −0
  • internal/hostclaim/relocating_test.go +9 −3
  • internal/hostrelocate/pin.go +7 −2
  • internal/hostrelocate/pin_test.go +9 −0
  • internal/hostrelocate/worker_test.go +73 −17

CI failed — run details

diff --git a/internal/hostclaim/relocating.go b/internal/hostclaim/relocating.go
index ef0ae2d36..21287537a 100644
--- a/internal/hostclaim/relocating.go
+++ b/internal/hostclaim/relocating.go
@@ -278,6 +278,10 @@ func ConvertCommittedLanding(ctx context.Context, s release.Store, observed Obse
return err
}
}
+ // Defensive: SetMovePhase and Commit above leave every accepted
+ // phase at committed, so this cannot fire today. It keeps the
+ // conversion from running over a landing the commit did not reach
+ // if either helper ever changes.
if c.Op.Phase != PhaseLandingCommitted {
return ErrWrongState
}
diff --git a/internal/hostclaim/relocating_test.go b/internal/hostclaim/relocating_test.go
index b2cdb4825..c08dd6718 100644
--- a/internal/hostclaim/relocating_test.go
+++ b/internal/hostclaim/relocating_test.go
@@ -557,13 +557,19 @@ func TestJ3CommittedLandingBecomesAFailoverInTheTargetsRegion(t *testing.T) {
func TestJ3ConversionRefusesWithoutWriting(t *testing.T) {
for _, tc := range []struct{ name string }{
- {"record-not-committed"}, {"record-rolled-back-during-verify"}, {"record-elsewhere"}, {"claim-before-probing"}, {"not-landing"},
+ {"record-not-committed"}, {"record-not-committed-claim-committed"}, {"record-rolled-back-during-verify"}, {"record-elsewhere"}, {"claim-before-probing"}, {"not-landing"},
{"wrong-op"}, {"wrong-box"}, {"source-box"}, {"fence-region"}, {"stale-fence"}, {"fence-gone"}, {"target-lost"}, {"journal-rebind"},
{"exec-elsewhere"}, {"no-guard"}, {"no-ids"}, {"missing-admission"}, {"missing-enrollment"}, {"authority"},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
- s, o := j3Landing(t, PhaseLandingProbed)
+ phase := PhaseLandingProbed
+ if tc.name == "record-not-committed-claim-committed" {
+ // The claim says committed; the record does not. The record
+ // is the evidence, so the conversion refuses.
+ phase = PhaseLandingCommitted
+ }
+ s, o := j3Landing(t, phase)
ref := j3Fence(t, s)
s.puts = 0
id, box := moveA, j3Target
@@ -571,7 +577,7 @@ func TestJ3ConversionRefusesWithoutWriting(t *testing.T) {
var authority FenceAuthority = j3Authority{}
newID := counter()
switch tc.name {
- case "record-not-committed":
+ case "record-not-committed", "record-not-committed-claim-committed":
guard = &j3Guard{rejectAt: 1} // moves' declared-loss rollback owns the fence
case "record-rolled-back-during-verify":
guard = &j3Guard{rejectAt: 2}
diff --git a/internal/hostrelocate/pin.go b/internal/hostrelocate/pin.go
index 21d1ab0fd..dc8e635fb 100644
--- a/internal/hostrelocate/pin.go
+++ b/internal/hostrelocate/pin.go
@@ -321,8 +321,8 @@ func referencedOrigins(location string, origins []string, m hostfiles.Manifest)
}
// ReadPin reads and verifies the pin object a journaled RecoveryPin names:
-// the exact bytes hash to the journaled digest (ruling 5). Every reader of
-// a pin goes through it.
+// the exact bytes hash to the journaled digest (ruling 5), and the
+// journaled release is the object's. Every reader of a pin goes through it.
func ReadPin(ctx context.Context, claims release.Store, ref hostclaim.RecoveryPin) (PinObject, error) {
raw, _, err := claims.Get(ctx, ref.Key)
if err != nil {
@@ -335,6 +335,11 @@ func ReadPin(ctx context.Context, claims release.Store, ref hostclaim.RecoveryPi
if err := json.Unmarshal(raw, &obj); err != nil || obj.V != 1 {
return PinObject{}, fmt.Errorf("%w: pin object %s", ErrRefused, ref.Key)
}
+ // The journal's copy of the release is what install_release copies
+ // (R9): it must be the object's own.
+ if obj.Release == "" || obj.Release != ref.Release {
+ return PinObject{}, fmt.Errorf("%w: pin object %s names release %q, the journal %q", ErrRefused, ref.Key, obj.Release, ref.Release)
+ }
return obj, nil
}
diff --git a/internal/hostrelocate/pin_test.go b/internal/hostrelocate/pin_test.go
index 8dd446462..eabb20cdf 100644
--- a/internal/hostrelocate/pin_test.go
+++ b/internal/hostrelocate/pin_test.go
@@ -573,6 +573,15 @@ func TestStorePinnerPinsTheRouteRelease(t *testing.T) {
if obj, err := ReadPin(ctx, claims, pin); err != nil || obj.Release != "v1.4.2" {
t.Fatalf("object: %+v %v", obj, err)
}
+ // A journaled copy whose release is not the object's is refused, though
+ // the bytes still hash to the digest.
+ for _, rel := range []string{"v1.5.0", ""} {
+ forged := pin
+ forged.Release = rel
+ if _, err := ReadPin(ctx, claims, forged); !errors.Is(err, ErrRefused) {
+ t.Fatalf("read a pin whose journaled release %q is not the object's: %v", rel, err)
+ }
+ }
// The route moves on; a retry at the same generation reuses the stored
// object, its release included, and does not ask the route again.
route = "v1.5.0"
diff --git a/internal/hostrelocate/worker_test.go b/internal/hostrelocate/worker_test.go
index 4305a7ecc..3095526ff 100644
--- a/internal/hostrelocate/worker_test.go
+++ b/internal/hostrelocate/worker_test.go
@@ -4,6 +4,8 @@ import (
"context"
"crypto/ed25519"
"crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -883,39 +885,93 @@ func TestWorkerRefusesAConvertedMoveWhoseBoundFenceIsMissing(t *testing.T) {
}
}
+// committedGuard is moves' record for the landing: committed, from uk
+// (the move's source region) to ie, this worker's region.
+type committedGuard struct{ op string }
+
+func (g committedGuard) CheckLandingCommitted(_ context.Context, o hostclaim.Observed, id string) (hostclaim.CommittedLanding, error) {
+ if id != g.op || o.Claim.Op.ID != g.op {
+ return hostclaim.CommittedLanding{}, hostclaim.ErrWrongState
+ }
+ h := o.Claim.Holder
+ at := time.Date(2026, 10, 7, 8, 0, 0, 0, time.UTC)
+ return hostclaim.CommittedLanding{FromRegion: "uk", ToRegion: "ie",
+ SourceScope: hostclaim.ParkScope{Location: h.Location, Incarnation: h.Incarnation, EvidenceAt: at, RequestLimit: 120 * time.Second},
+ StagingScope: hostclaim.ParkScope{Location: "moves/" + host + "/" + g.op, Incarnation: h.Incarnation, EvidenceAt: at, RequestLimit: 120 * time.Second}}, nil
+}
+
+// convertedLanding drives the host through an admitted move from src (in
+// uk, for the move) to dst through the claim's own transitions to a probed
+// landing, terminates dst, and converts it with ConvertCommittedLanding:
+// the claim the worker then finds is the conversion's real output.
+func (f *fixture) convertedLanding() hostclaim.Observed {
+ f.t.Helper()
+ ctx := context.Background()
+ const id = "mv_abcdefghijklmnopqrstuvwxyz"
+ o := f.claim()
+ o = f.step(o, hostclaim.BeginAdmittedMove(id, "ops", "src", "dst", "ie", f.now, hostclaim.MoveAdmission{AppID: "app-canonical", AdmissionID: id, SourceEnrollment: enrollment("src"), TargetEnrollment: enrollment("dst")}))
+ o = f.step(o, hostclaim.ExecToNone(id, "src", "moves/"+host+"/"+id+"/stopped-source.json", hostclaim.JournalEvidenceSource))
+ o = f.step(o, hostclaim.SetJournal(id, map[string]string{hostclaim.JournalSeal: `{"n":7}`, hostclaim.JournalCutSession: "1"}))
+ o = f.step(o, hostclaim.SetMovePhase(id, hostclaim.KindMoving, hostclaim.PhaseMoving, hostclaim.PhaseSealed))
+ o = f.step(o, hostclaim.Flip(id, "dst", o.Claim.Holder.Stamp, o.Claim.Holder.Generation))
+ for _, p := range [][2]string{{"checking", "parking"}, {"parking", "building"}, {"building", "published"}, {"published", "local"}, {"local", "configured"}, {"configured", "restored"}, {"restored", "probing"}, {"probing", "probed"}} {
+ o = f.step(o, hostclaim.SetMovePhase(id, hostclaim.KindLanding, p[0], p[1]))
+ }
+ // The probe's cold admission on dst (claim rev 6.9): its generation is
+ // holder.generation and dst the writer.
+ o = f.step(o, func(c *hostclaim.Claim) error {
+ h := c.Holder
+ h.Session++
+ h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Region: "ie", PredecessorRegion: "uk", Session: h.Session, Box: "dst", Generation: "gen-landed", Predecessor: h.Generation, Mode: "cold", Coverage: "sealed"})
+ h.Generation, h.WriterBox = "gen-landed", "dst"
+ return nil
+ })
+ f.terminate("dst")
+ enr := enrollment("dst")
+ raw, _, err := f.s.Get(ctx, hostfence.FenceKey(enr))
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ sum := sha256.Sum256(raw)
+ ref := hostclaim.FenceRef{Enrollment: enr, Key: hostfence.FenceKey(enr), SHA256: hex.EncodeToString(sum[:])}
+ n := 0
+ out, err := hostclaim.ConvertCommittedLanding(ctx, f.s, o, id, "dst", ref, f.w.Fences, committedGuard{op: id}, nil, func() string { n++; return fmt.Sprintf("mv-chore-%d", n) })
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ return out
+}
+
// J3: a committed landing converted to a failover (ConvertCommittedLanding)
// starts with no target. The worker names one in starting; it acknowledges
// before PinFailover, and the pin is selected after that, as an initial
-// failover's is; installing then copies the pin's release (R9).
+// failover's is; installing then copies the pin's release (R9). The move's
+// cleanup obligations ride through untouched.
func TestWorkerNamesAConvertedLandingsTargetBeforeItsPin(t *testing.T) {
f := newFixture(t)
- ctx := context.Background()
- f.terminate("src")
- if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonFailover, Source: "src", Target: "dst"}); err != nil {
- t.Fatal(err)
+ converted := f.convertedLanding()
+ c := converted.Claim
+ if c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != hostclaim.PhaseStarting || c.Op.Exec != "none" || c.Op.Intent["source_box"] != "dst" ||
+ c.Op.Intent["move_source_box"] != "src" || c.Op.Journal["conversion"] != "committed-landing" || len(c.Op.Participants) != 0 || len(c.Obligations) == 0 {
+ t.Fatalf("not the conversion's output: %+v obligations %d", c.Op, len(c.Obligations))
}
- // The shape the conversion leaves: starting, op.exec none, the dead box
- // the source (on lost, its fence consumed), no target, no participant.
- f.participant(func(c *hostclaim.Claim) error {
- delete(c.Op.Intent, "target_box")
- delete(c.Op.Intent, "target_enrollment")
- c.Op.Participants = nil
- c.Op.Journal["conversion"] = "committed-landing"
- return nil
- })
+ chores, _ := json.Marshal(c.Obligations)
o := f.worker(nil)
- if o.Claim.Op.Phase != hostclaim.PhaseStarting || o.Claim.Op.Intent["target_box"] != "dst" || !slices.Equal(o.Claim.Op.Participants, []string{"dst"}) || o.Claim.Op.Journal["pin"] != "" {
+ if o.Claim.Op.Phase != hostclaim.PhaseStarting || o.Claim.Op.Intent["target_box"] != "spa" || !slices.Equal(o.Claim.Op.Participants, []string{"spa"}) || o.Claim.Op.Journal["pin"] != "" {
t.Fatalf("named in starting: %+v", o.Claim.Op)
}
f.worker(ErrNotYet) // the target has not acknowledged: no pin yet
- f.ack("dst")
+ f.ack("spa")
if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhasePinning {
t.Fatalf("phase %s", o.Claim.Op.Phase)
}
if o = f.worker(nil); o.Claim.Op.Journal["pin"] == "" {
t.Fatal("not pinned")
}
- if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseInstalling || o.Claim.Op.Exec != "dst" || o.Claim.Op.Journal["install_release"] != "v1.4.2" {
+ if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseInstalling || o.Claim.Op.Exec != "spa" || o.Claim.Op.Journal["install_release"] != "v1.4.2" {
t.Fatalf("installing: %+v", o.Claim.Op)
}
+ if after, _ := json.Marshal(o.Claim.Obligations); string(after) != string(chores) {
+ t.Fatalf("the failover changed an obligation:\n%s\n%s", after, chores)
+ }
}