carlos / platform Public

region HA: stage B reads main's home region (park.HomeRegion) through main's helpers wherever it meets it

Main names the home "@home" where moves maps a placement code (intent
"to", its parks, its admissions) and keeps the roster name for the claim
assembly's engine, aliasing the two only under a verified home binding.
Stage B's relocation region stays the roster name, as main's
ConvertMovingToRelocating writes it. Where stage B meets the other name:

- relocationInstallLocal's Selection carries the work source's HomeRegion
  and FleetHomeRegion; main's IdentityProvider.Local refused it otherwise
  on any fleet with a designated home.
- RelocationStartGuard takes the home binding and reads a park-replica
  under either name (regionname.SameRegion), as BeginAdmittedMove's guard
  does; StartRelocation records RelocationStart.HomeRegion as the op's
  home_region intent and refuses one that is not a placement code.
- The completion's abandoned-P park reuses one owed under the home's other
  name.
- CommittedLandingRecords answers park.PlacementRegion of the record's
  From/To (moves' commit regions); ConvertCommittedLanding compares under
  the move's home_region, takes the fence binding (roster identity) as the
  relocation's region, and queues the commit's parks in engine regions.
- The worker holds the box's verified binding (Worker.HomeRegion, checked
  against the signed roster), converts a landing into its region under
  park.VerifiedHome + SameRegion, starts under it, and does not step an op
  bound to another home.
- The pinner, the relocation wake (and its fresh publisher) and the
  release flush select through park.HolderRegionView, as main's serving
  baseline does.
- ExecutorChooser maps a home record's park.HomeRegion to HomeName (main's
  StartCreate mapping); the console passes dbClaimHomeRegion(), the
  binding ProductionAuthority holds.
- claimcensus: the .moves fallback stays (M-K25: moves removes the lock
  when the stage finishes; a crash still leaves it).

Tests for each site; 20 of 21 mutants killed (the survivor is equivalent).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 4b66efbfe2f444052f26ded0c256bc6b27bbf3b9 parent db97dd7
20 files changed, +625 −42
  • internal/console/hostclaimexecutor.go +3 −1
  • internal/host/claimcensus.go +5 −3
  • internal/host/relocatepass.go +2 −2
  • internal/host/relocation_home_test.go +51 −0
  • internal/host/relocationassembly.go +2 −1
  • internal/host/relocationinstall.go +1 −1
  • internal/host/relocationlanding.go +12 −2
  • internal/host/relocationlanding_test.go +138 −4
  • internal/host/relocationowners.go +2 −1
  • internal/host/relocationwake.go +10 −5
  • internal/hostclaim/relocating.go +21 −7
  • internal/hostclaim/relocation.go +24 −8
  • internal/hostclaim/relocation_home_test.go +188 −0
  • internal/hostclaim/relocation_test.go +1 −1
  • internal/hostops/executor.go +9 −0
  • internal/hostops/executor_test.go +22 −0
  • internal/hostrelocate/pin.go +7 −1
  • internal/hostrelocate/pin_test.go +39 −0
  • internal/hostrelocate/worker.go +32 −5
  • internal/hostrelocate/worker_test.go +56 −0
