carlos / platform Public

host: moves evidence fix round 1 (the close vouches only for what the move retired)

Ruling 1: VerifyRetirement's one time binds every instances/<host>
candidate, and the worker has no per-candidate abstention. At the live
location it now refuses with a typed NotMovesEvidence unless every
non-current generation was retired by the move: superseded at or before
the move's own admission (the prepared landing's generation named after
the move), never admitted after it, every version older than the close.
Ordinary retirement owns the rest. Unclassifiable objects refuse too.

Ruling 2: the proof no longer needs the claim's released_move journal,
which any later serving op replaces (CompleteMetadataPlacement). The
durable proof is the record's LastMove bound to the holder's incarnation,
with the serving claim showing no move op; the live-move and lane checks
stay.

Ruling 3: the free-host lane proof is claim Plan 12's; the refusal stays,
its test renamed with a pointer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7e94ea3aa9d811480abf7d1c4be3eb05fad8ed3e parent 94a8d9f
2 files changed, +329 −41
  • internal/host/movesevidence.go +98 −19
  • internal/host/movesevidence_test.go +231 −22
diff --git a/internal/host/movesevidence.go b/internal/host/movesevidence.go
index 5f9d83bf2..149392519 100644
--- a/internal/host/movesevidence.go
+++ b/internal/host/movesevidence.go
@@ -16,20 +16,34 @@ package host
// valid only with the precondition proof (K10): the claim shows no move
// op, and the newest retired move's close is earlier than the listing.
//
-// The proof comes from the record's LastMove (M8). It holds only when:
+// The proof comes from the record's LastMove (M8, fix round 1 ruling 2): the
+// durable move id, outcome and close time, bound to the holder's
+// incarnation. It holds only when:
//
-// - the claim is serving and was released by LastMove's move;
+// - the claim is serving, so no move op is live on the host;
// - the record is the holder's own and carries no live move;
// - LastMove names a move closed at a real time before this clock;
// - every moves/<host>/<op>/ object is that move's own, or was written
// before its close.
//
-// With M4's record-first close, LastMove.At follows every park of the move's
-// lanes, and so every staging horizon. A zero time is never evidence.
+// The claim's released_move journal is not required: any later serving op
+// replaces it (CompleteMetadataPlacement, for one). With M4's record-first
+// close, LastMove is written before the claim's release, so it is the
+// serving claim, with no move op, that proves the release finished.
+// LastMove.At follows every park of the move's lanes, and so every staging
+// horizon. A zero time is never evidence.
//
-// VerifyRetirement also refuses while any instances/<host> residue lies
-// outside the holder's live location (M7). Moves keeps no archived history
-// for legacy or older incarnations; that census is claim Plan 12's.
+// VerifyRetirement answers for less than the proof covers (M7; fix round 1
+// ruling 1). The worker binds its one time to every instances/<host>
+// candidate, so it refuses unless the close is genuine evidence for all of
+// them:
+//
+// - instances/<host> residue outside the holder's live location has no
+// moves history (the archived-incarnation census is claim Plan 12's);
+// - at the live location the close covers only generations the move
+// retired: those superseded at or before the move's own admission. A
+// generation admitted from that admission on was written past the
+// close, and ordinary retirement owns it.
//
// Nothing registers this provider yet. Task 9a passes it to
// RegisterProductionClaimMoveParks, which reaches VerifyMovesSweep by type
@@ -70,6 +84,26 @@ func (e *MovesSweepRefusal) Error() string {
func (e *MovesSweepRefusal) Unwrap() error { return ErrMovesSweepRefused }
+// ErrNotMovesEvidence is what every NotMovesEvidence unwraps to.
+var ErrNotMovesEvidence = errors.New("host: the move's close is not evidence for this residue")
+
+// NotMovesEvidence is VerifyRetirement's refusal of residue at the live
+// location that the move did not retire: a generation written past the
+// move's close, or an object no generation owns. The close is not its
+// evidence, and ordinary retirement owns it. The worker binds one evidence
+// time to every instances/<host> candidate, so the provider cannot abstain
+// for one generation; this refusal skips the pass's sweep while such
+// residue stands. Reason is for the agent's log, never shown to a user.
+type NotMovesEvidence struct {
+ Host, Generation, Reason string
+}
+
+func (e *NotMovesEvidence) Error() string {
+ return "host: the move's close is not evidence for " + e.Host + " generation " + e.Generation + ": " + e.Reason
+}
+
+func (e *NotMovesEvidence) Unwrap() error { return ErrNotMovesEvidence }
+
// movesLegacyCensus is the contract VerifyRetirement waits on for residue it
// has no history for (M7: the legacy census is claim Plan 12's).
const movesLegacyCensus = "claim Plan 12 archived-incarnation census for instances/<host> residue outside the live location"
@@ -85,6 +119,7 @@ type movesRetirementEvidence struct {
type movesRetired struct {
host, incarnation, location, moveID string
at time.Time
+ holder hostclaim.Holder
}
func (e movesRetirementEvidence) VerifyMovesSweep(ctx context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (park.MoveSweepProof, error) {
@@ -106,9 +141,62 @@ func (e movesRetirementEvidence) VerifyRetirement(ctx context.Context, o hostcla
return "", time.Time{}, &ClaimDependencyError{Contracts: []string{movesLegacyCensus}}
}
}
+ if err := r.retiredGenerations(versions); err != nil {
+ return "", time.Time{}, err
+ }
return r.incarnation, r.at, nil
}
+// retiredGenerations refuses unless the close is evidence for every object
+// at the live location that the engine could make a candidate (fix round 1,
+// ruling 1). The marker key and the holder's current generation are the
+// live installation's. Any other generation must be one the move retired:
+// the claim must still hold the move's own admission (a prepared landing
+// admits the generation named after the move, movesprobeowners.go), the
+// generation must not be admitted at or after it (admissions are in claim
+// order and carry no time), and every version of it must predate the close.
+// A rolled-back or aborted move, or a landing that admitted a fresh name,
+// leaves no such admission, so no generation is vouched for. Objects are
+// classified as the landing's view classifies them (landingGenerationOf).
+func (r movesRetired) retiredGenerations(versions []lease.ObjectVersion) error {
+ h := r.holder
+ refuse := func(gen, format string, args ...any) error {
+ return &NotMovesEvidence{Host: r.host, Generation: gen, Reason: fmt.Sprintf(format, args...)}
+ }
+ moveAdmission := -1
+ for i := len(h.Admissions) - 1; i >= 0; i-- {
+ if h.Admissions[i].Generation == r.moveID {
+ moveAdmission = i
+ break
+ }
+ }
+ afterTheMove := map[string]bool{}
+ if moveAdmission >= 0 {
+ for _, a := range h.Admissions[moveAdmission:] {
+ afterTheMove[a.Generation] = true
+ }
+ }
+ for _, v := range versions {
+ if loc, ok := movesInstancesLocation(r.host, v.Key); !ok || loc != r.location {
+ continue
+ }
+ gen, ok := landingGenerationOf(r.location, v.Key)
+ switch {
+ case !ok:
+ return refuse("", "%s belongs to no generation", v.Key)
+ case gen == landingMarkerGeneration || gen == h.Generation:
+ continue
+ case moveAdmission < 0:
+ return refuse(gen, "the claim holds no admission of %s to date it by", r.moveID)
+ case afterTheMove[gen]:
+ return refuse(gen, "it was admitted after %s's admission, so written past the close", r.moveID)
+ case v.LastModified.IsZero() || !v.LastModified.Before(r.at):
+ return refuse(gen, "%s was written after %s closed", v.Key, r.moveID)
+ }
+ }
+ return 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
@@ -140,7 +228,8 @@ func (e movesRetirementEvidence) retired(ctx context.Context, o hostclaim.Observ
return movesRetired{}, &MovesSweepRefusal{Host: host, Reason: fmt.Sprintf(format, args...), Anomaly: anomaly}
}
if c.Holder == nil {
- // A free or joining claim names no record, so no move history.
+ // A free or joining claim names no record, so no move history. The
+ // free-host lane proof is claim Plan 12's (fix round 1, ruling 3).
return movesRetired{}, &ClaimDependencyError{Contracts: []string{movesLegacyCensus}}
}
if c.Op.Kind != hostclaim.KindServing {
@@ -175,16 +264,6 @@ func (e movesRetirementEvidence) retired(ctx context.Context, o hostclaim.Observ
if last == nil || last.ID == "" {
return refuse(false, "the record has closed no move")
}
- if err := hostclaim.RequireReleasedMove(last.ID)(c); err != nil {
- // A serving op names the move that released it until another op
- // replaces it. Any other name is a release the record never closed.
- // The key is hostclaim's journalReleasedMove; it only picks the
- // reason, RequireReleasedMove above is the check.
- if c.Op.Journal["released_move"] == "" {
- return refuse(false, "the serving claim names no released move")
- }
- return refuse(true, "the claim was released by %s, the record closed %s", c.Op.Journal["released_move"], last.ID)
- }
if last.At.IsZero() || !last.At.Before(e.Now()) {
return refuse(true, "the close of %s has no time before now", last.ID)
}
@@ -211,5 +290,5 @@ func (e movesRetirementEvidence) retired(ctx context.Context, o hostclaim.Observ
return refuse(true, "lane %s was written after %s closed", op, last.ID)
}
}
- return movesRetired{host: host, incarnation: h.Incarnation, location: location, moveID: last.ID, at: last.At.UTC()}, nil
+ return movesRetired{host: host, incarnation: h.Incarnation, location: location, moveID: last.ID, at: last.At.UTC(), holder: *h}, nil
}
diff --git a/internal/host/movesevidence_test.go b/internal/host/movesevidence_test.go
index 14a0f8bef..67a9e87e4 100644
--- a/internal/host/movesevidence_test.go
+++ b/internal/host/movesevidence_test.go
@@ -10,6 +10,7 @@ import (
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/lease/leasetest"
"github.com/carlosframework/platform/internal/park"
@@ -54,13 +55,29 @@ func newEvFixture(t *testing.T) *evFixture {
f.rec = &provision.Record{Schema: 1, Host: evHost, App: evApp, Account: evAcct, CreatedAt: evCreated,
LastMove: &provision.MoveOutcome{ID: evMoveID, Outcome: "done", At: evAt}}
f.setClaim(t, func(c *hostclaim.Claim) {
- c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey(evAcct, evApp, evHost), Account: evAcct, App: evApp, Incarnation: inc, CreatedAt: evCreated, Location: f.location, Stamp: 1, Session: 1}
+ c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey(evAcct, evApp, evHost), Account: evAcct, App: evApp, Incarnation: inc, CreatedAt: evCreated, Location: f.location, Stamp: 2, Session: 2, WriterBox: "box", Generation: evMoveID,
+ Admissions: evAdmissions(evMoveID)}
c.Op = servingReleasedBy(evMoveID)
c.Touched = hostclaim.Touched{Complete: true, Regions: []string{"ie"}}
})
return f
}
+// evAdmissions is the holder's admission history: an origin generation
+// "old", then the move's prepared landing, which admits the generation
+// named after the move (movesprobeowners.go), then any later generations,
+// each the next one's predecessor. The last is the holder's current.
+func evAdmissions(gens ...string) []hostclaim.AdmissionRecord {
+ out := []hostclaim.AdmissionRecord{{Session: 1, Box: "box", Generation: "old", Mode: "cold", Region: "ie"}}
+ for i, g := range gens {
+ out = append(out, hostclaim.AdmissionRecord{Session: uint64(i + 2), Box: "box", Generation: g, Predecessor: out[i].Generation, Mode: "cold", Region: "ie", PredecessorRegion: "ie"})
+ }
+ return out
+}
+
+// genKey is an object of generation gen at the fixture's live location.
+func (f *evFixture) genKey(gen string) string { return f.location + "/g-" + gen + "/0000/db" }
+
// servingReleasedBy is the serving op hostclaim.ReleaseMove leaves.
func servingReleasedBy(id string) hostclaim.Op {
return hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box", Journal: map[string]string{"released_move": id}}
@@ -185,7 +202,7 @@ func TestMovesSweepProofRetiredAtIsTheClose(t *testing.T) {
func TestVerifyRetirementLabelsMovesResidueWithTheProofsIncarnation(t *testing.T) {
f := newEvFixture(t)
f.lane(evMoveID, "move.json")
- f.bucket.Put("data", f.location+"/g-live/0000/db", []byte("live"))
+ f.bucket.Put("data", f.genKey(evMoveID), []byte("live"))
obs, census := f.observe(t), f.census(t)
inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census)
if err != nil {
@@ -220,7 +237,7 @@ func TestVerifyRetirementRefusesLegacyResidue(t *testing.T) {
t.Run(name, func(t *testing.T) {
f := newEvFixture(t)
f.lane(evMoveID, "move.json")
- f.bucket.Put("data", f.location+"/g-live/0000/db", []byte("live"))
+ f.bucket.Put("data", f.genKey(evMoveID), []byte("live"))
f.bucket.Put("data", key, []byte("legacy"))
obs, census := f.observe(t), f.census(t)
inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census)
@@ -236,7 +253,7 @@ func TestVerifyRetirementRefusesLegacyResidue(t *testing.T) {
t.Run("an incarnation directory beside a bare holder", func(t *testing.T) {
f := newEvFixture(t)
f.bare(t)
- f.bucket.Put("data", "instances/"+evHost+"/g-live/0000/db", []byte("live"))
+ f.bucket.Put("data", f.genKey(evMoveID), []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
@@ -264,7 +281,7 @@ func TestMovedHostAtABareLocationIsSwept(t *testing.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"))
+ f.bucket.Put("data", f.genKey(evMoveID), []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) {
@@ -283,6 +300,86 @@ func TestMovedHostAtABareLocationIsSwept(t *testing.T) {
}
}
+// TestVerifyRetirementVouchesOnlyForWhatTheMoveRetired (fix round 1,
+// ruling 1): the close is evidence only for what the move retired. At the
+// live location that is a generation superseded at or before the move's
+// own admission, every version of it older than the close. A generation
+// admitted from the move's admission on was written past the close, so the
+// provider has no evidence for it and says so with a typed error; the
+// moves lane proof is unaffected.
+func TestVerifyRetirementVouchesOnlyForWhatTheMoveRetired(t *testing.T) {
+ answers := func(t *testing.T, f *evFixture) {
+ t.Helper()
+ inc, at, err := f.provider().VerifyRetirement(t.Context(), f.observe(t), f.census(t))
+ if err != nil || inc != f.inc || !at.Equal(evAt) {
+ t.Fatalf("VerifyRetirement = %q, %v, %v; want %q, %v", inc, at, err, f.inc, evAt)
+ }
+ }
+ notEvidence := func(t *testing.T, f *evFixture, gen string) {
+ t.Helper()
+ obs, census := f.observe(t), f.census(t)
+ inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census)
+ var ne *NotMovesEvidence
+ if !errors.As(err, &ne) || inc != "" || !at.IsZero() {
+ t.Fatalf("VerifyRetirement = %q, %v, %v; want a *NotMovesEvidence and no evidence", inc, at, err)
+ }
+ if ne.Host != evHost || ne.Generation != gen || ne.Reason == "" {
+ t.Fatalf("refusal = %+v, want generation %q", ne, gen)
+ }
+ if _, err := f.provider().VerifyMovesSweep(t.Context(), obs, census); err != nil {
+ t.Fatalf("the lane proof does not depend on the generations: %v", err)
+ }
+ }
+ t.Run("the current generation and the marker", func(t *testing.T) {
+ f := newEvFixture(t)
+ f.bucket.Put("data", f.genKey(evMoveID), []byte("live"))
+ f.bucket.Put("data", f.location+"/generation.json", []byte("marker"))
+ answers(t, f)
+ })
+ t.Run("a generation the move's admission superseded", func(t *testing.T) {
+ f := newEvFixture(t)
+ f.bucket.Put("data", f.genKey("old"), []byte("old"))
+ f.bucket.Put("data", f.location+"/files/g-old/a.txt", []byte("old"))
+ answers(t, f)
+ })
+ t.Run("the move's generation superseded after the close", func(t *testing.T) {
+ f := newEvFixture(t)
+ f.setClaim(t, func(c *hostclaim.Claim) {
+ c.Holder.Admissions, c.Holder.Generation, c.Holder.Session, c.Holder.Stamp = evAdmissions(evMoveID, "later"), "later", 3, 3
+ })
+ f.bucket.Put("data", f.genKey(evMoveID), []byte("superseded after the close"))
+ f.bucket.Put("data", f.genKey("later"), []byte("live"))
+ notEvidence(t, f, evMoveID)
+ })
+ t.Run("a later generation superseded after the close", func(t *testing.T) {
+ f := newEvFixture(t)
+ f.setClaim(t, func(c *hostclaim.Claim) {
+ c.Holder.Admissions, c.Holder.Generation, c.Holder.Session, c.Holder.Stamp = evAdmissions(evMoveID, "later", "latest"), "latest", 4, 4
+ })
+ f.bucket.Put("data", f.genKey("later"), []byte("superseded after the close"))
+ notEvidence(t, f, "later")
+ })
+ t.Run("no admission of the move in the claim", func(t *testing.T) {
+ f := newEvFixture(t)
+ f.setClaim(t, func(c *hostclaim.Claim) {
+ c.Holder.Admissions, c.Holder.Generation = evAdmissions("fresh"), "fresh"
+ })
+ f.bucket.Put("data", f.genKey("old"), []byte("old"))
+ notEvidence(t, f, "old")
+ })
+ t.Run("a retired generation written after the close", func(t *testing.T) {
+ f := newEvFixture(t)
+ f.rec.LastMove.At = time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)
+ f.bucket.Put("data", f.genKey("old"), []byte("late"))
+ notEvidence(t, f, "old")
+ })
+ t.Run("an object no generation owns", func(t *testing.T) {
+ f := newEvFixture(t)
+ f.bucket.Put("data", f.location+"/stray.bin", []byte("?"))
+ notEvidence(t, f, "")
+ })
+}
+
// 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.
@@ -307,20 +404,61 @@ func TestMovesSweepProofRefusesALiveMove(t *testing.T) {
bothRefuse(t, f, false)
})
}
- t.Run("the claim was released by another move", func(t *testing.T) {
+ t.Run("the record names no closed move", func(t *testing.T) {
f := newEvFixture(t)
- f.setClaim(t, func(c *hostclaim.Claim) { c.Op = servingReleasedBy("mv_orphan") })
- bothRefuse(t, f, true)
+ f.rec.LastMove = nil
+ bothRefuse(t, f, false)
})
- t.Run("the serving claim names no released move", func(t *testing.T) {
+}
+
+// TestMovesProofSurvivesAMetadataPlacement (fix round 1, ruling 2): the
+// durable proof is the record's LastMove, bound to the holder's
+// incarnation, with no move op on the claim. A later operation that
+// replaces the serving op (here CompleteMetadataPlacement, which drops the
+// released_move journal) leaves cleanup running, and so does a serving op
+// released by a move whose record never carried it.
+func TestMovesProofSurvivesAMetadataPlacement(t *testing.T) {
+ t.Run("a metadata placement", func(t *testing.T) {
f := newEvFixture(t)
- f.setClaim(t, func(c *hostclaim.Claim) { c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"} })
- bothRefuse(t, f, false)
+ f.lane(evMoveID, "move.json")
+ f.bucket.Put("data", f.genKey(evMoveID), []byte("live"))
+ f.setClaim(t, func(c *hostclaim.Claim) {
+ if err := hostops.BeginPlacement("pl_1", "op@example", "meta", evNow, []string{"ie"}, []string{"box"})(c); err != nil {
+ t.Fatal(err)
+ }
+ c.Op.Intent["app_id"] = "app_1"
+ if err := hostops.CompleteMetadataPlacement("pl_1", "app_1")(c); err != nil {
+ t.Fatal(err)
+ }
+ })
+ obs := f.observe(t)
+ if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Journal["released_move"] != "" {
+ t.Fatalf("the placement left op %+v", obs.Claim.Op)
+ }
+ census := f.census(t)
+ proof, err := f.provider().VerifyMovesSweep(t.Context(), obs, census)
+ if err != nil || proof.MoveID != evMoveID || !proof.RetiredAt.Equal(evAt) {
+ t.Fatalf("proof = %+v, %v", proof, err)
+ }
+ if inc, at, err := f.provider().VerifyRetirement(t.Context(), obs, census); err != nil || inc != f.inc || !at.Equal(evAt) {
+ t.Fatalf("VerifyRetirement = %q, %v, %v", inc, at, 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)
+ }
+ if _, ok := f.chores(t)["moves/"+evHost]; !ok {
+ t.Fatalf("chores = %+v; want the moves/<host> chore", f.chores(t))
+ }
})
- t.Run("the record names no closed move", func(t *testing.T) {
+ t.Run("a release by a move the record never carried", func(t *testing.T) {
f := newEvFixture(t)
- f.rec.LastMove = nil
- bothRefuse(t, f, false)
+ f.setClaim(t, func(c *hostclaim.Claim) { c.Op = servingReleasedBy("mv_orphan") })
+ if _, err := f.provider().VerifyMovesSweep(t.Context(), f.observe(t), f.census(t)); err != nil {
+ t.Fatalf("no lane of the orphan, so nothing unproven: %v", err)
+ }
+ f.bucket.Put("data", "moves/"+evHost+"/mv_orphan/move.json", []byte("orphan"))
+ f.rec.LastMove.At = time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)
+ bothRefuse(t, f, true)
})
}
@@ -464,12 +602,14 @@ func (f *evFixture) worker(t *testing.T, listing time.Time) hostbox.RegionalWork
t.Helper()
seq := 0
p := f.provider()
+ location := f.location
engine := park.Engine{
Store: f.bucket, Claims: f.claims, Bucket: "data", Region: "ie", Leases: evLeases{},
Now: func() time.Time { return listing },
NewID: func() string { seq++; return fmt.Sprintf("id%d", seq) },
View: func(_ context.Context, o hostclaim.Observed) (park.View, error) {
- return park.View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "uk"}, nil
+ return park.View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "uk",
+ GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}, nil
},
Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil },
VerifyMovesSweep: p.VerifyMovesSweep,
@@ -502,7 +642,7 @@ func TestAllowedSweepQueuesTheMovesChore(t *testing.T) {
f := newEvFixture(t)
f.lane(evMoveID, "move.json")
f.lane("mv_older", "replica/0000/db")
- left := f.location + "/g-left/0000/db"
+ left := f.genKey("old") // superseded by the move's own admission
f.bucket.Put("data", left, []byte("left behind"))
err := f.worker(t, evNow).Run(t.Context())
if !errors.Is(err, park.ErrPending) {
@@ -523,7 +663,7 @@ func TestAllowedSweepQueuesTheMovesChore(t *testing.T) {
t.Fatalf("no archive under the holder's incarnation: %v, %v", vs, err)
}
for _, o := range f.observe(t).Claim.Obligations {
- if o.Op != "session:1" || o.Owner != "region:ie" {
+ if o.Op != "session:2" || o.Owner != "region:ie" {
t.Fatalf("chore %+v is not the serving session's regional chore", o)
}
}
@@ -572,7 +712,7 @@ func TestSweepSkipsWhileAMoveStages(t *testing.T) {
func TestSweepRefusedUnlessTheCloseIsBeforeTheListing(t *testing.T) {
f := newEvFixture(t)
f.lane(evMoveID, "move.json")
- f.bucket.Put("data", f.location+"/g-left/0000/db", []byte("left behind"))
+ f.bucket.Put("data", f.genKey("old"), []byte("left behind"))
err := f.worker(t, evAt).Run(t.Context())
if !errors.Is(err, park.ErrPending) {
t.Fatalf("the location chore still queues: %v", err)
@@ -603,10 +743,11 @@ func TestSweepScopeMembership(t *testing.T) {
}
}
-// TestFreeHostSweepIsRefusedWithoutAHolder: a free claim names no record,
-// so no closed move can be proven and the claim stays free. Its residue is
-// the legacy census's (M7, claim Plan 12).
-func TestFreeHostSweepIsRefusedWithoutAHolder(t *testing.T) {
+// TestFreeHostLaneProofIsClaimPlan12s (fix round 1, ruling 3): a free claim
+// names no record, so moves can prove no closed move and the claim stays
+// free. The free-host lane proof is transferred to claim Plan 12 (confirmed
+// by platform-1e); this test pins the refusal until then.
+func TestFreeHostLaneProofIsClaimPlan12s(t *testing.T) {
f := newEvFixture(t)
f.setClaim(t, func(c *hostclaim.Claim) { c.Holder, c.Op, c.Touched = nil, hostclaim.Op{}, hostclaim.Touched{} })
f.lane(evMoveID, "move.json")
@@ -619,3 +760,71 @@ func TestFreeHostSweepIsRefusedWithoutAHolder(t *testing.T) {
t.Fatalf("claim = %+v", c)
}
}
+
+// authoritative makes ie the authoritative region, with the marker at the
+// holder's current generation and objects classified by the landing's
+// layout, as Task 9a's view will.
+func (f *evFixture) authoritative(t *testing.T, w *hostbox.RegionalWorker, current string) {
+ t.Helper()
+ marker := f.location + "/generation.json"
+ ref := park.Ref{Key: marker, VersionID: f.bucket.LatestID("data", marker)}
+ location := f.location
+ w.Regional.Engine.View = func(_ context.Context, o hostclaim.Observed) (park.View, error) {
+ return park.View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "ie", MarkerKnown: true, MarkerGeneration: current, Marker: ref,
+ GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}, nil
+ }
+}
+
+// TestAuthoritativeSweepStampsTheCloseOnlyOnWhatTheMoveRetired (fix round 1,
+// ruling 1): in the authoritative region the engine makes superseded
+// generations park-generation candidates and binds them to
+// VerifyRetirement's time. A generation the move's admission superseded
+// gets the close; when the move's own generation was superseded after the
+// close, the provider refuses and nothing is queued.
+func TestAuthoritativeSweepStampsTheCloseOnlyOnWhatTheMoveRetired(t *testing.T) {
+ listing := evAt.Add(time.Minute) // before the close's horizon, so chores stay queued
+ t.Run("superseded by the move", func(t *testing.T) {
+ f := newEvFixture(t)
+ f.lane(evMoveID, "move.json")
+ f.bucket.Put("data", f.genKey(evMoveID), []byte("live"))
+ f.bucket.Put("data", f.location+"/generation.json", []byte("marker"))
+ f.bucket.Put("data", f.genKey("old"), []byte("superseded by the move"))
+ w := f.worker(t, listing)
+ f.authoritative(t, &w, evMoveID)
+ if err := w.Run(t.Context()); !errors.Is(err, park.ErrPending) {
+ t.Fatalf("the chores wait for their horizons: %v", err)
+ }
+ var gens []hostclaim.Obligation
+ for _, o := range f.observe(t).Claim.Obligations {
+ if o.Kind == "park-generation" {
+ gens = append(gens, o)
+ }
+ }
+ if len(gens) != 1 || gens[0].Generation != "old" || gens[0].Park.Location != f.location || !gens[0].Park.EvidenceAt.Equal(evAt) {
+ t.Fatalf("generation chores = %+v; want one for old at the close %v", gens, evAt)
+ }
+ if moves, ok := f.chores(t)["moves/"+evHost]; !ok || !moves.EvidenceAt.Equal(listing) {
+ t.Fatalf("chores = %+v; want the moves chore at the listing", f.chores(t))
+ }
+ })
+ t.Run("superseded after the close", func(t *testing.T) {
+ f := newEvFixture(t)
+ f.setClaim(t, func(c *hostclaim.Claim) {
+ c.Holder.Admissions, c.Holder.Generation, c.Holder.Session, c.Holder.Stamp = evAdmissions(evMoveID, "later"), "later", 3, 3
+ })
+ f.lane(evMoveID, "move.json")
+ f.bucket.Put("data", f.genKey("later"), []byte("live"))
+ f.bucket.Put("data", f.location+"/generation.json", []byte("marker"))
+ f.bucket.Put("data", f.genKey(evMoveID), []byte("written until after the close"))
+ w := f.worker(t, listing)
+ f.authoritative(t, &w, "later")
+ err := w.Run(t.Context())
+ var ne *NotMovesEvidence
+ if !errors.As(err, &ne) || ne.Generation != evMoveID {
+ t.Fatalf("err = %v, want a *NotMovesEvidence for %s", err, evMoveID)
+ }
+ if len(f.observe(t).Claim.Obligations) != 0 {
+ t.Fatalf("chores %+v", f.observe(t).Claim.Obligations)
+ }
+ })
+}