carlos / platform Public

region ha: a delete or purge that verifies the completion box's termination fence passes it to the completion barrier as its exempt box (ruling 86).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev ca282378110844d8765befb634792d406184ded4 parent b1aacf7
27 files changed, +397 −42
  • internal/console/testdata/claim-authority-sites.txt +1 −1
  • internal/host/claimparticipation_stale_test.go +1 −1
  • internal/host/claimretirerecovery_test.go +1 −1
  • internal/host/movesabort_test.go +1 −1
  • internal/host/movesparks_test.go +1 −1
  • internal/host/plan12_census_test.go +1 −1
  • internal/host/relocationfinish_unix_test.go +1 −1
  • internal/host/relocationfirstwake_unix_test.go +1 −1
  • internal/hostbox/work_test.go +1 −1
  • internal/hostclaim/adopt_test.go +3 −3
  • internal/hostclaim/create_test.go +1 −1
  • internal/hostclaim/delete.go +5 −2
  • internal/hostclaim/delete_test.go +3 −3
  • internal/hostclaim/generation_test.go +2 −2
  • internal/hostclaim/move.go +2 −2
  • internal/hostclaim/ops.go +8 −3
  • internal/hostclaim/ops_test.go +4 −4
  • internal/hostclaim/relocation_probestop_test.go +3 −3
  • internal/hostclaim/rev713_test.go +1 −1
  • internal/hostops/completion_fence.go +65 −0
  • internal/hostops/completion_fence_test.go +266 −0
  • internal/hostops/operations.go +1 −1
  • internal/hostops/operations_test.go +2 −2
  • internal/hostops/production_delete.go +6 −1
  • internal/hostops/production_held_start.go +8 −1
  • internal/hostops/purge.go +7 −3
  • internal/hostops/purge_retirement_test.go +1 −1

CI cancelled — superseded by a newer push — run details

diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt
index 9e1fbf855..81c14d677 100644
--- a/internal/console/testdata/claim-authority-sites.txt
+++ b/internal/console/testdata/claim-authority-sites.txt
@@ -21,7 +21,7 @@
../hostops/move_recovery_grant.go|ResolveStart|runner.resolveUncertain(ctx, e, result)
../hostops/move_start_resolve.go|ResolveMoveStart|h.Runner(h.w.now).resolveUncertain(ctx, e, result)
../hostops/mutation_boundary.go|NewMutationBoundary|NewObservedMutationBoundary(enabled, raw, check, nil)
-../hostops/production_held_start.go|startAppLifecycle|BeginPurge(id, by, a.Now(), regions, boxes)
+../hostops/production_held_start.go|startAppLifecycle|BeginPurge(id, by, a.Now(), regions, boxes, fenced)
../hostops/purge_control.go|DrainPurgeRegistrations|w.SavePurgeJournal(ctx, j, tag)
../hostops/purge_control.go|DrainPurgeRegistrations|w.SavePurgeJournal(ctx, j, tag)
../hostops/purge_control.go|DrainPurgeRegistrations|w.SavePurgeJournal(ctx, j, tag)
diff --git a/internal/host/claimparticipation_stale_test.go b/internal/host/claimparticipation_stale_test.go
index 97c142c10..452b1c16f 100644
--- a/internal/host/claimparticipation_stale_test.go
+++ b/internal/host/claimparticipation_stale_test.go
@@ -32,7 +32,7 @@ func TestPlan9PreviousAgentRetiredParticipation(t *testing.T) {
if mode == "launch" {
f.controller("boot-old", "controller-old", 901, "101").admit(t, f)
}
- o, err := hostclaim.Update(ctx, f.claims, f.read(), hostclaim.BeginDelete("delete", "console", "record-etag", f.now, nil, nil))
+ o, err := hostclaim.Update(ctx, f.claims, f.read(), hostclaim.BeginDelete("delete", "console", "record-etag", f.now, nil, nil, ""))
if err != nil {
t.Fatal(err)
}
diff --git a/internal/host/claimretirerecovery_test.go b/internal/host/claimretirerecovery_test.go
index 56dc467bb..081b76e29 100644
--- a/internal/host/claimretirerecovery_test.go
+++ b/internal/host/claimretirerecovery_test.go
@@ -21,7 +21,7 @@ func retirementRecoveryRow(t *testing.T, f *recoveryFixture) registry.Route {
t.Helper()
o := f.read()
var err error
- o, err = hostclaim.Update(t.Context(), f.claims, o, hostclaim.BeginDelete("delete-1", "console", `"record-etag"`, f.now, nil, nil))
+ o, err = hostclaim.Update(t.Context(), f.claims, o, hostclaim.BeginDelete("delete-1", "console", `"record-etag"`, f.now, nil, nil, ""))
if err != nil {
t.Fatal(err)
}
diff --git a/internal/host/movesabort_test.go b/internal/host/movesabort_test.go
index 871feb728..b2c47db75 100644
--- a/internal/host/movesabort_test.go
+++ b/internal/host/movesabort_test.go
@@ -354,7 +354,7 @@ func TestAbortWaitsForAnUnreachableTargetRegion(t *testing.T) {
}
}
c := h.claim(t)
- if err := hostclaim.StartGuard(c, []string{"ie"}, []string{"ie-box"}); err == nil {
+ if err := hostclaim.StartGuard(c, []string{"ie"}, []string{"ie-box"}, ""); err == nil {
t.Fatal("a new operation could start over the waiting abort")
}
// An unarmed target box parks nothing either: the abort's lane park goes
diff --git a/internal/host/movesparks_test.go b/internal/host/movesparks_test.go
index 3f2051ac1..7eb9ff6de 100644
--- a/internal/host/movesparks_test.go
+++ b/internal/host/movesparks_test.go
@@ -248,7 +248,7 @@ func TestCloseIsRecordFirst(t *testing.T) {
if c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != hostclaim.PhaseCommitted || c.Op.ID != p1ID {
t.Fatalf("the claim at the crash point: %+v", c.Op)
}
- if err := hostclaim.StartGuard(c, []string{"uk"}, []string{"uk-box"}); err == nil {
+ if err := hostclaim.StartGuard(c, []string{"uk"}, []string{"uk-box"}, ""); err == nil {
t.Fatal("a new operation could start over the unreleased claim")
}
gen := c.Gen
diff --git a/internal/host/plan12_census_test.go b/internal/host/plan12_census_test.go
index ec06b09d1..dcc769be8 100644
--- a/internal/host/plan12_census_test.go
+++ b/internal/host/plan12_census_test.go
@@ -19,7 +19,7 @@ func plan12Retired(t *testing.T) hostclaim.Observed {
o, err = hostclaim.Update(t.Context(), f.claims, o, func(c *hostclaim.Claim) error {
c.Holder.Location = "instances/" + o.Host
c.Touched = hostclaim.Touched{Complete: true}
- return hostclaim.BeginDelete("delete", "console", "record", f.now, nil, nil)(c)
+ return hostclaim.BeginDelete("delete", "console", "record", f.now, nil, nil, "")(c)
})
if err != nil {
t.Fatal(err)
diff --git a/internal/host/relocationfinish_unix_test.go b/internal/host/relocationfinish_unix_test.go
index 93b550ab2..0943ab79a 100644
--- a/internal/host/relocationfinish_unix_test.go
+++ b/internal/host/relocationfinish_unix_test.go
@@ -128,7 +128,7 @@ func (w *finishWorld) marked() (bool, error) {
if err != nil {
w.t.Fatal(err)
}
- return ok && done.Discharged, hostclaim.StartGuard(obs.Claim, []string{"uk"}, []string{"spa"})
+ return ok && done.Discharged, hostclaim.StartGuard(obs.Claim, []string{"uk"}, []string{"spa"}, "")
}
// done asserts the finished state: discharged as many times in all as
diff --git a/internal/host/relocationfirstwake_unix_test.go b/internal/host/relocationfirstwake_unix_test.go
index f81f377d7..ef675b09f 100644
--- a/internal/host/relocationfirstwake_unix_test.go
+++ b/internal/host/relocationfirstwake_unix_test.go
@@ -132,7 +132,7 @@ func TestCompletionPassThenFirstServingWakeWithRegionHAOff(t *testing.T) {
if act.svc.Fenced(recoveryHost) {
t.Fatal("the probe's owned fence was not lifted")
}
- if err := hostclaim.StartGuard(prior.Claim, []string{"uk"}, []string{"spa"}); err != nil {
+ if err := hostclaim.StartGuard(prior.Claim, []string{"uk"}, []string{"spa"}, ""); err != nil {
t.Fatalf("the barrier did not open: %v", err)
}
diff --git a/internal/hostbox/work_test.go b/internal/hostbox/work_test.go
index cf3fa7cc4..080a1078c 100644
--- a/internal/hostbox/work_test.go
+++ b/internal/hostbox/work_test.go
@@ -74,7 +74,7 @@ func TestSelectedWorkRejectsChangesAndNeverRecaptures(t *testing.T) {
}
switch mode {
case "phase":
- _, err = hostclaim.Update(ctx, store, obs, hostclaim.BeginDelete("delete", "test", "etag", time.Unix(1, 0), []string{"ie"}, []string{"box"}))
+ _, err = hostclaim.Update(ctx, store, obs, hostclaim.BeginDelete("delete", "test", "etag", time.Unix(1, 0), []string{"ie"}, []string{"box"}, ""))
case "executor":
_, err = hostclaim.Update(ctx, store, obs, func(c *hostclaim.Claim) error { c.Op.Exec = "other"; c.Op.Participants = []string{"other"}; return nil })
case "round-trip":
diff --git a/internal/hostclaim/adopt_test.go b/internal/hostclaim/adopt_test.go
index 0ff95d3af..bcf740114 100644
--- a/internal/hostclaim/adopt_test.go
+++ b/internal/hostclaim/adopt_test.go
@@ -31,7 +31,7 @@ func TestAdoptAndDiscoveryCompletion(t *testing.T) {
t.Fatalf("adopt = %+v", c)
}
// A delete is refused while discovery chores are pending in its write set (CC11).
- if _, err := Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"})); !errors.Is(err, ErrPendingChore) {
+ if _, err := Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"}, "")); !errors.Is(err, ErrPendingChore) {
t.Fatalf("delete during discovery = %v", err)
}
for _, ob := range c.Obligations {
@@ -45,7 +45,7 @@ func TestAdoptAndDiscoveryCompletion(t *testing.T) {
if _, err := Update(ctx, s, o, Adopt(nh, "adi-knitting", nil, nil, "op_adopt2", counter())); !errors.Is(err, ErrWrongState) {
t.Fatalf("re-adopt = %v", err)
}
- o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"}))
+ o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"}, ""))
if err != nil {
t.Fatal(err)
}
@@ -92,7 +92,7 @@ func TestAdoptWithoutDiscoveryTargets(t *testing.T) {
if !reflect.DeepEqual(o.Claim.Touched, want) || len(o.Claim.Obligations) != 0 {
t.Fatalf("adopt without discovery targets = %+v", o.Claim)
}
- o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil))
+ o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil, ""))
if err != nil {
t.Fatal(err)
}
diff --git a/internal/hostclaim/create_test.go b/internal/hostclaim/create_test.go
index e12a3bdaf..1d308ab91 100644
--- a/internal/hostclaim/create_test.go
+++ b/internal/hostclaim/create_test.go
@@ -190,7 +190,7 @@ func TestCreateHomeRegionParksEffectiveRegion(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil))
+ o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil, ""))
if err != nil {
t.Fatal(err)
}
diff --git a/internal/hostclaim/delete.go b/internal/hostclaim/delete.go
index b0a3e9625..e5757972f 100644
--- a/internal/hostclaim/delete.go
+++ b/internal/hostclaim/delete.go
@@ -28,10 +28,13 @@ func union(known, supplied []string) []string {
return result
}
-func BeginDelete(opID, by, recordETag string, now time.Time, allRegions, allBoxes []string) func(*Claim) error {
+// BeginDelete starts a delete from serving. fencedCompletion is
+// StartGuard's (ruling 86): the completion box whose termination fence the
+// caller verified, or "".
+func BeginDelete(opID, by, recordETag string, now time.Time, allRegions, allBoxes []string, fencedCompletion string) func(*Claim) error {
return func(c *Claim) error {
regions, boxes := WriteSet(c, allRegions, allBoxes)
- if err := StartGuard(c, regions, boxes); err != nil {
+ if err := StartGuard(c, regions, boxes, fencedCompletion); err != nil {
return err
}
if opID == "" || recordETag == "" {
diff --git a/internal/hostclaim/delete_test.go b/internal/hostclaim/delete_test.go
index a16192032..a86a345d0 100644
--- a/internal/hostclaim/delete_test.go
+++ b/internal/hostclaim/delete_test.go
@@ -39,7 +39,7 @@ func TestDeleteParkFree(t *testing.T) {
now := time.Date(2026, 10, 4, 2, 0, 0, 0, time.UTC)
o := servingClaim(t, ctx, s)
inc, stamp := o.Claim.Holder.Incarnation, o.Claim.Holder.Stamp
- o, err := Update(ctx, s, o, BeginDelete("op_d1", "console", `"etag1"`, now, nil, nil))
+ o, err := Update(ctx, s, o, BeginDelete("op_d1", "console", `"etag1"`, now, nil, nil, ""))
if err != nil || o.Claim.Op.Kind != KindDeleting || o.Claim.Op.Intent["etag"] != `"etag1"` {
t.Fatalf("begin delete = %+v, %v", o.Claim, err)
}
@@ -77,7 +77,7 @@ func TestIncompleteTouchedParksEverywhere(t *testing.T) {
t.Fatal(err)
}
regions, boxes := []string{"ie", "uk", "us"}, []string{"b1", "b2"}
- o, err = Update(ctx, s, o, BeginDelete("op_d1", "console", `"e"`, now, regions, boxes))
+ o, err = Update(ctx, s, o, BeginDelete("op_d1", "console", `"e"`, now, regions, boxes, ""))
if err != nil {
t.Fatal(err)
}
@@ -170,7 +170,7 @@ func TestIncompleteAdoptionPreservesParkingTargets(t *testing.T) {
t.Fatal(err)
}
regions := []string{"ie"}
- o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), regions, nil))
+ o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), regions, nil, ""))
if err != nil {
t.Fatal(err)
}
diff --git a/internal/hostclaim/generation_test.go b/internal/hostclaim/generation_test.go
index 81994ce8c..aa3c396b9 100644
--- a/internal/hostclaim/generation_test.go
+++ b/internal/hostclaim/generation_test.go
@@ -249,7 +249,7 @@ func TestAdmitWriterBoundToOperationAndPhase(t *testing.T) {
stale := AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "gen_a"})
var err error
if change == "delete" {
- o, err = Update(ctx, s, o, BeginDelete("d1", "console", "etag", time.Now(), nil, nil))
+ o, err = Update(ctx, s, o, BeginDelete("d1", "console", "etag", time.Now(), nil, nil, ""))
} else {
o, err = Update(ctx, s, o, func(c *Claim) error {
if change == "operation" {
@@ -302,7 +302,7 @@ func TestAdmitWriterSurvivesOperationReplacement(t *testing.T) {
t.Fatalf("admitted holder = %+v", writer)
}
if transition == "delete" {
- o, err = Update(ctx, s, o, BeginDelete("d1", "console", "etag", time.Now(), nil, nil))
+ o, err = Update(ctx, s, o, BeginDelete("d1", "console", "etag", time.Now(), nil, nil, ""))
} else {
o, err = Update(ctx, s, o, func(c *Claim) error {
c.Op = Op{Kind: KindMoving, ID: "m1", Phase: "probe", Exec: "boxT"}
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go
index c8b86380b..ce06845ea 100644
--- a/internal/hostclaim/move.go
+++ b/internal/hostclaim/move.go
@@ -46,7 +46,7 @@ const journalReleasedMove = "released_move"
// found.
func BeginMove(opID, by, sourceBox, targetBox, targetRegion string, now time.Time) func(*Claim) error {
return func(c *Claim) error {
- if err := StartGuard(c, []string{targetRegion}, []string{targetBox}); err != nil {
+ if err := StartGuard(c, []string{targetRegion}, []string{targetBox}, ""); err != nil {
return err
}
if opID == "" || sourceBox == "" || targetBox == "" || targetRegion == "" {
@@ -90,7 +90,7 @@ func BeginAdmittedMove(opID, by, sourceBox, targetBox, targetRegion string, now
if regionname.SameRegion(a.HomeRegion, targetRegion, regionname.HomeRegion) {
regions = append(regions, a.HomeRegion, regionname.HomeRegion)
}
- if err := StartGuard(c, regions, []string{targetBox}); err != nil {
+ if err := StartGuard(c, regions, []string{targetBox}, ""); err != nil {
return err
}
if err := BeginMove(opID, by, sourceBox, targetBox, targetRegion, now)(c); err != nil {
diff --git a/internal/hostclaim/ops.go b/internal/hostclaim/ops.go
index d645eb840..62a6a921f 100644
--- a/internal/hostclaim/ops.go
+++ b/internal/hostclaim/ops.go
@@ -29,12 +29,17 @@ func RequireOp(id string) func(*Claim) error {
// StartGuard is §4: one operation at a time, started from serving, and
// CC11: never over a chore in a region or box the operation will write.
// Region HA N13 (ruling 77): nor while a completed relocation's box has
-// not discharged its participation (completionBarrier).
-func StartGuard(c *Claim, regions, boxes []string) error {
+// not discharged its participation (completionBarrier). fencedCompletion
+// is completionBarrier's fencedSource (ruling 86): a box whose termination
+// fence the caller has verified (VerifyFence, under a FenceAuthority) for
+// this start, as a failover's start passes its source. "" when there is
+// no verified fence: never a box the caller has not verified, and never
+// one inferred from a missing fence.
+func StartGuard(c *Claim, regions, boxes []string, fencedCompletion string) error {
if c.Holder == nil || c.Op.Kind != KindServing {
return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind)
}
- if err := completionBarrier(c, ""); err != nil {
+ if err := completionBarrier(c, fencedCompletion); err != nil {
return err
}
inR, inB := set(regions), set(boxes)
diff --git a/internal/hostclaim/ops_test.go b/internal/hostclaim/ops_test.go
index 53fbcea17..adedf0077 100644
--- a/internal/hostclaim/ops_test.go
+++ b/internal/hostclaim/ops_test.go
@@ -20,14 +20,14 @@ func TestRequireOpRefusesStale(t *testing.T) {
func TestStartGuard(t *testing.T) {
h := &Holder{Incarnation: "i"}
- if err := StartGuard(&Claim{Holder: h, Op: Op{Kind: KindDeleting, ID: "x"}}, nil, nil); !errors.Is(err, ErrWrongState) {
+ if err := StartGuard(&Claim{Holder: h, Op: Op{Kind: KindDeleting, ID: "x"}}, nil, nil, ""); !errors.Is(err, ErrWrongState) {
t.Fatalf("start over a running op = %v", err)
}
busy := &Claim{Holder: h, Op: Op{Kind: KindServing}, Obligations: []Obligation{{Kind: "park-replica", Region: "uk"}}}
- if err := StartGuard(busy, []string{"uk"}, nil); !errors.Is(err, ErrPendingChore) {
+ if err := StartGuard(busy, []string{"uk"}, nil, ""); !errors.Is(err, ErrPendingChore) {
t.Fatalf("start over a pending chore = %v", err)
}
- if err := StartGuard(busy, []string{"ie"}, nil); err != nil {
+ if err := StartGuard(busy, []string{"ie"}, nil, ""); err != nil {
t.Fatalf("start elsewhere = %v", err)
}
}
@@ -45,7 +45,7 @@ func TestStartGuardLocalBox(t *testing.T) {
{name: "no boxes"},
} {
t.Run(tc.name, func(t *testing.T) {
- if err := StartGuard(busy, nil, tc.boxes); !errors.Is(err, tc.want) {
+ if err := StartGuard(busy, nil, tc.boxes, ""); !errors.Is(err, tc.want) {
t.Fatalf("start with boxes %v = %v, want %v", tc.boxes, err, tc.want)
}
})
diff --git a/internal/hostclaim/relocation_probestop_test.go b/internal/hostclaim/relocation_probestop_test.go
index 04940d2f5..a89ba6fda 100644
--- a/internal/hostclaim/relocation_probestop_test.go
+++ b/internal/hostclaim/relocation_probestop_test.go
@@ -245,7 +245,7 @@ func TestMoveSuccessorWaitsForTheCompletedDischarge(t *testing.T) {
if _, err := Update(ctx, s, o, move); !errors.Is(err, ErrPendingChore) || !errors.Is(err, ErrRelocationFinishing) {
t.Fatalf("a move started before B's discharge: %v", err)
}
- if _, err := Update(ctx, s, o, BeginDelete("del-1", "console", "etag-1", relObserved.Add(time.Hour), []string{"ie"}, []string{relTarget})); !errors.Is(err, ErrPendingChore) {
+ if _, err := Update(ctx, s, o, BeginDelete("del-1", "console", "etag-1", relObserved.Add(time.Hour), []string{"ie"}, []string{relTarget}, "")); !errors.Is(err, ErrPendingChore) {
t.Fatalf("a delete started before B's discharge: %v", err)
}
if now, err := Read(ctx, s, o.Claim.Host); err != nil || now.ETag != o.ETag {
@@ -253,7 +253,7 @@ func TestMoveSuccessorWaitsForTheCompletedDischarge(t *testing.T) {
}
lost := copyOf(t, o.Claim)
lost.Lost = append(lost.Lost, relTarget)
- if err := StartGuard(lost, []string{"uk"}, []string{relSpare}); err != nil {
+ if err := StartGuard(lost, []string{"uk"}, []string{relSpare}, ""); err != nil {
t.Fatalf("a lost completion box still holds the barrier: %v", err)
}
o = step(t, s, o, MarkCompletedDischarged("rel-1"))
@@ -298,7 +298,7 @@ func TestAnUnreadableCompletedRelocationRefusesEveryStart(t *testing.T) {
if _, err := StartRelocation(ctx, s, o, failover, relAuthority{}); !errors.Is(err, ErrRelocation) {
t.Fatalf("a failover started over an unreadable completion: %v", err)
}
- if err := StartGuard(copyOf(t, o.Claim), []string{"uk"}, []string{relSpare}); !errors.Is(err, ErrRelocation) {
+ if err := StartGuard(copyOf(t, o.Claim), []string{"uk"}, []string{relSpare}, ""); !errors.Is(err, ErrRelocation) {
t.Fatalf("StartGuard: %v", err)
}
if err := MarkCompletedDischarged("rel-1")(copyOf(t, o.Claim)); !errors.Is(err, ErrRelocation) {
diff --git a/internal/hostclaim/rev713_test.go b/internal/hostclaim/rev713_test.go
index 481064dd4..235654f11 100644
--- a/internal/hostclaim/rev713_test.go
+++ b/internal/hostclaim/rev713_test.go
@@ -368,7 +368,7 @@ func TestAdoptLostTargetReturnDeclaresLegacySource(t *testing.T) {
t.Fatalf("legacy cut = %+v", cut)
}
o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
- if err := StartGuard(c, []string{"ie", "uk"}, []string{"boxS", "boxT"}); err != nil {
+ if err := StartGuard(c, []string{"ie", "uk"}, []string{"boxS", "boxT"}, ""); err != nil {
return err
}
c.Op = Op{Kind: KindMoving, ID: "move", Phase: "cut", Exec: "boxS", Participants: []string{"boxS", "boxT"}, Journal: map[string]string{"src_covered": "false"}}
diff --git a/internal/hostops/completion_fence.go b/internal/hostops/completion_fence.go
new file mode 100644
index 000000000..46cc99aea
--- /dev/null
+++ b/internal/hostops/completion_fence.go
@@ -0,0 +1,65 @@
+package hostops
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "os"
+ "slices"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfence"
+)
+
+// completionFence is ruling 86: the box a serving claim's undischarged
+// completed relocation names, when that box's termination fence verifies,
+// for a delete or purge to pass to the completion barrier as its exempt
+// box (hostclaim.StartGuard's fencedCompletion), as a failover passes its
+// fenced source. A dead box's participation slot binds nothing on another
+// box; without the exemption a purge that has closed the app's admission
+// waits on a failover that admission refuses (the two-box model, seed
+// 2224).
+//
+// The fence is verified as the failover's start verifies it: the record
+// at the box's current roster enrollment, by its exact digest, through
+// hostclaim.VerifyFence under the claim fence authority, and bound to the
+// box (VerifyFenceForBox). Only that evidence exempts. "" with no error
+// when there is nothing to exempt (no completion, one already discharged,
+// or its box already on lost, which the barrier exempts itself) or no
+// fence to verify (a box not in the roster, or no fence record): the
+// barrier then refuses as before, since absence is never evidence. A fence
+// that cannot be read, or does not verify, is its error and the start is
+// refused. A completion record that cannot be read is left to the
+// barrier, which refuses over it.
+func (a ProductionAuthority) completionFence(ctx context.Context, c *hostclaim.Claim) (string, error) {
+ done, ok, err := hostclaim.ReadCompletedRelocation(c)
+ if err != nil || !ok || done.Discharged || slices.Contains(c.Lost, done.Stop.Box) {
+ return "", nil
+ }
+ if a.Store == nil || a.Now == nil {
+ return "", ErrContract
+ }
+ o, err := a.Directory.Read(ctx)
+ if err != nil {
+ return "", err
+ }
+ b, ok := o.Roster.Boxes[done.Stop.Box]
+ if !ok {
+ return "", nil
+ }
+ key := hostfence.FenceKey(b.Enrollment)
+ raw, _, err := a.Store.Get(ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ return "", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ sum := sha256.Sum256(raw)
+ ref := hostclaim.FenceRef{Enrollment: b.Enrollment, Key: key, SHA256: hex.EncodeToString(sum[:])}
+ if err := hostclaim.VerifyFenceForBox(ctx, a.Store, hostfence.ClaimFenceAuthority{Directory: a.Directory, Now: a.Now}, ref, done.Stop.Box); err != nil {
+ return "", err
+ }
+ return done.Stop.Box, nil
+}
diff --git a/internal/hostops/completion_fence_test.go b/internal/hostops/completion_fence_test.go
new file mode 100644
index 000000000..f1dde18ec
--- /dev/null
+++ b/internal/hostops/completion_fence_test.go
@@ -0,0 +1,266 @@
+package hostops
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/termfence"
+)
+
+const completionHost = "shop.example"
+
+// completionWorld is ruling 86's schedule (the two-box model, seed 2224):
+// a relocation completed on box-a, which is terminated before it
+// discharges, so the serving op's completed relocation stays undischarged
+// and box-a is not on lost. The signed roster holds box-a and the worker
+// box-w that writes box-a's fence; the authority is production's
+// (ProductionAuthority over that roster, ClaimFenceAuthority).
+type completionWorld struct {
+ s release.Store
+ a ProductionAuthority
+}
+
+func newCompletionWorld(t *testing.T, change func(*hostclaim.Claim)) *completionWorld {
+ t.Helper()
+ ctx := context.Background()
+ x, s, _, _ := chooserFixture(t, "box-a", "box-w")
+ w := &completionWorld{s: s, a: ProductionAuthority{Store: s, Directory: x.Directory, Namespace: "platform", Now: x.Now}}
+ obs, err := hostclaim.Read(ctx, s, completionHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs, err = hostclaim.Update(ctx, s, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + completionHost + ".json", Account: "acct", App: "app", Host: completionHost, Region: "ie"}, "box-a", nil, nil, "adopt", func() string { return "id" }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done, err := json.Marshal(hostclaim.CompletedRelocation{Op: "rel-1", Stop: hostclaim.ProbeStop{Box: "box-a", Session: 7, Receipt: "probe-fence-1", ReceiptSHA256: strings.Repeat("a", 64)}, Phase: hostclaim.PhaseRelocatingProbing})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = hostclaim.Update(ctx, s, obs, func(c *hostclaim.Claim) error {
+ c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box-a", Participants: []string{"box-a"}, Journal: map[string]string{hostclaim.JournalCompleted: string(done)}}
+ if change != nil {
+ change(c)
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return w
+}
+
+// fence writes raw at box-a's fence key (its roster enrollment).
+func (w *completionWorld) fence(t *testing.T, raw []byte) {
+ t.Helper()
+ if _, err := w.s.Put(context.Background(), "control/box-fences/"+strings.Repeat("a", 32)+".json", raw, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// terminated is box-w's termination fence of box-a, valid under the
+// roster; edit changes it before it is written.
+func (w *completionWorld) terminated(t *testing.T, edit func(*termfence.Record)) {
+ t.Helper()
+ f := termfence.Record{V: 1, Box: "box-a", Enrollment: strings.Repeat("a", 32), State: "terminated",
+ Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box-a"},
+ ObservedAt: "2026-10-07T08:00:00Z", Via: termfence.ViaDescribe, RequestID: "req",
+ WrittenBy: termfence.WrittenBy{Box: "box-w", Enrollment: strings.Repeat("b", 32)}}
+ if edit != nil {
+ edit(&f)
+ }
+ raw, err := json.Marshal(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ w.fence(t, raw)
+}
+
+// purge is the purge start as startAppLifecycle makes it: the verified
+// completion fence, then BeginPurge's CAS with it. delete is the same
+// for StartDeleteClaim and BeginDelete.
+func (w *completionWorld) purge(t *testing.T) (hostclaim.Observed, error) {
+ t.Helper()
+ ctx := context.Background()
+ obs, err := hostclaim.Read(ctx, w.s, completionHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fenced, err := w.a.completionFence(ctx, obs.Claim)
+ if err != nil {
+ return obs, err
+ }
+ return hostclaim.Update(ctx, w.s, obs, BeginPurge("purge-1", "console", time.Unix(200, 0), []string{"ie"}, []string{"box-a", "box-w"}, fenced))
+}
+
+func (w *completionWorld) delete(t *testing.T) (hostclaim.Observed, error) {
+ t.Helper()
+ ctx := context.Background()
+ obs, err := hostclaim.Read(ctx, w.s, completionHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fenced, err := w.a.completionFence(ctx, obs.Claim)
+ if err != nil {
+ return obs, err
+ }
+ return hostclaim.Update(ctx, w.s, obs, hostclaim.BeginDelete("delete-1", "console", "record-etag", time.Unix(200, 0), []string{"ie"}, []string{"box-a", "box-w"}, fenced))
+}
+
+func (w *completionWorld) unchanged(t *testing.T, what string) {
+ t.Helper()
+ obs, err := hostclaim.Read(context.Background(), w.s, completionHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.ID != "" {
+ t.Fatalf("%s: the claim was started: %+v", what, obs.Claim.Op)
+ }
+}
+
+// Ruling 86, the deadlock schedule: the completion box is terminated before
+// it discharged, and a purge (or a delete) starts. With box-a's termination
+// fence verified under the roster, the start passes the completion barrier
+// as a failover's would; without the exemption StartGuard's barrier
+// refuses it, which is the deadlock the model found.
+func TestPurgeStartsOverATerminatedCompletionBox(t *testing.T) {
+ w := newCompletionWorld(t, nil)
+ w.terminated(t, nil)
+ ctx := context.Background()
+ obs, err := hostclaim.Read(ctx, w.s, completionHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := hostclaim.Update(ctx, w.s, obs, BeginPurge("purge-1", "console", time.Unix(200, 0), []string{"ie"}, []string{"box-a"}, "")); !errors.Is(err, hostclaim.ErrRelocationFinishing) {
+ t.Fatalf("the barrier without the exemption: %v (the deadlock's refusal)", err)
+ }
+ if fenced, err := w.a.completionFence(ctx, obs.Claim); err != nil || fenced != "box-a" {
+ t.Fatalf("the verified completion fence: %q %v", fenced, err)
+ }
+ started, err := w.purge(t)
+ if err != nil || started.Claim.Op.Kind != hostclaim.KindPurging || started.Claim.Op.ID != "purge-1" {
+ t.Fatalf("the purge over a terminated completion box: %v %+v", err, started.Claim)
+ }
+ d := newCompletionWorld(t, nil)
+ d.terminated(t, nil)
+ if started, err := d.delete(t); err != nil || started.Claim.Op.Kind != hostclaim.KindDeleting {
+ t.Fatalf("the delete over a terminated completion box: %v", err)
+ }
+}
+
+// Ruling 86's guards: only a verified fence exempts. A live completion box
+// (no fence), a fence that does not verify (another instance, written by
+// the box itself, not terminated, another box's record at this key), an
+// unreadable fence and a completion box the roster does not hold all leave
+// the purge refused; an unreadable or unverifiable fence is its error. A
+// completion already discharged, or whose box is on lost, needs no fence:
+// the barrier passes it and an unreadable fence is never read.
+func TestPurgeOverACompletionNeedsAVerifiedFence(t *testing.T) {
+ ctx := context.Background()
+ t.Run("a live completion box", func(t *testing.T) {
+ w := newCompletionWorld(t, nil)
+ if _, err := w.purge(t); !errors.Is(err, hostclaim.ErrRelocationFinishing) {
+ t.Fatalf("a live completion box: %v", err)
+ }
+ if _, err := w.delete(t); !errors.Is(err, hostclaim.ErrRelocationFinishing) {
+ t.Fatalf("a live completion box (delete): %v", err)
+ }
+ w.unchanged(t, "a live completion box")
+ })
+ for name, edit := range map[string]func(*termfence.Record){
+ "another instance": func(f *termfence.Record) { f.Cloud.InstanceID = "i-other" },
+ "written by the box": func(f *termfence.Record) {
+ f.WrittenBy = termfence.WrittenBy{Box: "box-a", Enrollment: strings.Repeat("a", 32)}
+ },
+ "not terminated": func(f *termfence.Record) { f.State = "running" },
+ "another box's record": func(f *termfence.Record) { f.Box = "box-w" },
+ "written by an unenrolled": func(f *termfence.Record) { f.WrittenBy.Box = "box-x" },
+ } {
+ t.Run("a fence that does not verify: "+name, func(t *testing.T) {
+ w := newCompletionWorld(t, nil)
+ w.terminated(t, edit)
+ if _, err := w.purge(t); err == nil {
+ t.Fatalf("an unverified fence exempted the purge")
+ }
+ if _, err := w.delete(t); err == nil {
+ t.Fatalf("an unverified fence exempted the delete")
+ }
+ w.unchanged(t, name)
+ })
+ }
+ t.Run("an unreadable fence", func(t *testing.T) {
+ w := newCompletionWorld(t, nil)
+ w.fence(t, []byte("{"))
+ if _, err := w.purge(t); err == nil || errors.Is(err, hostclaim.ErrRelocationFinishing) {
+ t.Fatalf("an unreadable fence: %v (want its error)", err)
+ }
+ w.unchanged(t, "an unreadable fence")
+ })
+ t.Run("a fence read that fails", func(t *testing.T) {
+ w := newCompletionWorld(t, nil)
+ w.terminated(t, nil)
+ w.a.Store = &faultStore{Store: w.s, failGet: "control/box-fences/" + strings.Repeat("a", 32) + ".json"}
+ if fenced, err := w.a.completionFence(ctx, mustClaim(t, w)); fenced != "" || err == nil {
+ t.Fatalf("a failed fence read: %q %v (want its error)", fenced, err)
+ }
+ })
+ t.Run("a completion box the roster does not hold", func(t *testing.T) {
+ w := newCompletionWorld(t, func(c *hostclaim.Claim) {
+ done, _, _ := hostclaim.ReadCompletedRelocation(c)
+ done.Stop.Box = "box-gone"
+ raw, _ := json.Marshal(done)
+ c.Op.Exec, c.Op.Participants, c.Op.Journal[hostclaim.JournalCompleted] = "box-gone", []string{"box-gone"}, string(raw)
+ })
+ w.terminated(t, nil)
+ if fenced, err := w.a.completionFence(ctx, mustClaim(t, w)); fenced != "" || err != nil {
+ t.Fatalf("an unrostered box: %q %v", fenced, err)
+ }
+ if _, err := w.purge(t); !errors.Is(err, hostclaim.ErrRelocationFinishing) {
+ t.Fatalf("an unrostered box: %v", err)
+ }
+ })
+ t.Run("a completion record that cannot be read", func(t *testing.T) {
+ w := newCompletionWorld(t, func(c *hostclaim.Claim) { c.Op.Journal[hostclaim.JournalCompleted] = "{" })
+ w.terminated(t, nil)
+ if _, err := w.purge(t); !errors.Is(err, hostclaim.ErrRelocation) {
+ t.Fatalf("an unreadable completion record: %v", err)
+ }
+ w.unchanged(t, "an unreadable completion record")
+ })
+ for name, change := range map[string]func(*hostclaim.Claim){
+ "already discharged": func(c *hostclaim.Claim) {
+ done, _, _ := hostclaim.ReadCompletedRelocation(c)
+ done.Discharged = true
+ raw, _ := json.Marshal(done)
+ c.Op.Journal[hostclaim.JournalCompleted] = string(raw)
+ },
+ "its box on lost": func(c *hostclaim.Claim) { c.Lost = append(c.Lost, "box-a") },
+ "no completion": func(c *hostclaim.Claim) { c.Op.Journal = nil },
+ } {
+ t.Run("nothing to exempt: "+name, func(t *testing.T) {
+ w := newCompletionWorld(t, change)
+ w.fence(t, []byte("{"))
+ if fenced, err := w.a.completionFence(ctx, mustClaim(t, w)); fenced != "" || err != nil {
+ t.Fatalf("%s: %q %v (the fence must not be read)", name, fenced, err)
+ }
+ if started, err := w.purge(t); err != nil || started.Claim.Op.Kind != hostclaim.KindPurging {
+ t.Fatalf("%s: %v", name, err)
+ }
+ })
+ }
+}
+
+func mustClaim(t *testing.T, w *completionWorld) *hostclaim.Claim {
+ t.Helper()
+ obs, err := hostclaim.Read(context.Background(), w.s, completionHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return obs.Claim
+}
diff --git a/internal/hostops/operations.go b/internal/hostops/operations.go
index c7a95d985..079b13f2b 100644
--- a/internal/hostops/operations.go
+++ b/internal/hostops/operations.go
@@ -278,7 +278,7 @@ func (c Controller) BeginRetire(ctx context.Context, key, by, expectedInc string
}); ok {
return production.StartDeleteClaim(ctx, obs, r, by)
}
- return hostclaim.Update(ctx, c.Store, obs, hostclaim.BeginDelete(c.NewID(), by, r.ETag, c.Now(), regions, boxes))
+ return hostclaim.Update(ctx, c.Store, obs, hostclaim.BeginDelete(c.NewID(), by, r.ETag, c.Now(), regions, boxes, ""))
}
func (c Controller) Retire(ctx context.Context, host, id string) error {
diff --git a/internal/hostops/operations_test.go b/internal/hostops/operations_test.go
index b1ee10380..9cfeb5fa1 100644
--- a/internal/hostops/operations_test.go
+++ b/internal/hostops/operations_test.go
@@ -367,7 +367,7 @@ func TestPurgeRetiresUnderOwnOperation(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- obs, err = hostclaim.Update(f.ctx, f.store, obs, BeginPurge("purge", "test", f.now, []string{"ie"}, []string{"box-a"}))
+ obs, err = hostclaim.Update(f.ctx, f.store, obs, BeginPurge("purge", "test", f.now, []string{"ie"}, []string{"box-a"}, ""))
if err != nil {
t.Fatal(err)
}
@@ -568,7 +568,7 @@ func TestPlacementStartKeepsInstallationAndExcludesDelete(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if _, err := hostclaim.Update(f.ctx, f.store, placed, hostclaim.BeginDelete("delete", "test", rec.ETag, f.now, nil, nil)); err == nil {
+ if _, err := hostclaim.Update(f.ctx, f.store, placed, hostclaim.BeginDelete("delete", "test", rec.ETag, f.now, nil, nil, "")); err == nil {
t.Fatal("delete overlapped placement")
}
}
diff --git a/internal/hostops/production_delete.go b/internal/hostops/production_delete.go
index 5c0fd72ef..5efe2f3c1 100644
--- a/internal/hostops/production_delete.go
+++ b/internal/hostops/production_delete.go
@@ -43,6 +43,11 @@ func (a ProductionAuthority) StartDeleteClaim(ctx context.Context, before hostcl
if err = a.StartDelete(ctx, before, r); err != nil {
return hostclaim.Observed{}, err
}
+ // Ruling 86: a delete passes the completion box it verified dead.
+ fenced, err := a.completionFence(ctx, before.Claim)
+ if err != nil {
+ return hostclaim.Observed{}, err
+ }
return a.startHeldClaim(ctx, before, id, scope, func() error {
if err := a.StartDelete(ctx, before, r); err != nil {
return err
@@ -55,5 +60,5 @@ func (a ProductionAuthority) StartDeleteClaim(ctx context.Context, before hostcl
return ErrPending
}
return nil
- }, hostclaim.BeginDelete(id, by, r.ETag, a.Now(), regions, boxes))
+ }, hostclaim.BeginDelete(id, by, r.ETag, a.Now(), regions, boxes, fenced))
}
diff --git a/internal/hostops/production_held_start.go b/internal/hostops/production_held_start.go
index b667a92de..44f5dca5e 100644
--- a/internal/hostops/production_held_start.go
+++ b/internal/hostops/production_held_start.go
@@ -115,8 +115,15 @@ func (a ProductionAuthority) startAppLifecycle(ctx context.Context, before hostc
if err != nil {
return hostclaim.Observed{}, err
}
+ fenced := ""
+ if !placing {
+ // Ruling 86: a purge passes the completion box it verified dead.
+ if fenced, err = a.completionFence(ctx, before.Claim); err != nil {
+ return hostclaim.Observed{}, err
+ }
+ }
return a.startHeldClaim(ctx, before, id, scope, check, func(claim *hostclaim.Claim) error {
- transition := BeginPurge(id, by, a.Now(), regions, boxes)
+ transition := BeginPurge(id, by, a.Now(), regions, boxes, fenced)
if placing {
transition = BeginPlacement(id, by, target, a.Now(), regions, boxes)
}
diff --git a/internal/hostops/purge.go b/internal/hostops/purge.go
index 4e1924937..823b9eb5f 100644
--- a/internal/hostops/purge.go
+++ b/internal/hostops/purge.go
@@ -152,12 +152,16 @@ func ReleasePurgeFence(id string) func(*hostclaim.Claim) error {
return nil
}
}
-func BeginPurge(id, by string, now time.Time, regions, boxes []string) func(*hostclaim.Claim) error {
+
+// BeginPurge starts one host's purge from serving. fencedCompletion is
+// hostclaim.StartGuard's (ruling 86): the completion box whose termination
+// fence the caller verified (ProductionAuthority.completionFence), or "".
+func BeginPurge(id, by string, now time.Time, regions, boxes []string, fencedCompletion string) func(*hostclaim.Claim) error {
return func(c *hostclaim.Claim) error {
if id == "" || now.IsZero() {
return ErrIdentity
}
- if err := hostclaim.StartGuard(c, regions, boxes); err != nil {
+ if err := hostclaim.StartGuard(c, regions, boxes, fencedCompletion); err != nil {
return err
}
c.Op = hostclaim.Op{Kind: hostclaim.KindPurging, ID: id, By: by, StartedAt: now.UTC(), Exec: c.Op.Exec, Participants: c.Op.Participants}
@@ -187,7 +191,7 @@ func BeginPlacement(id, by, target string, now time.Time, regions, boxes []strin
if id == "" || now.IsZero() {
return ErrIdentity
}
- if err := hostclaim.StartGuard(c, regions, boxes); err != nil {
+ if err := hostclaim.StartGuard(c, regions, boxes, ""); err != nil {
return err
}
c.Op = hostclaim.Op{Kind: hostclaim.KindPlacing, ID: id, By: by, StartedAt: now.UTC(), Exec: c.Op.Exec, Participants: c.Op.Participants, Intent: map[string]string{"target": target}}
diff --git a/internal/hostops/purge_retirement_test.go b/internal/hostops/purge_retirement_test.go
index 1d6d29652..ed5315c8e 100644
--- a/internal/hostops/purge_retirement_test.go
+++ b/internal/hostops/purge_retirement_test.go
@@ -52,7 +52,7 @@ func TestRetirePurgeHostsDoesNotCompleteParking(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- obs, err = hostclaim.Update(f.ctx, f.store, obs, hostclaim.BeginDelete("purge", "test", "record-tag", f.now, nil, nil))
+ obs, err = hostclaim.Update(f.ctx, f.store, obs, hostclaim.BeginDelete("purge", "test", "record-tag", f.now, nil, nil, ""))
if err != nil {
t.Fatal(err)
}