diff --git a/internal/console/hostclaimexecutor.go b/internal/console/hostclaimexecutor.go
index 8c2dd834e..a1f9c24a6 100644
--- a/internal/console/hostclaimexecutor.go
+++ b/internal/console/hostclaimexecutor.go
@@ -30,5 +30,7 @@ func (s *Server) executorChooser(d hostactors.Directory) *hostops.ExecutorChoose
Loads: func(ctx context.Context) (map[string]boxchoice.Load, error) {
return hostops.ClaimLoads(ctx, s.cfg.Releases)
},
- SecretsKeyFP: s.expectedSecretsFP(), HomeName: s.cfg.HomeRegion, Now: time.Now}
+ // HomeName is the create door's home binding, the one
+ // ProductionAuthority.HomeRegion holds (dbClaimHomeRegion).
+ SecretsKeyFP: s.expectedSecretsFP(), HomeName: s.cfg.dbClaimHomeRegion(), Now: time.Now}
}
diff --git a/internal/host/claimcensus.go b/internal/host/claimcensus.go
index b5642fd6e..e9c6fe8f3 100644
--- a/internal/host/claimcensus.go
+++ b/internal/host/claimcensus.go
@@ -58,9 +58,11 @@ func claimDataCensus(dataDir string) []string {
staged := claimCensusRel(dataDir, release.HeldStagedDir(dataDir))
// And its held-stage lock (Task 8c fix round 1, I1): every held
// publication, root's relay and the relocation install's own, holds
- // .moves/held-<host>.lock (withHeldStageLock), which is never removed.
- // .moves is a shared tree, not opaque: regular files and directories
- // only, and anything naming the host being censused still refuses.
+ // .moves/held-<host>.lock (withHeldStageLock). Moves removes it when the
+ // stage finishes (M-K25 on #236), but a holder killed mid-stage leaves
+ // it, so the fallback stays, as moves asked: .moves is a shared tree,
+ // not opaque: regular files and directories only, and anything naming
+ // the host being censused still refuses.
return []string{".hostclaims", ".hostexec", "hostguard", "relocate", stage, staged, hostbox.CensusTree(movesDirName), hostbox.CensusFile(reg), hostbox.CensusFile(reg + "-wal"), hostbox.CensusFile(reg + "-shm")}
}
diff --git a/internal/host/relocatepass.go b/internal/host/relocatepass.go
index 3623fc26b..cb27db9b6 100644
--- a/internal/host/relocatepass.go
+++ b/internal/host/relocatepass.go
@@ -318,7 +318,7 @@ func NewRelocationRuntime(ctx context.Context, getenv func(string) string, store
Choose: chooser.Choose, Eligible: chooser.Eligible,
Relocatable: relocationRelocatable(cfg.DataDir, store),
Leases: relocationLeases(stores),
- Pins: hostrelocate.StorePinner{Claims: store, Data: stores.Files, Bucket: stores.Bucket, Region: stores.Region,
+ Pins: hostrelocate.StorePinner{Claims: store, Data: stores.Files, Bucket: stores.Bucket, Region: stores.Region, HomeRegion: cfg.Source.HomeRegion,
SelectDB: func(ctx context.Context, r genselect.Request) (*genselect.Plan, error) {
return genselect.Select(ctx, stores.Versions, r)
},
@@ -327,7 +327,7 @@ func NewRelocationRuntime(ctx context.Context, getenv func(string) string, store
Discharge: volumeDischarge(store),
Notify: func(ctx context.Context) { _ = release.BumpSerial(ctx, store, time.Now()) },
Self: termfence.WrittenBy{Box: in.Enrollment.Box, Enrollment: in.Enrollment.Enrollment},
- Region: stores.Region, Controller: newClaimID(), NewID: newClaimID, Now: time.Now}
+ Region: stores.Region, HomeRegion: cfg.Source.HomeRegion, Controller: newClaimID(), NewID: newClaimID, Now: time.Now}
participant := hostrelocate.Participant{Claims: store, Box: in.Enrollment.Box, Enrollment: in.Enrollment.Enrollment, Effects: effects, Now: time.Now}
return &RelocationRuntime{Worker: worker, Participant: participant, Box: in.Enrollment.Box, Convert: convertFencedLanding(worker, store), Finish: effects.Finish, DataDir: cfg.DataDir, config: cfg}, nil
}
diff --git a/internal/host/relocation_home_test.go b/internal/host/relocation_home_test.go
new file mode 100644
index 000000000..31036a82f
--- /dev/null
+++ b/internal/host/relocation_home_test.go
@@ -0,0 +1,51 @@
+package host
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/park"
+)
+
+// Main's IdentityProvider.Local refuses a selection whose home binding is
+// not the actor's (HomeRegion and FleetHomeRegion), so the install's one
+// Selection carries the work source's binding, as main's own selections do
+// (WorkSource.Select, claimAdminTokens, claimLaunchAuthority).
+func TestRelocationInstallSelectionCarriesTheHomeBinding(t *testing.T) {
+ var got hostbox.Selection
+ src := hostbox.WorkSource{HomeRegion: "ie", FleetHomeRegion: "ie", Box: "dst", Region: "ie",
+ Local: func(_ context.Context, _ *hostclaim.Claim, s hostbox.Selection) error { got = s; return nil }}
+ c := &hostclaim.Claim{Host: "shop.example", Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseInstalling},
+ Holder: &hostclaim.Holder{Generation: "g1", Session: 2}}
+ if err := relocationInstallLocal(t.Context(), src, c, hostguard.Captured{Incarnation: "inc", Stamp: 4}); err != nil {
+ t.Fatal(err)
+ }
+ if got.HomeRegion != "ie" || got.FleetHomeRegion != "ie" || got.Region != "ie" || got.Box != "dst" || got.Action != relocationActionInstall {
+ t.Fatalf("selection %+v", got)
+ }
+}
+
+// The relocation wake's generation selection and its fresh publisher see
+// the holder through the box's home binding (park.HolderRegionView), as
+// main's serving owners do: an admission moves recorded under
+// park.HomeRegion is this region's.
+func TestRelocationWakeSelectsThroughTheHomeBinding(t *testing.T) {
+ o := hostclaim.Observed{Host: "shop.example", Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{
+ Admissions: []hostclaim.AdmissionRecord{{Region: park.HomeRegion, PredecessorRegion: "uk", Generation: "g1"}}}}}
+ for _, tc := range []struct{ home, want string }{{"ie", "ie"}, {"", park.HomeRegion}} {
+ r := &RelocationWakeInputs{Bucket: "data", Region: "ie", HomeRegion: tc.home}
+ req := r.request(o)
+ if req.Region != "ie" || req.Holder.Admissions[0].Region != tc.want || req.Holder.Admissions[0].PredecessorRegion != "uk" {
+ t.Fatalf("home %q: request %+v", tc.home, req)
+ }
+ if f := r.freshOwners(); f.HomeRegion != tc.home || f.Region != "ie" {
+ t.Fatalf("home %q: fresh owners %+v", tc.home, f)
+ }
+ }
+ if o.Claim.Holder.Admissions[0].Region != park.HomeRegion {
+ t.Fatal("the view changed the claim's own holder")
+ }
+}
diff --git a/internal/host/relocationassembly.go b/internal/host/relocationassembly.go
index b1d48b400..af52d7de2 100644
--- a/internal/host/relocationassembly.go
+++ b/internal/host/relocationassembly.go
@@ -20,6 +20,7 @@ import (
"github.com/carlosframework/platform/internal/hostrelocate"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -217,7 +218,7 @@ func (a ClaimAssembly) relocationFlushDB(cfg RelocationAssemblyConfig) func(cont
sel := func(ctx context.Context, r genselect.Request) (*genselect.Plan, error) {
return genselect.Select(ctx, cfg.Versions, r)
}
- return releaseFinalTXID(ctx, att.rep, sel, genselect.Request{Bucket: cfg.Bucket, Region: cfg.Region, Host: o.Host, Holder: *o.Claim.Holder})
+ return releaseFinalTXID(ctx, att.rep, sel, genselect.Request{Bucket: cfg.Bucket, Region: cfg.Region, Host: o.Host, Holder: park.HolderRegionView(*o.Claim.Holder, cfg.Source.HomeRegion, cfg.Region)})
}
}
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go
index f9c682fca..3a493210a 100644
--- a/internal/host/relocationinstall.go
+++ b/internal/host/relocationinstall.go
@@ -70,7 +70,7 @@ func relocationInstallAuthority(c *hostclaim.Claim, op, box string, phases ...st
// action on c at captured: the one Selection both the guard activation
// and every build write ask with.
func relocationInstallLocal(ctx context.Context, src hostbox.WorkSource, c *hostclaim.Claim, captured hostguard.Captured) error {
- sel := hostbox.Selection{Host: c.Host, Action: relocationActionInstall, Box: src.Box, Region: src.Region, Captured: captured,
+ sel := hostbox.Selection{HomeRegion: src.HomeRegion, FleetHomeRegion: src.FleetHomeRegion, Host: c.Host, Action: relocationActionInstall, Box: src.Box, Region: src.Region, Captured: captured,
Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session}
return src.Local(ctx, c, sel)
}
diff --git a/internal/host/relocationlanding.go b/internal/host/relocationlanding.go
index 29586032f..90be6b5c4 100644
--- a/internal/host/relocationlanding.go
+++ b/internal/host/relocationlanding.go
@@ -5,13 +5,15 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/provision"
)
// CommittedLandingRecords is hostclaim.CommittedLandingGuard over the
// authoritative instance record: the move named by the claim's op has
// committed for this incarnation (provision.MoveCommitted, no rollback),
-// and the regions and park scopes are the commit's (moves.CommitScopes).
+// and the regions and park scopes are the commit's (moves.CommitScopes),
+// the regions as engine regions.
type CommittedLandingRecords struct{ Records MovesRecordReader }
var _ hostclaim.CommittedLandingGuard = CommittedLandingRecords{}
@@ -36,5 +38,13 @@ func (g CommittedLandingRecords) CheckLandingCommitted(ctx context.Context, o ho
if err != nil {
return hostclaim.CommittedLanding{}, err
}
- return hostclaim.CommittedLanding{FromRegion: m.From, ToRegion: m.To, SourceScope: source, StagingScope: staging}, nil
+ // The record's From and To are placement codes. The commit's parks are
+ // queued in engine regions, as moves' commitClaim queues them
+ // (park.PlacementRegion, so the home is park.HomeRegion, never ""); a
+ // code that is no placement refuses.
+ from, to := park.PlacementRegion(m.From), park.PlacementRegion(m.To)
+ if from == "" || to == "" {
+ return hostclaim.CommittedLanding{}, hostclaim.ErrWrongState
+ }
+ return hostclaim.CommittedLanding{FromRegion: from, ToRegion: to, SourceScope: source, StagingScope: staging}, nil
}
diff --git a/internal/host/relocationlanding_test.go b/internal/host/relocationlanding_test.go
index 291c8c2bf..8e33b24cb 100644
--- a/internal/host/relocationlanding_test.go
+++ b/internal/host/relocationlanding_test.go
@@ -15,6 +15,7 @@ import (
"github.com/carlosframework/platform/internal/hostfence"
"github.com/carlosframework/platform/internal/hostrelocate"
"github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/termfence"
@@ -34,6 +35,16 @@ type landingWorld struct {
}
func newLandingWorld(t *testing.T, committed bool, change func(*hostactors.Roster)) landingWorld {
+ t.Helper()
+ return newLandingWorldHome(t, committed, change, "")
+}
+
+// newLandingWorldHome is newLandingWorld with the landing into the home
+// when home is the fleet's home region (ie): the signed roster designates
+// it, the move is admitted under it (MoveAdmission.HomeRegion, its "to"
+// park.HomeRegion, as BeginClaimMove writes it) and the record's To is the
+// home's placement code "".
+func newLandingWorldHome(t *testing.T, committed bool, change func(*hostactors.Roster), home string) landingWorld {
t.Helper()
ctx := t.Context()
const host, id = "shop.example", "mv_abcdefghijklmnopqrstuvwxyz"
@@ -50,6 +61,7 @@ func newLandingWorld(t *testing.T, committed bool, change func(*hostactors.Roste
roster.Boxes[box] = hostactors.Box{Enrollment: enr, Namespace: "platform", BoxID: box, Label: box, Region: "ie", Pool: "default",
Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + box}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}
}
+ roster.HomeRegion = home
if change != nil {
change(&roster)
}
@@ -61,7 +73,7 @@ func newLandingWorld(t *testing.T, committed bool, change func(*hostactors.Roste
t.Fatal(err)
}
w.directory = hostactors.Directory{Store: w.store, PublicKey: pub, Bucket: "bucket"}
- w.obs = landingAtProbed(t, w.store, host, id, w.now, w.enrollment)
+ w.obs = landingAtProbedHome(t, w.store, host, id, w.now, w.enrollment, home)
dst := roster.Boxes["dst"]
fence, _ := json.Marshal(termfence.Record{V: 1, Box: "dst", Enrollment: dst.Enrollment, Cloud: dst.Cloud, State: "terminated", ObservedAt: w.now.Add(-time.Minute).Format(time.RFC3339Nano),
Via: termfence.ViaDescribe, RequestID: "req", WrittenBy: termfence.WrittenBy{Box: "spa", Enrollment: w.enrollment["spa"]}})
@@ -74,14 +86,25 @@ func newLandingWorld(t *testing.T, committed bool, change func(*hostactors.Roste
at := w.now.Add(-30 * time.Minute)
state, committedAt = provision.MoveCommitted, &at
}
- record := provision.Record{Schema: 1, Host: host, Slug: "shop", App: "jam", Account: "bab", Region: "ie", Channel: "stable", CreatedAt: created,
- Move: &provision.MoveState{ID: id, Incarnation: w.obs.Claim.Holder.Incarnation, From: "uk", To: "ie", State: state, CommittedAt: committedAt, FlippedAt: &flipped}}
+ to := "ie"
+ if home != "" {
+ to = "" // the home's placement code
+ }
+ record := provision.Record{Schema: 1, Host: host, Slug: "shop", App: "jam", Account: "bab", Region: to, Channel: "stable", CreatedAt: created,
+ Move: &provision.MoveState{ID: id, Incarnation: w.obs.Claim.Holder.Incarnation, From: "uk", To: to, State: state, CommittedAt: committedAt, FlippedAt: &flipped}}
if err := provision.CreateRecord(ctx, w.store, record); err != nil {
t.Fatal(err)
}
return w
}
+// homeWorker is worker with the box's verified home binding.
+func (w landingWorld) homeWorker(enrollment, region, home string) hostrelocate.Worker {
+ x := w.worker(enrollment, region)
+ x.HomeRegion = home
+ return x
+}
+
// worker is the converting worker: wrk, running as enrollment, in region.
func (w landingWorld) worker(enrollment, region string) hostrelocate.Worker {
now := func() time.Time { return w.now }
@@ -201,6 +224,13 @@ func TestRelocatePassConversionRefusesAnUnauthorizedWorker(t *testing.T) {
// move from src to dst flipped, the target's probe admitted. Its seal is a
// real one, so moves.CommitScopes reads it.
func landingAtProbed(t *testing.T, store release.Store, host, id string, now time.Time, enrollment map[string]string) hostclaim.Observed {
+ t.Helper()
+ return landingAtProbedHome(t, store, host, id, now, enrollment, "")
+}
+
+// landingAtProbedHome is landingAtProbed into the home when home is set:
+// the move is admitted under the home binding with "to" park.HomeRegion.
+func landingAtProbedHome(t *testing.T, store release.Store, host, id string, now time.Time, enrollment map[string]string, home string) hostclaim.Observed {
t.Helper()
ctx := t.Context()
created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)
@@ -221,7 +251,11 @@ func landingAtProbed(t *testing.T, store release.Store, host, id string, now tim
step(hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: enrollment["src"]}))
step(hostclaim.CompleteCreate("c1", "src"))
step(hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true}))
- step(hostclaim.BeginAdmittedMove(id, "ops", "src", "dst", "ie", now.Add(-3*time.Hour), hostclaim.MoveAdmission{AppID: "app-canonical", AdmissionID: id, SourceEnrollment: enrollment["src"], TargetEnrollment: enrollment["dst"]}))
+ to := "ie"
+ if home != "" {
+ to = park.HomeRegion
+ }
+ step(hostclaim.BeginAdmittedMove(id, "ops", "src", "dst", to, now.Add(-3*time.Hour), hostclaim.MoveAdmission{HomeRegion: home, AppID: "app-canonical", AdmissionID: id, SourceEnrollment: enrollment["src"], TargetEnrollment: enrollment["dst"]}))
step(hostclaim.ExecToNone(id, "src", "moves/"+host+"/"+id+"/stopped-source.json", hostclaim.JournalEvidenceSource))
step(hostclaim.SetJournal(id, map[string]string{hostclaim.JournalSeal: seal.Encode(), hostclaim.JournalCutSession: "1"}))
step(hostclaim.SetMovePhase(id, hostclaim.KindMoving, hostclaim.PhaseMoving, hostclaim.PhaseSealed))
@@ -238,3 +272,103 @@ func landingAtProbed(t *testing.T, store release.Store, host, id string, now tim
})
return o
}
+
+// Main's home-region parks name a move into the home by park.HomeRegion
+// ("to") while the roster, the fence binding and the region worker name it
+// by its roster name (ie). Under the binding the box and the move agree on
+// (the signed roster's home), the home's worker converts the committed
+// landing: the relocation's region is the roster identity (as
+// ConvertMovingToRelocating's is), the move's home binding is kept, and the
+// commit's parks are queued in engine regions exactly as moves' commit
+// queues them (the source's location in uk, the staging lane in
+// park.HomeRegion). A box with no binding, or a binding the roster does not
+// designate, writes nothing.
+func TestRelocatePassConvertsACommittedLandingIntoTheHome(t *testing.T) {
+ ctx := t.Context()
+ w := newLandingWorldHome(t, true, nil, "ie")
+ if err := convertFencedLanding(w.homeWorker(w.enrollment["wrk"], "ie", "ie"), w.store)(ctx, w.store, w.obs); err != nil {
+ t.Fatalf("convert: %v", err)
+ }
+ after, err := hostclaim.Read(ctx, w.store, w.obs.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := after.Claim
+ if c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != hostclaim.PhaseStarting || c.Op.Intent["source_box"] != "dst" || c.Op.Journal["conversion"] != "committed-landing" {
+ t.Fatalf("not converted: %+v", c.Op)
+ }
+ if c.Op.Intent["region"] != "ie" || c.Op.Intent["home_region"] != "ie" || c.Op.Intent["move_to_region"] != park.HomeRegion || c.Op.Journal["move_from_region"] != "uk" {
+ t.Fatalf("intent %v journal from %q", c.Op.Intent, c.Op.Journal["move_from_region"])
+ }
+ parks := map[string]int{}
+ for _, o := range c.Obligations {
+ if o.Kind == "park-replica" {
+ parks[o.Region+" "+o.Owner]++
+ }
+ }
+ if parks["uk region:uk"] != 1 || parks[park.HomeRegion+" region:"+park.HomeRegion] != 1 || len(parks) != 2 {
+ t.Fatalf("the commit's parks: %v", parks)
+ }
+
+ for _, tc := range []struct {
+ name string
+ change func(*hostactors.Roster)
+ home string
+ want error
+ }{
+ {name: "the box holds no home binding", want: park.ErrUnsafe},
+ {name: "the roster designates no home", change: func(r *hostactors.Roster) { r.HomeRegion = "" }, home: "ie", want: hostrelocate.ErrNotWorker},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ w := newLandingWorldHome(t, true, tc.change, "ie")
+ err := convertFencedLanding(w.homeWorker(w.enrollment["wrk"], "ie", tc.home), w.store)(ctx, w.store, w.obs)
+ if !errors.Is(err, tc.want) {
+ t.Fatalf("got %v, want %v", err, tc.want)
+ }
+ if after, err := hostclaim.Read(ctx, w.store, w.obs.Host); err != nil || after.ETag != w.obs.ETag {
+ t.Fatalf("the claim was written: %v", err)
+ }
+ })
+ }
+}
+
+// CommittedLandingRecords answers the record's placement codes as engine
+// regions (park.PlacementRegion), the regions moves' commit queues its
+// parks in: the home's "" is park.HomeRegion, and a code that is no
+// placement refuses.
+func TestCommittedLandingRecordsAnswersEngineRegions(t *testing.T) {
+ ctx := t.Context()
+ for _, tc := range []struct {
+ name, from, to string
+ wantFrom, wantTo string
+ refused bool
+ }{
+ {name: "named", from: "uk", to: "ie", wantFrom: "uk", wantTo: "ie"},
+ {name: "into the home", from: "uk", to: "", wantFrom: "uk", wantTo: park.HomeRegion},
+ {name: "out of the home", from: "", to: "uk", wantFrom: park.HomeRegion, wantTo: "uk"},
+ {name: "not a placement code", from: "UK", to: "ie", refused: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ w := newLandingWorld(t, true, nil)
+ c := w.obs.Claim
+ read := func(ctx context.Context, account, app, host string) (*provision.Record, error) {
+ rec, err := provision.GetRecord(ctx, w.store, account, app, host)
+ if err != nil {
+ return nil, err
+ }
+ rec.Move.From, rec.Move.To = tc.from, tc.to
+ return rec, nil
+ }
+ got, err := CommittedLandingRecords{Records: read}.CheckLandingCommitted(ctx, w.obs, c.Op.ID)
+ if tc.refused {
+ if !errors.Is(err, hostclaim.ErrWrongState) {
+ t.Fatalf("got %+v, %v", got, err)
+ }
+ return
+ }
+ if err != nil || got.FromRegion != tc.wantFrom || got.ToRegion != tc.wantTo {
+ t.Fatalf("got %+v, %v", got, err)
+ }
+ })
+ }
+}
diff --git a/internal/host/relocationowners.go b/internal/host/relocationowners.go
index 5dad4dd25..dbee77079 100644
--- a/internal/host/relocationowners.go
+++ b/internal/host/relocationowners.go
@@ -24,6 +24,7 @@ import (
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/release"
)
@@ -79,7 +80,7 @@ func (s *RelocationSource) Open(ctx context.Context, store release.Store, data l
}
releases := StopReceiptReleases{Receipts: RelocationReleaseReceipts{Store: store, Directory: s.Directory}}
inputs := &RelocationWakeInputs{Launch: src.Launch, FilesManifest: src.Files, Markers: src.Markers, Claims: store, FilesStore: files,
- Data: hostbox.RequestStore{Store: data, Check: src.Identity}, Bucket: actor.Bucket, Region: actor.Region, Box: actor.Box,
+ Data: hostbox.RequestStore{Store: data, Check: src.Identity}, Bucket: actor.Bucket, Region: park.PlacementRegion(actor.Region), HomeRegion: actor.HomeRegion, Box: actor.Box,
NewID: newClaimID, Now: time.Now, Releases: releases}
attempts := &relocationProbeAttempts{Claims: store, DataDir: s.DataDir, Box: actor.Box, NewID: newClaimID, Now: time.Now}
owners := ClaimRelocationOwners{
diff --git a/internal/host/relocationwake.go b/internal/host/relocationwake.go
index 5d3ebdde1..d3eb56776 100644
--- a/internal/host/relocationwake.go
+++ b/internal/host/relocationwake.go
@@ -20,6 +20,7 @@ import (
"github.com/carlosframework/platform/internal/hostrelocate"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -36,9 +37,13 @@ type RelocationWakeInputs struct {
Data genselect.Reader
Bucket string
Region string
- Box string
- NewID func() string
- Now func() time.Time
+ // HomeRegion is the box's verified home binding (hostbox.Actor's), or
+ // "": the selection and the fresh publisher see the holder through it
+ // (park.HolderRegionView), as main's serving owners do.
+ HomeRegion string
+ Box string
+ NewID func() string
+ Now func() time.Time
// Releases verifies a journaled release at its receipt (stage A's
// StopReceiptReleases over main's Complete).
Releases hostclaim.ReleaseAuthority
@@ -76,13 +81,13 @@ func (r *RelocationWakeInputs) freshOwners() *claimServingOwners {
r.mu.Lock()
defer r.mu.Unlock()
if r.fresh == nil {
- r.fresh = &claimServingOwners{Data: r.Data, Bucket: r.Bucket, Region: r.Region, Box: r.Box, NewID: r.NewID, Now: r.Now, Sources: ClaimServingSources{Markers: r.Markers}}
+ r.fresh = &claimServingOwners{HomeRegion: r.HomeRegion, Data: r.Data, Bucket: r.Bucket, Region: r.Region, Box: r.Box, NewID: r.NewID, Now: r.Now, Sources: ClaimServingSources{Markers: r.Markers}}
}
return r.fresh
}
func (r *RelocationWakeInputs) request(o hostclaim.Observed) genselect.Request {
- return genselect.Request{Bucket: r.Bucket, Region: r.Region, Host: o.Host, Holder: *o.Claim.Holder}
+ return genselect.Request{Bucket: r.Bucket, Region: r.Region, Host: o.Host, Holder: park.HolderRegionView(*o.Claim.Holder, r.HomeRegion, r.Region)}
}
func preparedAuthority(c *hostclaim.Claim) (hostclaim.PreparedAuthority, error) {
diff --git a/internal/hostclaim/relocating.go b/internal/hostclaim/relocating.go
index 2ff1de3f8..62fc536c3 100644
--- a/internal/hostclaim/relocating.go
+++ b/internal/hostclaim/relocating.go
@@ -7,6 +7,7 @@ import (
"slices"
"strconv"
+ "github.com/carlosframework/platform/internal/regionname"
"github.com/carlosframework/platform/internal/release"
)
@@ -168,9 +169,11 @@ func ConvertMovingToRelocating(ctx context.Context, s release.Store, observed Ob
}
// CommittedLanding is the authoritative instance record's committed move
-// as a CommittedLandingGuard read it: the regions it moved between and the
-// two parks the commit queues, built by moves.CommitScopes from the
-// record's flip time and the claim's seal (the one builder moves' own
+// as a CommittedLandingGuard read it: the regions it moved between, as
+// engine regions (park.PlacementRegion of the record's placement codes, so
+// the home is park.HomeRegion, exactly the regions moves' own commit
+// queues its parks in), and the two parks the commit queues, built by
+// moves.CommitScopes from the record's flip time and the claim's seal (the one builder moves' own
// commit uses). The scopes are used only when the claim has not yet
// followed the commit.
type CommittedLanding struct {
@@ -241,7 +244,12 @@ func ConvertCommittedLanding(ctx context.Context, s release.Store, observed Obse
if err != nil {
return Observed{}, err
}
- if committed.FromRegion == "" || committed.ToRegion != in["to"] || committed.FromRegion == committed.ToRegion {
+ // The committed regions are engine regions, and so is the move's "to"
+ // (BeginClaimMove's park.PlacementRegion); the home's two names are one
+ // region only under the move's own home binding (main's hostclaim reads
+ // it from the intent the same way).
+ home := in["home_region"]
+ if committed.FromRegion == "" || !regionname.SameRegion(home, committed.ToRegion, in["to"]) || regionname.SameRegion(home, committed.FromRegion, committed.ToRegion) {
return Observed{}, ErrWrongState
}
fence, err := VerifyFence(ctx, s, authority, ref)
@@ -249,8 +257,10 @@ func ConvertCommittedLanding(ctx context.Context, s release.Store, observed Obse
return Observed{}, err
}
// The fence is the target's, bound to the target's region in the
- // roster, which is the record's destination.
- if fence.record.Box != targetBox || fence.record.Enrollment != in["target_enrollment"] || fence.binding.Region != committed.ToRegion {
+ // roster, which is the record's destination. The binding is the roster
+ // name, so a landing in the home matches its park.HomeRegion only under
+ // the move's home binding.
+ if fence.record.Box != targetBox || fence.record.Enrollment != in["target_enrollment"] || !regionname.SameRegion(home, fence.binding.Region, committed.ToRegion) {
return Observed{}, ErrFence
}
return Update(ctx, s, observed, func(c *Claim) error {
@@ -311,7 +321,11 @@ func ConvertCommittedLanding(ctx context.Context, s release.Store, observed Obse
}
delete(intent, "target")
delete(intent, "target_enrollment")
- intent["region"] = committed.ToRegion
+ // The relocation's region is the verified roster identity, as
+ // ConvertMovingToRelocating's is (the fence binding, which matched
+ // the destination above): the region worker's own name, never
+ // park.HomeRegion.
+ intent["region"] = fence.binding.Region
intent["source_box"] = targetBox
intent["source_enrollment"] = in["target_enrollment"]
intent["stamp_from"] = strconv.FormatUint(c.Holder.Stamp, 10)
diff --git a/internal/hostclaim/relocation.go b/internal/hostclaim/relocation.go
index ffe02e47f..d4220a17c 100644
--- a/internal/hostclaim/relocation.go
+++ b/internal/hostclaim/relocation.go
@@ -10,6 +10,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/regionname"
"github.com/carlosframework/platform/internal/release"
)
@@ -55,9 +56,15 @@ var (
// RelocationStart is the start CAS's intent (spec 1 §5.8, claim §4). Fence
// is set for a failover only and names the source's termination fence.
type RelocationStart struct {
- OpID, By string
- Now time.Time
- Region, Reason string
+ OpID, By string
+ Now time.Time
+ Region, Reason string
+ // HomeRegion is the starting box's verified home binding (the fleet's
+ // home roster name when Region is it, hostbox.Actor.HomeRegion), or ""
+ // with no alias. It lets the start guard see the home's chores under
+ // either of its names (regionname.SameRegion), and is recorded as the
+ // op's "home_region" intent, as BeginAdmittedMove records a move's.
+ HomeRegion string
SourceBox, SourceEnrollment string
TargetBox, TargetEnrollment string
DirectoryRevision uint64
@@ -259,8 +266,11 @@ func failover(op Op) bool {
// RelocationStartGuard is §4's start guard with the relocation exception
// (L11): a park-generation chore in the region does not block, a
// park-replica in the region does, a park-local on the target does, and a
-// park-local on a fenced source does not block a failover.
-func RelocationStartGuard(c *Claim, region, source, target string, sourceFenced bool) error {
+// park-local on a fenced source does not block a failover. home is the
+// verified home binding or "": a park-replica queued under the home's
+// other name (park.HomeRegion, as moves queues it) is the same region
+// (regionname.SameRegion), as BeginAdmittedMove's start guard reads both.
+func RelocationStartGuard(c *Claim, home, region, source, target string, sourceFenced bool) error {
if c.Holder == nil || c.Op.Kind != KindServing || c.Op.ID != "" {
return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind)
}
@@ -273,7 +283,7 @@ func RelocationStartGuard(c *Claim, region, source, target string, sourceFenced
}
for _, o := range c.Obligations {
switch {
- case o.Kind == "park-replica" && o.Region == region:
+ case o.Kind == "park-replica" && regionname.SameRegion(home, o.Region, region):
return fmt.Errorf("%w: park-replica in %s", ErrPendingChore, region)
case o.Kind == "park-local" && o.Box == target:
return fmt.Errorf("%w: park-local on %s", ErrPendingChore, target)
@@ -460,6 +470,9 @@ func StartRelocation(ctx context.Context, s release.Store, observed Observed, in
default:
return Observed{}, fmt.Errorf("%w: reason %q", ErrRelocation, in.Reason)
}
+ if in.HomeRegion != "" && regionname.PlacementRegion(in.HomeRegion) != in.HomeRegion {
+ return Observed{}, fmt.Errorf("%w: home region %q is not a placement code", ErrRelocation, in.HomeRegion)
+ }
if in.OpID == "" || in.Region == "" || in.AdmissionID == "" || in.DirectoryRevision == 0 || in.Now.IsZero() ||
!validBoxReference(in.SourceBox) || !validBoxReference(in.TargetBox) || in.SourceBox == "none" || in.TargetBox == "none" ||
in.SourceBox == in.TargetBox || in.SourceEnrollment == "" || in.TargetEnrollment == "" {
@@ -480,7 +493,7 @@ func StartRelocation(ctx context.Context, s release.Store, observed Observed, in
if c.Op.Exec != in.SourceBox || slices.Contains(c.Lost, in.TargetBox) {
return fmt.Errorf("%w: the source is not the executor, or the target is lost", ErrWrongState)
}
- if err := RelocationStartGuard(c, in.Region, in.SourceBox, in.TargetBox, in.Fence != nil); err != nil {
+ if err := RelocationStartGuard(c, in.HomeRegion, in.Region, in.SourceBox, in.TargetBox, in.Fence != nil); err != nil {
return err
}
exec, participants := in.SourceBox, []string{in.SourceBox, in.TargetBox}
@@ -499,6 +512,9 @@ func StartRelocation(ctx context.Context, s release.Store, observed Observed, in
"admission_id": in.AdmissionID,
},
Journal: map[string]string{}}
+ if in.HomeRegion != "" {
+ c.Op.Intent["home_region"] = in.HomeRegion
+ }
if in.Fence != nil {
return fenceBox(c, in.SourceBox, *in.Fence, observedAt, in.Discharge)
}
@@ -1104,7 +1120,7 @@ func CompleteRelocation(opID string, newID func() string) func(*Claim) error {
}
queued := false
for _, o := range c.Obligations {
- if o.Kind == "park-generation" && o.Region == region && o.Generation == g {
+ if o.Kind == "park-generation" && regionname.SameRegion(c.Op.Intent["home_region"], o.Region, region) && o.Generation == g {
queued = true
}
}
diff --git a/internal/hostclaim/relocation_home_test.go b/internal/hostclaim/relocation_home_test.go
new file mode 100644
index 000000000..5cbaaf4b4
--- /dev/null
+++ b/internal/hostclaim/relocation_home_test.go
@@ -0,0 +1,188 @@
+package hostclaim
+
+import (
+ "context"
+ "errors"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/regionname"
+)
+
+// Main's home-region parks (park.HomeRegion, "@home") name the home's
+// chores by that token where moves queues them, while a relocation's region
+// is the roster name (ie here). Under a verified home binding the two are
+// one region (regionname.SameRegion), so a relocation in the home sees and
+// reuses the chores moves queued under the other name. Without a binding
+// the comparison stays exact, as main's own comparisons do.
+
+func TestRelocationStartGuardReadsTheHomesTwoNames(t *testing.T) {
+ serving := func(obs ...Obligation) *Claim {
+ return &Claim{Holder: &Holder{Incarnation: "i"}, Op: Op{Kind: KindServing, Exec: relSource}, Obligations: obs}
+ }
+ for _, tc := range []struct {
+ name, home, region string
+ ob Obligation
+ want error
+ }{
+ {"park-replica under @home, home bound", "ie", "ie", Obligation{Kind: "park-replica", Region: regionname.HomeRegion}, ErrPendingChore},
+ {"park-replica under @home, no binding", "", "ie", Obligation{Kind: "park-replica", Region: regionname.HomeRegion}, nil},
+ {"park-replica under the roster name, home bound", "ie", "ie", Obligation{Kind: "park-replica", Region: "ie"}, ErrPendingChore},
+ {"park-replica under @home, another region's relocation", "ie", "uk", Obligation{Kind: "park-replica", Region: regionname.HomeRegion}, nil},
+ {"park-generation under @home (L11)", "ie", "ie", Obligation{Kind: "park-generation", Region: regionname.HomeRegion}, nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := RelocationStartGuard(serving(tc.ob), tc.home, tc.region, relSource, relTarget, false); !errors.Is(err, tc.want) {
+ t.Fatalf("got %v, want %v", err, tc.want)
+ }
+ })
+ }
+}
+
+func TestStartRelocationCarriesTheHomeBinding(t *testing.T) {
+ ctx := context.Background()
+ t.Run("recorded as the op's home_region", func(t *testing.T) {
+ s, o := relServing(t)
+ in := relStart(ReasonDrain, nil)
+ in.HomeRegion = "ie"
+ o, err := StartRelocation(ctx, s, o, in, relAuthority{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Claim.Op.Intent["home_region"] != "ie" || o.Claim.Op.Intent["region"] != "ie" {
+ t.Fatalf("intent %v", o.Claim.Op.Intent)
+ }
+ })
+ t.Run("no binding records none", func(t *testing.T) {
+ s, o := relServing(t)
+ o, err := StartRelocation(ctx, s, o, relStart(ReasonDrain, nil), relAuthority{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := o.Claim.Op.Intent["home_region"]; ok {
+ t.Fatalf("intent %v", o.Claim.Op.Intent)
+ }
+ })
+ t.Run("a park-replica under @home refuses the start", func(t *testing.T) {
+ s, o := relServing(t)
+ o = step(t, s, o, func(c *Claim) error {
+ c.Obligations = append(c.Obligations, Obligation{ID: "lane", Op: "mv-old", Kind: "park-replica", Region: regionname.HomeRegion, Owner: "region:" + regionname.HomeRegion, Retirement: "r"})
+ return nil
+ })
+ in := relStart(ReasonDrain, nil)
+ in.HomeRegion = "ie"
+ raw := mustJSON(t, o.Claim)
+ if _, err := StartRelocation(ctx, s, o, in, relAuthority{}); !errors.Is(err, ErrPendingChore) {
+ t.Fatalf("got %v", err)
+ }
+ if stored, err := Read(ctx, s, o.Host); err != nil || stored.ETag != o.ETag || string(raw) != string(mustJSON(t, o.Claim)) {
+ t.Fatalf("refusal wrote the claim: %v", err)
+ }
+ })
+ t.Run("a home that is no placement code refuses", func(t *testing.T) {
+ s, o := relServing(t)
+ in := relStart(ReasonDrain, nil)
+ in.HomeRegion = regionname.HomeRegion
+ if _, err := StartRelocation(ctx, s, o, in, relAuthority{}); !errors.Is(err, ErrRelocation) {
+ t.Fatalf("got %v", err)
+ }
+ if stored, err := Read(ctx, s, o.Host); err != nil || stored.ETag != o.ETag {
+ t.Fatalf("refusal wrote the claim: %v", err)
+ }
+ })
+}
+
+// The completion parks an abandoned P in the relocation's region unless a
+// park-generation for it is already owed there, under either of the home's
+// names when the op is bound to the home.
+func TestRelocationCompletionReusesAParkUnderTheHomesOtherName(t *testing.T) {
+ ctx := context.Background()
+ for _, home := range []string{"ie", ""} {
+ t.Run("home "+home, func(t *testing.T) {
+ s, o := relAtPhase(t, PhaseInstalling)
+ o, err := ReplaceTarget(ctx, s, o, "rel-1", putFence(t, s, relTarget), nil, Target{relSpare, relEnrollment(relSpare)}, "P2", relAuthority{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ o = step(t, s, o, func(c *Claim) error {
+ if home != "" {
+ c.Op.Intent["home_region"] = home
+ }
+ c.Obligations = append(c.Obligations, Obligation{ID: "earlier", Op: "mv-old", Kind: "park-generation", Region: regionname.HomeRegion, Owner: "region:" + regionname.HomeRegion, Generation: "P1", Retirement: "r-earlier"})
+ return nil
+ })
+ o = step(t, s, o, ack(relSpare))
+ o = step(t, s, o, ToInstalled("rel-1", relPrepared("P2", 40, 7)))
+ o = step(t, s, o, RecordLocalInstall("rel-1", localInstall(o.Claim)))
+ o = step(t, s, o, ToProbing("rel-1", relObserved))
+ o = step(t, s, o, AdmitWriter(Admission{ObservedOpID: "rel-1", ObservedPhase: PhaseRelocatingProbing, ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation,
+ Box: relSpare, Mode: "cold", NewGeneration: "P2", KeepStamp: true, PredecessorCertified: true, Region: "ie", PredecessorRegion: "ie"}))
+ o = step(t, s, o, RecordProbe("rel-1", Probe{Box: relSpare, Session: o.Claim.Holder.Session}))
+ o = step(t, s, o, RecordProbeStop("rel-1", ProbeStop{Box: relSpare, Session: o.Claim.Holder.Session, Receipt: "fence-1", ReceiptSHA256: strings.Repeat("a", 64)}))
+ o = step(t, s, o, CompleteRelocation("rel-1", counter()))
+ var regions []string
+ for _, ob := range o.Claim.Obligations {
+ if ob.Kind == "park-generation" && ob.Generation == "P1" {
+ regions = append(regions, ob.Region)
+ }
+ }
+ want := []string{regionname.HomeRegion}
+ if home == "" {
+ want = append(want, "ie") // exact without a binding: a second chore in the roster name
+ }
+ slices.Sort(regions)
+ slices.Sort(want)
+ if !slices.Equal(regions, want) {
+ t.Fatalf("P1's parks: %v, want %v", regions, want)
+ }
+ })
+ }
+}
+
+// J3 into the home: the move names its destination park.HomeRegion and
+// the fence binding names the roster's home (uk here). They are one region
+// only under the move's home binding; the relocation's region is then the
+// roster name, and the commit's lane park is queued in park.HomeRegion, as
+// moves' own commit queues it.
+func TestJ3CommittedLandingIntoTheHomeNeedsTheMovesHomeBinding(t *testing.T) {
+ ctx := context.Background()
+ for _, bound := range []bool{true, false} {
+ name := "bound"
+ if !bound {
+ name = "unbound"
+ }
+ t.Run(name, func(t *testing.T) {
+ s, o := j3Landing(t, PhaseLandingProbed)
+ o = step(t, s, o, func(c *Claim) error {
+ c.Op.Intent["to"] = regionname.HomeRegion
+ if bound {
+ c.Op.Intent["home_region"] = "uk"
+ }
+ return nil
+ })
+ ref := j3Fence(t, s)
+ s.puts = 0
+ after, err := ConvertCommittedLanding(ctx, s, o, moveA, j3Target, ref, j3Authority{}, &j3Guard{to: regionname.HomeRegion}, nil, counter())
+ if !bound {
+ if !errors.Is(err, ErrFence) || s.puts != 0 {
+ t.Fatalf("got %v, writes %d", err, s.puts)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := after.Claim
+ if c.Op.Intent["region"] != "uk" || c.Op.Intent["home_region"] != "uk" || c.Op.Intent["move_to_region"] != regionname.HomeRegion {
+ t.Fatalf("intent %v", c.Op.Intent)
+ }
+ if owner, err := RelocationRecoveryOwner(c); err != nil || owner != "region:uk" {
+ t.Fatalf("owner=%q error=%v", owner, err)
+ }
+ if k := obligationKinds(c); k["park-replica/ie"] != 1 || k["park-replica/"+regionname.HomeRegion] != 1 {
+ t.Fatalf("the commit's chores: %v", k)
+ }
+ })
+ }
+}
diff --git a/internal/hostclaim/relocation_test.go b/internal/hostclaim/relocation_test.go
index 0aa35ddc8..17adfdf96 100644
--- a/internal/hostclaim/relocation_test.go
+++ b/internal/hostclaim/relocation_test.go
@@ -896,7 +896,7 @@ func TestRelocationStartGuard(t *testing.T) {
{"park-local on a fenced source", Obligation{Kind: "park-local", Box: relSource}, true, nil},
} {
t.Run(tc.name, func(t *testing.T) {
- if err := RelocationStartGuard(serving(tc.ob), "ie", relSource, relTarget, tc.fenced); !errors.Is(err, tc.want) {
+ if err := RelocationStartGuard(serving(tc.ob), "", "ie", relSource, relTarget, tc.fenced); !errors.Is(err, tc.want) {
t.Fatalf("got %v, want %v", err, tc.want)
}
})
diff --git a/internal/hostops/executor.go b/internal/hostops/executor.go
index d948393a1..d34440d20 100644
--- a/internal/hostops/executor.go
+++ b/internal/hostops/executor.go
@@ -9,6 +9,7 @@ import (
"github.com/carlosframework/platform/internal/boxchoice"
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
)
// ErrNoEligibleBox is a create refused because no box in the placement is
@@ -86,6 +87,14 @@ func (x ExecutorChooser) Permits(ctx context.Context, obs hostclaim.Observed, r
// reduced to boxchoice.Eligible's candidates.
func (x ExecutorChooser) eligible(ctx context.Context, obs hostclaim.Observed, r Record) (hostactors.Observed, []boxchoice.Candidate, error) {
placement := r.Holder.Region
+ // A record placed in the home names it park.HomeRegion when no home
+ // name is configured on the console (main's claimPlacementRegion); the
+ // roster, its boxes and their edges files name it by the home's roster
+ // name. The one mapping is main's StartCreate's: park.HomeRegion is
+ // HomeName when HomeName is a placement code.
+ if placement == park.HomeRegion && x.HomeName != "" && park.PlacementRegion(x.HomeName) == x.HomeName {
+ placement = x.HomeName
+ }
if x.Edges == nil || x.Loads == nil || x.Now == nil || placement == "" {
return hostactors.Observed{}, nil, ErrContract
}
diff --git a/internal/hostops/executor_test.go b/internal/hostops/executor_test.go
index fc61916d4..c874eac2f 100644
--- a/internal/hostops/executor_test.go
+++ b/internal/hostops/executor_test.go
@@ -15,6 +15,7 @@ import (
"github.com/carlosframework/platform/internal/boxchoice"
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/termfence"
)
@@ -394,3 +395,24 @@ func TestProductionCreateWithAChooserPlacesWithinPlan5sScope(t *testing.T) {
}
})
}
+
+// Main's home-region parks: a record placed in the home with no home name
+// configured names it park.HomeRegion. With the home binding (HomeName,
+// the create door's ProductionAuthority.HomeRegion) the chooser places it
+// in the home's roster region, as main's StartCreate maps it; with none,
+// park.HomeRegion is no roster region and the create is refused.
+func TestExecutorChooserPlacesAHomeRecordUnderTheHomeBinding(t *testing.T) {
+ ctx := context.Background()
+ r := chooserRecord()
+ r.Holder.Region = park.HomeRegion
+ absent := hostclaim.Observed{Host: r.Holder.Host, Absent: true}
+ x, _, _, _ := chooserFixture(t, "box-a")
+ x.HomeName = "ie"
+ if got, err := x.Choose(ctx, absent, r); err != nil || got.Box != "box-a" {
+ t.Fatalf("bound: %+v %v", got, err)
+ }
+ x.HomeName = ""
+ if _, err := x.Choose(ctx, absent, r); !errors.Is(err, ErrContract) {
+ t.Fatalf("unbound: %v", err)
+ }
+}
diff --git a/internal/hostrelocate/pin.go b/internal/hostrelocate/pin.go
index 24d5ab6f9..01c7cacb2 100644
--- a/internal/hostrelocate/pin.go
+++ b/internal/hostrelocate/pin.go
@@ -16,6 +16,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfiles"
"github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/release"
)
@@ -91,6 +92,11 @@ type StorePinner struct {
SelectDB func(context.Context, genselect.Request) (*genselect.Plan, error)
Bucket string // the region's data bucket name, for genselect
Region string
+ // HomeRegion is the box's verified home binding, or "": genselect sees
+ // the holder's admissions through it (park.HolderRegionView), as main's
+ // serving baseline does, so an admission recorded under the home's
+ // other name is this region's.
+ HomeRegion string
// Release is the app release version the host's relocation installs,
// read from the region controller's authoritative registry route for
// the host (registry.Route.Version, the route's deployed version) at
@@ -135,7 +141,7 @@ func (p StorePinner) Pin(ctx context.Context, obs hostclaim.Observed, now time.T
return hostclaim.RecoveryPin{}, fmt.Errorf("%w: the registry route for %s names no release", ErrRefused, c.Host)
}
obj := PinObject{V: 1, Host: c.Host, Op: c.Op.ID, ClaimGen: c.Gen, PinnedAt: now.UTC(), Release: version}
- plan, err := p.SelectDB(ctx, genselect.Request{Bucket: p.Bucket, Region: p.Region, Host: c.Host, Holder: *c.Holder})
+ plan, err := p.SelectDB(ctx, genselect.Request{Bucket: p.Bucket, Region: p.Region, Host: c.Host, Holder: park.HolderRegionView(*c.Holder, p.HomeRegion, p.Region)})
switch {
case err == nil:
obj.DB = plan
diff --git a/internal/hostrelocate/pin_test.go b/internal/hostrelocate/pin_test.go
index d9d8cc786..2c491498a 100644
--- a/internal/hostrelocate/pin_test.go
+++ b/internal/hostrelocate/pin_test.go
@@ -15,6 +15,7 @@ import (
"github.com/carlosframework/platform/internal/genselect"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/release"
)
@@ -750,3 +751,41 @@ func TestStorePinnerRefusesAManifestWithoutTheEnvOrWakeState(t *testing.T) {
})
}
}
+
+// Main's home-region parks: an admission moves recorded in the home names
+// it park.HomeRegion. The pinner's selection sees the holder through the
+// box's home binding (park.HolderRegionView), as main's serving baseline
+// does, so that admission is this region's; with no binding the holder is
+// read exactly as stored.
+func TestStorePinnerSelectsThroughTheHomeBinding(t *testing.T) {
+ ctx := context.Background()
+ for _, home := range []string{"ie", ""} {
+ t.Run("home "+home, func(t *testing.T) {
+ claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
+ putManifest(t, data, "g1", keyed(hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 9, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}))
+ var seen []string
+ p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", HomeRegion: home,
+ SelectDB: func(ctx context.Context, req genselect.Request) (*genselect.Plan, error) {
+ for _, a := range req.Holder.Admissions {
+ seen = append(seen, a.Region+"/"+a.PredecessorRegion)
+ }
+ return selectAt(41)(ctx, req)
+ }}
+ obs := pinClaim()
+ obs.Claim.Holder.Admissions[1].Region, obs.Claim.Holder.Admissions[1].PredecessorRegion = park.HomeRegion, "uk"
+ if _, err := p.Pin(ctx, obs, time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)); err != nil {
+ t.Fatal(err)
+ }
+ want := "@home/uk"
+ if home != "" {
+ want = "ie/uk"
+ }
+ if len(seen) != 2 || seen[1] != want {
+ t.Fatalf("selection saw %v, want second %s", seen, want)
+ }
+ if obs.Claim.Holder.Admissions[1].Region != park.HomeRegion {
+ t.Fatal("the view changed the claim's own holder")
+ }
+ })
+ }
+}
diff --git a/internal/hostrelocate/worker.go b/internal/hostrelocate/worker.go
index 669261f7b..00bc62e79 100644
--- a/internal/hostrelocate/worker.go
+++ b/internal/hostrelocate/worker.go
@@ -26,6 +26,7 @@ import (
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfence"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/termfence"
)
@@ -133,9 +134,15 @@ type Worker struct {
Discharge func(context.Context, hostactors.Box, termfence.Record) *hostclaim.LocalDischarge
// Notify bumps the control serial after an executor CAS (§5.3,
// "Prompt changes"). A failed bump costs cadence, never correctness.
- Notify func(context.Context)
- Self termfence.WrittenBy
- Region string
+ Notify func(context.Context)
+ Self termfence.WrittenBy
+ Region string
+ // HomeRegion is this box's verified home binding (hostbox.Actor's, the
+ // fleet's home roster name when Region is it), or "" with no alias. It
+ // is the one binding under which the home's two names, Region and
+ // park.HomeRegion, are one region (park.SameRegion), as main's claim
+ // owners read it (park.VerifiedHome against the op's "home_region").
+ HomeRegion string
Controller string
NewID func() string
Now func() time.Time
@@ -162,6 +169,11 @@ func (w Worker) self(ctx context.Context) (hostactors.Observed, error) {
if !ok || b.State != "active" || b.Enrollment != w.Self.Enrollment || b.Region != w.Region {
return hostactors.Observed{}, ErrNotWorker
}
+ // A home binding is the signed roster's home, and only the home's own
+ // boxes hold it (hostbox.IdentityProvider.Verify's rule).
+ if w.HomeRegion != "" && (w.HomeRegion != o.Roster.HomeRegion || w.HomeRegion != w.Region) {
+ return hostactors.Observed{}, ErrNotWorker
+ }
fenced, err := w.Directory.TerminationFence(ctx, o, w.Self.Box, w.Now())
if err != nil || fenced {
return hostactors.Observed{}, errors.Join(ErrNotWorker, err)
@@ -219,7 +231,17 @@ func (w Worker) ConvertLanding(ctx context.Context, obs hostclaim.Observed, guar
}
c := obs.Claim
box := c.Op.Exec
- if c.Op.Kind != hostclaim.KindLanding || c.Op.Intent["to"] != w.Region || box == "" || box == "none" {
+ if c.Op.Kind != hostclaim.KindLanding || box == "" || box == "none" {
+ return obs, nil
+ }
+ // The landing's "to" is an engine region (park.HomeRegion for the
+ // home); this worker's Region is its roster name. They are one region
+ // only under a home binding this box and the move agree on.
+ home, err := park.VerifiedHome(o.Roster.HomeRegion, w.HomeRegion, w.Region, c.Op.Intent["home_region"])
+ if err != nil {
+ return obs, err
+ }
+ if !park.SameRegion(home, c.Op.Intent["to"], w.Region) {
return obs, nil
}
ref, record, err := w.fence(ctx, o, box)
@@ -331,7 +353,7 @@ func (w Worker) Start(ctx context.Context, host string, req Request) (hostclaim.
if err := w.Admission.Recheck(ctx, admission); err != nil {
return hostclaim.Observed{}, err
}
- in := hostclaim.RelocationStart{OpID: id, By: "box:" + w.Self.Box, Now: w.Now(), Region: w.Region, Reason: req.Reason,
+ in := hostclaim.RelocationStart{OpID: id, By: "box:" + w.Self.Box, Now: w.Now(), Region: w.Region, HomeRegion: w.HomeRegion, Reason: req.Reason,
SourceBox: req.Source, SourceEnrollment: source.Enrollment, TargetBox: req.Target, TargetEnrollment: target.Enrollment,
DirectoryRevision: revision, AdmissionID: admission, Fence: ref}
if ref != nil {
@@ -365,6 +387,11 @@ func (w Worker) Step(ctx context.Context, host string) (hostclaim.Observed, erro
if c.Op.Kind != hostclaim.KindRelocating || c.Op.Intent["region"] != w.Region || c.Op.Journal["stuck"] != "" {
return obs, nil
}
+ // An op bound to a home names it in "home_region"; this box steps it
+ // only under the same binding (park.VerifiedHome, main's rule).
+ if _, err := park.VerifiedHome(o.Roster.HomeRegion, w.HomeRegion, w.Region, c.Op.Intent["home_region"]); err != nil {
+ return obs, err
+ }
id := c.Op.ID
// Evidence first: a fenced participant changes what every phase may do.
// A pass that refuses here checks the release deadline first (refused).
diff --git a/internal/hostrelocate/worker_test.go b/internal/hostrelocate/worker_test.go
index 1c2908800..af20b469e 100644
--- a/internal/hostrelocate/worker_test.go
+++ b/internal/hostrelocate/worker_test.go
@@ -17,6 +17,7 @@ import (
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfence"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/termfence"
)
@@ -977,3 +978,58 @@ func TestWorkerNamesAConvertedLandingsTargetBeforeItsPin(t *testing.T) {
t.Fatalf("the failover changed an obligation:\n%s\n%s", after, chores)
}
}
+
+// Main's home-region parks: a worker in the home holds the box's verified
+// home binding. It starts a relocation under it (the op's "home_region",
+// which lets the claim see the home's chores under park.HomeRegion too),
+// and only when the signed roster designates that home and the worker's
+// region is it.
+func TestWorkerStartsUnderItsHomeBinding(t *testing.T) {
+ ctx := context.Background()
+ t.Run("designated", func(t *testing.T) {
+ f := newFixture(t)
+ f.roster.HomeRegion = "ie"
+ f.writeRoster()
+ f.w.HomeRegion = "ie"
+ o, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Claim.Op.Intent["home_region"] != "ie" || o.Claim.Op.Intent["region"] != "ie" {
+ t.Fatalf("intent %v", o.Claim.Op.Intent)
+ }
+ })
+ t.Run("not designated", func(t *testing.T) {
+ f := newFixture(t)
+ f.w.HomeRegion = "ie"
+ before := f.claim()
+ if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); !errors.Is(err, ErrNotWorker) {
+ t.Fatalf("got %v", err)
+ }
+ if after := f.claim(); after.ETag != before.ETag {
+ t.Fatal("a refused start wrote the claim")
+ }
+ })
+ t.Run("an op bound to another home is not stepped", func(t *testing.T) {
+ f := newFixture(t)
+ o, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.ack("src")
+ f.ack("dst")
+ o, err = hostclaim.Update(ctx, f.s, f.claim(), func(c *hostclaim.Claim) error {
+ c.Op.Intent["home_region"] = "uk"
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.w.Step(ctx, host); !errors.Is(err, park.ErrUnsafe) {
+ t.Fatalf("got %v", err)
+ }
+ if after := f.claim(); after.ETag != o.ETag {
+ t.Fatal("the step wrote the claim")
+ }
+ })
+}