host: the moves evidence provider answers for a bare-location holder
A move cuts only a holder at the activator's own lease location, instances/<host> (ruling T5-lease), so every moved host's holder is there. The provider refused that form, which would have blocked every moved host's lane sweep (controller finding, Plan 12 review). The holder may now be at the bare or the incarnation location, the two forms the claim allows. VerifyRetirement judges residue outside the live location by park.Decide's own partition: a 64-hex child directory is its own incarnation location, everything else is the bare one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+66
−7
internal/host/movesevidence.go+25 −4internal/host/movesevidence_test.go+41 −3
diff --git a/internal/host/movesevidence.go b/internal/host/movesevidence.go| index 5393648f3..5f9d83bf2 100644 |
| --- a/internal/host/movesevidence.go |
| +++ b/internal/host/movesevidence.go |
| @@ -37,6 +37,7 @@ package host |
| import ( |
| "context" |
| + "encoding/hex" |
| "errors" |
| "fmt" |
| "strings" |
| @@ -99,15 +100,32 @@ func (e movesRetirementEvidence) VerifyRetirement(ctx context.Context, o hostcla |
| if err != nil { |
| return "", time.Time{}, err |
| } |
| - live := r.location + "/" |
| for _, v := range versions { |
| - if strings.HasPrefix(v.Key, "instances/"+r.host+"/") && !strings.HasPrefix(v.Key, live) { |
| + loc, ok := movesInstancesLocation(r.host, v.Key) |
| + if ok && loc != r.location { |
| return "", time.Time{}, &ClaimDependencyError{Contracts: []string{movesLegacyCensus}} |
| } |
| } |
| return r.incarnation, r.at, nil |
| } |
| +// movesInstancesLocation is the location park.Decide files an |
| +// instances/<host>/ key under (park's locationOf): a 64-hex first segment |
| +// with a child is its own incarnation location, and everything else is the |
| +// bare instances/<host>. ok is false outside instances/<host>/. |
| +func movesInstancesLocation(host, key string) (string, bool) { |
| + base := "instances/" + host |
| + tail, ok := strings.CutPrefix(key, base+"/") |
| + if !ok { |
| + return "", false |
| + } |
| + first, _, child := strings.Cut(tail, "/") |
| + if _, err := hex.DecodeString(first); child && err == nil && len(first) == 64 { |
| + return base + "/" + first, true |
| + } |
| + return base, true |
| +} |
| + |
| // retired proves the newest retired move from the claim observation, the |
| // holder's record and the complete census, or refuses. |
| func (e movesRetirementEvidence) retired(ctx context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (movesRetired, error) { |
| @@ -132,9 +150,12 @@ func (e movesRetirementEvidence) retired(ctx context.Context, o hostclaim.Observ |
| if h.Family != "instance" || h.Key != provision.RecordKey(h.Account, h.App, host) { |
| return refuse(false, "the holder is not an instance record") |
| } |
| + // A moved holder is at the bare location, the activator's own lease |
| + // (ruling T5-lease, movescut.go writerLease); a scoped holder is at its |
| + // incarnation's. Anything else is a location the census cannot place. |
| location := strings.TrimSuffix(h.Location, "/") |
| - if location != "instances/"+host+"/"+h.Incarnation { |
| - return movesRetired{}, &ClaimDependencyError{Contracts: []string{movesLegacyCensus}} |
| + if location != "instances/"+host && location != "instances/"+host+"/"+h.Incarnation { |
| + return refuse(true, "the holder is at %s, neither the bare nor the incarnation location", h.Location) |
| } |
| rec, err := e.Records(ctx, h.Account, h.App, host) |
| if err != nil { |
diff --git a/internal/host/movesevidence_test.go b/internal/host/movesevidence_test.go| index a3d7afc9d..14a0f8bef 100644 |
| --- a/internal/host/movesevidence_test.go |
| +++ b/internal/host/movesevidence_test.go |
| @@ -233,10 +233,11 @@ func TestVerifyRetirementRefusesLegacyResidue(t *testing.T) { |
| } |
| }) |
| } |
| - t.Run("a holder in the legacy location form", func(t *testing.T) { |
| + t.Run("an incarnation directory beside a bare holder", func(t *testing.T) { |
| f := newEvFixture(t) |
| - f.setClaim(t, func(c *hostclaim.Claim) { c.Holder.Location = "instances/" + evHost }) |
| - f.bucket.Put("data", "instances/"+evHost+"/0000/db", []byte("live")) |
| + f.bare(t) |
| + f.bucket.Put("data", "instances/"+evHost+"/g-live/0000/db", []byte("live")) |
| + f.bucket.Put("data", "instances/"+evHost+"/"+f.inc+"/g-old/0000/db", []byte("scoped")) |
| _, _, err := f.provider().VerifyRetirement(t.Context(), f.observe(t), f.census(t)) |
| var dep *ClaimDependencyError |
| if !errors.As(err, &dep) { |
| @@ -245,6 +246,43 @@ func TestVerifyRetirementRefusesLegacyResidue(t *testing.T) { |
| }) |
| } |
| +// bare puts the holder at the activator's own lease location, |
| +// instances/<host>: the only holder a move cuts (ruling T5-lease, |
| +// movescut.go writerLease), so every moved host's holder. |
| +func (f *evFixture) bare(t *testing.T) { |
| + t.Helper() |
| + f.location = "instances/" + evHost |
| + f.setClaim(t, func(c *hostclaim.Claim) { c.Holder.Location = f.location }) |
| +} |
| + |
| +// TestMovedHostAtABareLocationIsSwept (controller finding, Plan 12 review): |
| +// a moved host's holder is at the bare location, and its objects there are |
| +// its live location's, not residue outside it. The provider answers, and |
| +// the worker queues the moves/<host> lane chore. |
| +func TestMovedHostAtABareLocationIsSwept(t *testing.T) { |
| + f := newEvFixture(t) |
| + f.bare(t) |
| + f.lane(evMoveID, "move.json") |
| + f.lane(evMoveID, "replica/0000/db") |
| + f.bucket.Put("data", f.location+"/g-live/0000/db", []byte("live")) |
| + obs, census := f.observe(t), f.census(t) |
| + inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census) |
| + if err != nil || inc != f.inc || !at.Equal(evAt) { |
| + t.Fatalf("VerifyRetirement = %q, %v, %v; want %q, %v", inc, at, err, f.inc, evAt) |
| + } |
| + proof, err := f.provider().VerifyMovesSweep(t.Context(), obs, census) |
| + if err != nil || proof.Incarnation != f.inc || !proof.RetiredAt.Equal(evAt) { |
| + t.Fatalf("proof = %+v, %v", proof, err) |
| + } |
| + if err := f.worker(t, evNow).Run(t.Context()); !errors.Is(err, park.ErrPending) { |
| + t.Fatalf("the lane chore waits for its horizon: %v", err) |
| + } |
| + moves, ok := f.chores(t)["moves/"+evHost] |
| + if !ok || moves.Incarnation != f.inc || !moves.EvidenceAt.Equal(evNow) { |
| + t.Fatalf("chores = %+v; want the moves/<host> chore at the listing's end", f.chores(t)) |
| + } |
| +} |
| + |
| // TestMovesSweepProofRefusesALiveMove (K10, M8): no proof while a move op |
| // is on the claim, while the record carries a live move, or while the |
| // claim was released by a move the record has not closed. |