carlos / platform Public

hostclaim: Plan 13, home-region naming (inert behind CARLOS_HOST_CLAIM)

A home box's claim engine keeps its verified roster name, bound to
moves' @home on the verified local home placement as one regional
identity: the regional runner acknowledges both spellings, quiet checks
take the later bound, whole-location coverage and pre-flip recovery go
through the alias, and remote home history is projected through the
operation's verified binding. One shared rule (internal/regionname,
HomePlacementCode) serves the console door and BeginClaimMove; loss
SourceRegion is mapped through PlacementRegion; invalid source codes
still refuse. A syntax-tree lint rejects direct record-vs-engine region
comparisons. No stored claim is rewritten.
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev daa062ffbeff3b05aa2b140a66c1b7844babfdfc parent 359e8ac
81 files changed, +1859 −186
  • home_region_comparison_test.go +95 −0
  • internal/boxchoice/boxchoice.go +2 −6
  • internal/console/apidbclaim.go +29 −1
  • internal/console/apiinstancemove.go +12 −17
  • internal/console/apiinstancemove_claim_test.go +14 −20
  • internal/console/apiinstances.go +1 −4
  • internal/console/apisidecars.go +1 −4
  • internal/console/claim_first_route.go +1 −1
  • internal/console/claim_runtime.go +1 −1
  • internal/console/movesrecovery.go +1 −1
  • internal/console/plan13_amendments_test.go +90 −0
  • internal/console/plan13_legacy_test.go +52 −0
  • internal/console/plan13_region_test.go +36 −0
  • internal/console/testdata/claim-authority-sites.txt +1 −1
  • internal/host/claimassembly.go +6 −5
  • internal/host/claimcleanup_identity.go +1 −0
  • internal/host/claimconstruction.go +1 −1
  • internal/host/claimhandover_recovery.go +2 −1
  • internal/host/claimlaunch_authority.go +2 −1
  • internal/host/claimmovefence.go +1 −1
  • internal/host/claimowners.go +3 −1
  • internal/host/claimproduction.go +18 −5
  • internal/host/claimrecoveryreceipt.go +3 −2
  • internal/host/claimregion.go +20 −0
  • internal/host/movesfinal.go +3 −3
  • internal/host/movesinstall.go +1 −1
  • internal/host/moveslandpark.go +1 −1
  • internal/host/movespass.go +6 −5
  • internal/host/movesphase1.go +3 −2
  • internal/host/movespolicy.go +3 −3
  • internal/host/movesprobeowners.go +13 −11
  • internal/host/movesreport.go +3 −2
  • internal/host/movesrevert.go +3 −3
  • internal/host/movesrollback.go +3 −3
  • internal/host/movessources.go +12 −7
  • internal/host/plan13_amendments_test.go +46 −0
  • internal/host/plan13_policy_test.go +22 −0
  • internal/host/plan13_quiet_test.go +23 −0
  • internal/host/plan13_receipt_test.go +93 −0
  • internal/host/plan13_region_test.go +44 −0
  • internal/host/plan13_views_test.go +121 −0
  • internal/hostbox/identity.go +7 −0
  • internal/hostbox/park.go +1 −1
  • internal/hostbox/plan13_admission_test.go +46 −0
  • internal/hostbox/plan13_region_test.go +103 −0
  • internal/hostbox/reconcile.go +2 −1
  • internal/hostbox/regionalworker.go +2 −2
  • internal/hostbox/work.go +3 −1
  • internal/hostbox/writer.go +3 −1
  • internal/hostclaim/generation.go +3 −2
  • internal/hostclaim/move.go +15 −0
  • internal/hostclaim/plan13_amendments_test.go +68 −0
  • internal/hostclaim/relocating.go +2 −1
  • internal/hostops/backfill.go +1 −0
  • internal/hostops/lifecycle_record.go +3 −2
  • internal/hostops/move_enrollment.go +23 −6
  • internal/hostops/move_fence_regions.go +34 −0
  • internal/hostops/move_fence_release.go +1 −1
  • internal/hostops/move_loss_admission.go +2 −1
  • internal/hostops/move_loss_owner.go +3 −2
  • internal/hostops/move_recovery_bridge.go +14 −7
  • internal/hostops/move_recovery_grant.go +2 −2
  • internal/hostops/move_region.go +27 −0
  • internal/hostops/plan13_conversion_test.go +78 −0
  • internal/hostops/plan13_enrollment_test.go +50 −0
  • internal/hostops/plan13_fence_test.go +73 −0
  • internal/hostops/plan13_loss_test.go +77 −0
  • internal/hostops/plan13_remote_test.go +37 −0
  • internal/hostops/production_create.go +7 −2
  • internal/hostops/remote.go +1 −1
  • internal/park/home_view.go +33 −0
  • internal/park/plan13_holder_test.go +25 −0
  • internal/park/plan13_observe_test.go +97 −0
  • internal/park/plan13_region_test.go +51 −0
  • internal/park/region.go +3 −15
  • internal/park/region_names.go +62 −0
  • internal/park/remote.go +20 −4
  • internal/park/sweep.go +16 −7
  • internal/provision/move_relocation.go +1 −1
  • internal/provision/plan9_b8_census_test.go +12 −12
  • internal/regionname/region.go +58 −0
diff --git a/home_region_comparison_test.go b/home_region_comparison_test.go
new file mode 100644
index 000000000..799a41282
--- /dev/null
+++ b/home_region_comparison_test.go
@@ -0,0 +1,95 @@
+package main
+
+import (
+ "bytes"
+ "go/ast"
+ "go/parser"
+ "go/printer"
+ "go/token"
+ "io/fs"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// String fields cannot encode the region vocabulary in their Go types. This
+// lint catches direct comparisons at the recognizable record/engine boundary;
+// runtime witnesses cover aliases carried through local variables and calls.
+func plan13MixedRegion(x *ast.BinaryExpr) bool {
+ if x.Op != token.EQL && x.Op != token.NEQ {
+ return false
+ }
+ render := func(e ast.Expr) string {
+ var b bytes.Buffer
+ _ = printer.Fprint(&b, token.NewFileSet(), e)
+ return b.String()
+ }
+ placement := func(s string) bool {
+ return s == "rec.Region" || s == "record.Region" || s == "recRegion" || s == "mv.To" || s == "mv.From" || s == "m.To" || s == "m.From" || strings.HasSuffix(s, ".Move.To") || strings.HasSuffix(s, ".Move.From")
+ }
+ engine := func(s string) bool {
+ if strings.Contains(s, ".Intent[") && (strings.HasSuffix(s, `["to"]`) || strings.HasSuffix(s, `["region"]`)) {
+ return true
+ }
+ switch s {
+ case "actor.Region", "src.Region", "dst.Region", "b.Region", "engine.Region", "e.Region", "r.Holder.Region", "in.SourceRegion", "in.TargetRegion":
+ return true
+ }
+ return false
+ }
+ a, b := render(x.X), render(x.Y)
+ return placement(a) && engine(b) || placement(b) && engine(a)
+}
+
+func TestPlan13NoDirectPlacementEngineComparison(t *testing.T) {
+ err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ if path != "." && strings.HasPrefix(d.Name(), ".") {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
+ return nil
+ }
+ fset := token.NewFileSet()
+ f, err := parser.ParseFile(fset, path, nil, 0)
+ if err != nil {
+ return err
+ }
+ ast.Inspect(f, func(n ast.Node) bool {
+ if b, ok := n.(*ast.BinaryExpr); ok && plan13MixedRegion(b) {
+ t.Errorf("%s: placement and engine regions need the shared rule", fset.Position(b.Pos()))
+ }
+ return true
+ })
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPlan13RegionComparisonLintWitness(t *testing.T) {
+ for _, expr := range []string{`rec.Region != src.Region`, `rec.Move.To == c.Op.Intent["to"]`, `in.SourceRegion == mv.From`} {
+ e, err := parser.ParseExpr(expr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !plan13MixedRegion(e.(*ast.BinaryExpr)) {
+ t.Fatal("missed", expr)
+ }
+ }
+ for _, expr := range []string{`rec.Region != cfg.Region`, `rec.Move.To == rec.Region`, `park.PlacementRegion(rec.Region) != src.Region`, `receipt.Region != box.Region`} {
+ e, err := parser.ParseExpr(expr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if plan13MixedRegion(e.(*ast.BinaryExpr)) {
+ t.Fatal("false positive", expr)
+ }
+ }
+}
diff --git a/internal/boxchoice/boxchoice.go b/internal/boxchoice/boxchoice.go
index 7308877e7..10a7eccf9 100644
--- a/internal/boxchoice/boxchoice.go
+++ b/internal/boxchoice/boxchoice.go
@@ -10,6 +10,7 @@ package boxchoice
import (
"crypto/sha256"
"encoding/hex"
+ "github.com/carlosframework/platform/internal/regionname"
"slices"
"sort"
@@ -110,12 +111,7 @@ func Eligible(snap Snapshot, roster map[string]RosterBox, placement, awsRegion,
if !snap.Complete || expectedFP == "" || awsRegion == "" {
return nil
}
- home := func(code string) string {
- if homeName != "" && code == homeName {
- return ""
- }
- return code
- }
+ home := func(code string) string { return regionname.HomePlacementCode(homeName, code) }
var out []Candidate
for _, f := range snap.Boxes {
want := home(placement)
diff --git a/internal/console/apidbclaim.go b/internal/console/apidbclaim.go
index d9fb9e8f0..99ca385ff 100644
--- a/internal/console/apidbclaim.go
+++ b/internal/console/apidbclaim.go
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"io"
"net/http"
"os"
@@ -176,7 +177,34 @@ func (s *Server) dbClaimFailure(w http.ResponseWriter, r *http.Request, g dbRequ
}
}
+func (cfg Config) claimPlacementRegion(code string) string {
+ region := park.PlacementRegion(code)
+ if region == park.HomeRegion && park.PlacementRegion(cfg.HomeRegion) == cfg.HomeRegion && cfg.HomeRegion != "" {
+ return cfg.HomeRegion
+ }
+ return region
+}
+
+// dbClaimHomeRegion binds the old fallback only for this configured home
+// bucket. Admission contents never choose the compatibility name.
+func (cfg Config) dbClaimHomeRegion() string {
+ if cfg.HomeRegion != "" {
+ return cfg.HomeRegion
+ }
+ legacy := strings.TrimSpace(cfg.HibernateRegion)
+ if cfg.HostClaim && cfg.Region == "" && cfg.HibernateBucket != "" && legacy != "" && park.PlacementRegion(legacy) == legacy {
+ return legacy
+ }
+ return ""
+}
+
func (cfg Config) dbClaimRegion() string {
+ if cfg.HostClaim {
+ if cfg.Region == "" && cfg.HomeRegion == "" && strings.TrimSpace(cfg.HibernateRegion) == "" {
+ return ""
+ }
+ return cfg.claimPlacementRegion(cfg.Region)
+ }
for _, region := range []string{cfg.Region, cfg.HomeRegion, cfg.HibernateRegion} {
if region = strings.TrimSpace(region); region != "" {
return region
@@ -190,7 +218,7 @@ func (s *Server) listDBVersions(ctx context.Context, g dbRequest, level int) (db
out := dbdoor.ListResponse{Protocol: 2, Plan: true, Files: []dbdoor.LTXFile{}}
h := g.claim.Claim.Holder
plan, err := genselect.Select(ctx, s.cfg.DBVersions, genselect.Request{
- Bucket: s.cfg.HibernateBucket, Region: s.cfg.dbClaimRegion(), Host: g.host, Holder: *h,
+ Bucket: s.cfg.HibernateBucket, Region: s.cfg.dbClaimRegion(), Host: g.host, Holder: park.HolderRegionView(*h, s.cfg.dbClaimHomeRegion(), s.cfg.dbClaimRegion()),
})
if err != nil {
return dbdoor.ListResponse{}, errDBClaimChanged
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 3f6a0af3b..11f42b8cb 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"log"
"net/http"
"os"
@@ -258,10 +259,7 @@ func (s *Server) homeRegionIn(region string) string {
// home named nothing is the home. claimMoveRosterPlaces reads roster
// regions through it too, so the door and the API translate home one way.
func homePlacementCode(home, region string) string {
- if home != "" && region == home {
- return ""
- }
- return region
+ return park.HomePlacementCode(home, region)
}
func (s *Server) homeRegionOut(region string) string {
@@ -806,7 +804,7 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct
}
return
}
- moving, err := hostops.BeginClaimMove(ctx, s.cfg.Releases, a.Directory, h, obs, ms.ID, ms.By, target, to, ms.Now)
+ moving, err := hostops.BeginClaimMove(ctx, s.cfg.Releases, a.Directory, h, obs, ms.ID, ms.By, target, to, ms.Now, s.cfg.HomeRegion)
if err != nil {
switch {
case errors.Is(err, hostops.ErrIdentity), errors.Is(err, hostactors.ErrRoster):
@@ -1269,15 +1267,8 @@ func phase1Conflict(err error) string {
// homeRegionIn applies), so a move out of home is placed and a named region
// is compared as it is. With no home named nothing translates, and a home
// source is refused as before (home-region parks, part 1).
-func claimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to, home string) bool {
- src, sourceOK := r.Boxes[source]
- dst, targetOK := r.Boxes[target]
- if !sourceOK || !targetOK {
- return false
- }
- from, into := homePlacementCode(home, src.Region), homePlacementCode(home, dst.Region)
- return source != target && src.State == "active" && dst.State == "active" && src.Enrollment != "" && dst.Enrollment != "" &&
- src.Region != "" && dst.Region != "" && from == recRegion && into == to && from != to
+func claimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to string, home ...string) bool {
+ return hostops.ClaimMoveRosterPlaces(r, source, target, recRegion, to, home...)
}
// sourceEnrollment is the enrollment of the box that executes the claim, and
@@ -1410,7 +1401,7 @@ func moveRelocationRegion(rec *provision.Record, obs hostclaim.Observed) (string
// The door answers relocating for it.
func moveConvertedAfterCommit(rec *provision.Record, obs hostclaim.Observed) bool {
region, ok := moveRelocationRegion(rec, obs)
- return ok && rec.Move.State == provision.MoveCommitted && region == rec.Move.To
+ return ok && rec.Move.State == provision.MoveCommitted && park.PlacementMatches(obs.Claim.Op.Intent["home_region"], rec.Move.To, region)
}
// writeMoveDetail answers an operator's detail=1. The claim read and the
@@ -1486,9 +1477,13 @@ func (s *Server) writeMoveDetail(w http.ResponseWriter, r *http.Request, acct *A
// Any phase that runs on no box (starting, releasing and pinning
// today, hostclaim's validateRelocating) is waiting for one.
if region, ok := moveRelocationRegion(rec, obs); ok && obs.Claim.Op.Exec == "none" {
- live, known := s.regionHasLiveBox(ctx, region)
+ code, valid := park.EnginePlacement(obs.Claim.Op.Intent["home_region"], region)
+ live, known := false, false
+ if valid {
+ live, known = s.regionHasLiveBox(ctx, code)
+ }
if known && !live {
- out.Detail.WaitingForBox = s.homeRegionOut(region)
+ out.Detail.WaitingForBox = s.homeRegionOut(code)
}
}
}
diff --git a/internal/console/apiinstancemove_claim_test.go b/internal/console/apiinstancemove_claim_test.go
index c50af8914..0604c3427 100644
--- a/internal/console/apiinstancemove_claim_test.go
+++ b/internal/console/apiinstancemove_claim_test.go
@@ -551,15 +551,9 @@ func TestMoveDoorHostClaimRetryAfterAFailedRecordStart(t *testing.T) {
}
}
-// Home-region parks, part 1 (Paul's ruling on concern 2): the host-claim
-// door places a move out of home. The record names home "" and the roster
-// names the home box by the home's name (CONSOLE_HOME_REGION, "ie" here:
-// the roster refuses an empty region); the door compares them through
-// homeRegionIn, the one translation between the two, so the roster check
-// passes and the move registers. It then fails closed, writing nothing to
-// the claim or the record, at hostops.BeginClaimMove's own roster check,
-// which compares the record's "" with the roster's "ie" literally (claim
-// side, platform-1e, #236).
+// The door and claim transition both resolve the record's empty home placement
+// against the roster's named home. The admitted operation retains that binding
+// while its move record keeps placement codes.
func TestMoveDoorHostClaimPlacesAHomeSource(t *testing.T) {
ctx := context.Background()
fx, token := operatorInstanceFixture(t, "code-inst-move-home")
@@ -578,23 +572,23 @@ func TestMoveDoorHostClaimPlacesAHomeSource(t *testing.T) {
t.Fatalf("fixture: the record is not home's: %+v %v", rec, err)
}
armClaimMoves(t, fx, true, true, nil)
- claim := claimBytes(t, fx, "acme.go.tito.io")
code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"uk"}`)
- if msg := errorText(out); strings.HasPrefix(msg, "the host roster does not allow this move") {
- t.Fatalf("the door's roster check refused a home source: %d %s", code, out)
- }
- if code != http.StatusConflict || errorText(out) != "the instance or its boxes changed under this request. read its state and try again" {
- t.Fatalf("request = %d %s, want BeginClaimMove's fail-closed refusal", code, out)
+ if code != http.StatusAccepted {
+ t.Fatalf("request = %d %s, want an admitted home-source move", code, out)
}
a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID)
if err != nil || len(a.Value.Registrations) != 1 {
- t.Fatalf("the move did not register past the door's roster check: %+v %v", a.Value.Registrations, err)
+ t.Fatalf("the move did not register: %+v %v", a.Value.Registrations, err)
}
- if claimBytes(t, fx, "acme.go.tito.io") != claim {
- t.Fatal("the refused move wrote the claim")
+ o, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io")
+ if err != nil || o.Absent || o.Claim == nil {
+ t.Fatalf("read admitted claim: %+v %v", o, err)
+ }
+ if o.Claim.Op.Kind != hostclaim.KindMoving || o.Claim.Op.Exec != "ie-box" || o.Claim.Op.Intent["to"] != "uk" || o.Claim.Op.Intent["home_region"] != "ie" {
+ t.Fatalf("admitted operation lost its home binding: %+v", o.Claim.Op)
}
- if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Move != nil {
- t.Fatalf("the refused move journaled a move: %+v %v", rec, err)
+ if rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io"); err != nil || rec.Region != "" || rec.Move == nil || rec.Move.From != "" || rec.Move.To != "uk" || rec.Move.ID != o.Claim.Op.ID {
+ t.Fatalf("admitted move did not retain placement codes: %+v %v", rec, err)
}
}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 7a535f4a6..e29b2643c 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -1395,10 +1395,7 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
claimRequest, createErr = s.beginClaimCreate(r.Context(), acct, app, host, principal, func(now time.Time, acquisition string) (hostops.Record, error) {
rec.CreatedAt = time.Time{}
rec.Acquisition = acquisition
- region := s.cfg.dbClaimRegion()
- if rec.Region != "" {
- region = rec.Region
- }
+ region := s.cfg.claimPlacementRegion(rec.Region)
prepared, err := hostops.PrepareInstance(rec, now, region)
if err == nil {
rec.CreatedAt = now
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go
index 67f9a491a..b16346bc7 100644
--- a/internal/console/apisidecars.go
+++ b/internal/console/apisidecars.go
@@ -343,10 +343,7 @@ func (s *Server) handleAPISidecarCreate(w http.ResponseWriter, r *http.Request)
request, err := s.beginClaimCreate(r.Context(), acct, app, rec.Host, principal, func(now time.Time, acquisition string) (hostops.Record, error) {
rec.CreatedAt = time.Time{}
rec.Acquisition = acquisition
- region := s.cfg.dbClaimRegion()
- if rec.Region != "" {
- region = rec.Region
- }
+ region := s.cfg.claimPlacementRegion(rec.Region)
prepared, err := hostops.PrepareSidecar(rec, now, region)
if err == nil {
rec.CreatedAt = now
diff --git a/internal/console/claim_first_route.go b/internal/console/claim_first_route.go
index 6ac0d9a66..7dabf8794 100644
--- a/internal/console/claim_first_route.go
+++ b/internal/console/claim_first_route.go
@@ -17,7 +17,7 @@ func (s *Server) attachClaimFirstRoute(ctx context.Context, acct *Account, app,
}
request, err := s.beginClaimCreate(ctx, acct, app, host, principal, func(now time.Time, acquisition string) (hostops.Record, error) {
rec := provision.Record{Schema: 1, Host: host, App: app, Account: acct.Sqid, Channel: channel, Kind: "static", CreatedBy: principal, Acquisition: acquisition}
- record, err := hostops.PrepareInstance(rec, now, s.cfg.dbClaimRegion())
+ record, err := hostops.PrepareInstance(rec, now, s.cfg.claimPlacementRegion(rec.Region))
record.VerifiedOwnership = true
return record, err
})
diff --git a/internal/console/claim_runtime.go b/internal/console/claim_runtime.go
index f4fa9ce18..6192b81f0 100644
--- a/internal/console/claim_runtime.go
+++ b/internal/console/claim_runtime.go
@@ -69,7 +69,7 @@ func (s *Server) ConfigureClaimRuntime(ctx context.Context) error {
if err = s.store.migrateClaimAppIdentity(); err != nil {
return err
}
- a := &hostops.ProductionAuthority{Store: data, Control: control, Directory: d, Now: time.Now, ConsoleID: s.cfg.HostConsoleID, Enrollment: self.Enrollment, Namespace: self.Namespaces[0], Signer: release.RootSigner(s.cfg.Key)}
+ a := &hostops.ProductionAuthority{HomeRegion: s.cfg.dbClaimHomeRegion(), Store: data, Control: control, Directory: d, Now: time.Now, ConsoleID: s.cfg.HostConsoleID, Enrollment: self.Enrollment, Namespace: self.Namespaces[0], Signer: release.RootSigner(s.cfg.Key)}
a.Backfill = &hostops.Backfill{Store: data, Directory: d}
a.Evidence = hostactors.ExactEvidence{PublicKey: s.cfg.ReleasePub, Store: data, Boxes: d.PrivateBoxReport, Self: s.localHostReport}
c := hostops.Controller{Store: data, Control: control, Authority: a, Now: time.Now, NewID: claimOperationID}
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go
index 7ea56e4d3..0d97102cd 100644
--- a/internal/console/movesrecovery.go
+++ b/internal/console/movesrecovery.go
@@ -366,7 +366,7 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant
s.pageMove(ctx, movePageCloseOverdue, o, c, rec, now)
return nil
case moveActAbortOverdue:
- if s.moveAbortOverdue(o, c.QuietAfter[park.PlacementRegion(rec.Move.To)], now) {
+ if s.moveAbortOverdue(o, park.QuietUntil(c.QuietAfter, s.cfg.HomeRegion, park.PlacementRegion(rec.Move.To)), now) {
s.pageMove(ctx, movePageAbortOverdue, o, c, rec, now)
}
return nil
diff --git a/internal/console/plan13_amendments_test.go b/internal/console/plan13_amendments_test.go
new file mode 100644
index 000000000..2cd2cc41d
--- /dev/null
+++ b/internal/console/plan13_amendments_test.go
@@ -0,0 +1,90 @@
+package console
+
+import (
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "net/http"
+ "testing"
+ "time"
+)
+
+func TestPlan13CompleteUnnamedHomeCreate(t *testing.T) {
+ cx := newClaimCaseWith(t, func(cfg *Config) {
+ cfg.HomeRegion = ""
+ cfg.HibernateRegion = "eu-west-1"
+ cfg.HibernateBucket = "data"
+ })
+ d := cx.srv.cfg.ClaimAuthority.Directory
+ roster, err := d.Read(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for ref, b := range roster.Roster.Boxes {
+ b.Region = "eu-west-1"
+ roster.Roster.Boxes[ref] = b
+ }
+ roster.Roster.Regions["eu-west-1"] = roster.Roster.Regions["ie"]
+ delete(roster.Roster.Regions, "ie")
+ body, err := release.SignRootEnvelope(release.RootSigner(cx.priv), roster.Roster)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = cx.rel.Put(t.Context(), hostactors.Key, body, roster.ETag); err != nil {
+ t.Fatal(err)
+ }
+ manifest := titoManifest()
+ manifest.Regions = nil
+ enableInstances(t, cx.consoleFixture, "titogo", manifest)
+ cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/titogo/instances", `{"host":"acme.go.tito.io"}`)
+ cx.readyClaims(t)
+ rec, err := provision.GetRecord(t.Context(), cx.rel, "bab", "titogo", "acme.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Region != "" {
+ t.Fatal("record lost home placement", rec.Region)
+ }
+ o, err := hostclaim.Read(t.Context(), cx.rel, rec.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Absent || o.Claim.Holder == nil || o.Claim.Op.Kind != hostclaim.KindServing || o.Claim.Op.Exec == "" {
+ t.Fatalf("create incomplete: %+v", o)
+ }
+ if len(o.Claim.Touched.Regions) == 0 || o.Claim.Touched.Regions[0] != park.HomeRegion {
+ t.Fatal("lost engine region", o.Claim.Touched)
+ }
+}
+
+func TestPlan13HomeRecoveryStatus(t *testing.T) {
+ for _, region := range []string{"uk", park.HomeRegion} {
+ t.Run(region, func(t *testing.T) {
+ fx, token, url := moveDoorWithClaim(t, "plan13-home-status")
+ readMoveAnswer(t, http.MethodPost, url, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted)
+ fx.srv.cfg.HomeRegion = "uk"
+ now := time.Now().UTC()
+ setMove(t, fx, func(rec *provision.Record) {
+ rec.Region = ""
+ rec.Move.To = ""
+ rec.Move.State = provision.MoveCommitted
+ rec.Move.CutPhase = provision.CutFlipped
+ rec.Move.FlippedAt = &now
+ rec.Move.CommittedAt = &now
+ })
+ rewriteClaim(t, fx, "acme.go.tito.io", func(c *hostclaim.Claim) {
+ c.Op.Kind = hostclaim.KindRelocating
+ c.Op.Phase = "pinning"
+ c.Op.Exec = "none"
+ c.Op.Participants = nil
+ c.Op.Intent = map[string]string{"region": region, "source_box": "uk-box", "home_region": "uk"}
+ })
+ a, out := readMoveAnswer(t, http.MethodGet, url+"?detail=1", token, "", http.StatusOK)
+ if a.State != provision.MoveRelocating || waitingFor(a) != "" {
+ t.Fatalf("home has a live box: %s", out)
+ }
+ })
+ }
+}
diff --git a/internal/console/plan13_legacy_test.go b/internal/console/plan13_legacy_test.go
new file mode 100644
index 000000000..861fd4f58
--- /dev/null
+++ b/internal/console/plan13_legacy_test.go
@@ -0,0 +1,52 @@
+package console
+
+import (
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+)
+
+func TestPlan13VerifiedLegacyHomeRead(t *testing.T) {
+ for _, which := range []string{"home", "named", "no-bucket", "invalid", "explicit"} {
+ cfg := Config{HostClaim: true, HibernateRegion: "eu-west-1", HibernateBucket: "data"}
+ switch which {
+ case "named":
+ cfg.Region = "uk"
+ case "no-bucket":
+ cfg.HibernateBucket = ""
+ case "invalid":
+ cfg.HibernateRegion = "@home"
+ case "explicit":
+ cfg.HomeRegion = "ie"
+ }
+ h := hostclaim.Holder{Admissions: []hostclaim.AdmissionRecord{{Region: "eu-west-1", PredecessorRegion: "eu-west-1"}}}
+ got := park.HolderRegionView(h, cfg.dbClaimHomeRegion(), cfg.dbClaimRegion())
+ want := "eu-west-1"
+ if which == "home" {
+ want = park.HomeRegion
+ }
+ if got.Admissions[0].Region != want || h.Admissions[0].Region != "eu-west-1" {
+ t.Fatal(which, got)
+ }
+ }
+}
+
+func TestPlan13UnnamedHomeUnfinishedSelection(t *testing.T) {
+ s, g, _, m := doorPlanFixture(t, true)
+ s.cfg.HomeRegion = ""
+ s.cfg.HibernateRegion = "eu-west-1"
+ c := cloneDoorClaim(g.claim.Claim)
+ c.Holder.Generation = "next"
+ c.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "next", Predecessor: hostclaim.RootGeneration, Region: "eu-west-1", PredecessorRegion: "eu-west-1", Mode: "cold"}}
+ writeDoorClaim(t, s, g, c)
+ var err error
+ g.claim, err = hostclaim.Read(t.Context(), s.cfg.Releases, g.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ m.Put("data", dbVersionKey(c.Holder.Location, 0, 1, 1), doorLTXHeader(t, 1, 1))
+ got, err := s.listDBVersions(t.Context(), g, 0)
+ if err != nil || len(got.Files) != 1 {
+ t.Fatal(got, err)
+ }
+}
diff --git a/internal/console/plan13_region_test.go b/internal/console/plan13_region_test.go
new file mode 100644
index 000000000..0f3ad3048
--- /dev/null
+++ b/internal/console/plan13_region_test.go
@@ -0,0 +1,36 @@
+package console
+
+import (
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+)
+
+func TestPlan13ConsoleClaimRegion(t *testing.T) {
+ for _, tc := range []struct {
+ cfg Config
+ code, want string
+ }{
+ {Config{HostClaim: true, HomeRegion: "ie"}, "", "ie"},
+ {Config{HostClaim: true, HomeRegion: "ie"}, "uk", "uk"},
+ {Config{HostClaim: true}, "", park.HomeRegion},
+ {Config{HostClaim: true, HomeRegion: "ie"}, "@home", ""},
+ } {
+ if got := tc.cfg.claimPlacementRegion(tc.code); got != tc.want {
+ t.Fatalf("%+v: %q", tc, got)
+ }
+ }
+ for _, tc := range []struct {
+ cfg Config
+ want string
+ }{
+ {Config{Region: "", HomeRegion: "ie", HibernateRegion: "eu-west-1"}, "ie"},
+ {Config{HibernateRegion: "eu-west-1"}, "eu-west-1"},
+ {Config{HostClaim: true, HomeRegion: "ie", HibernateRegion: "eu-west-1"}, "ie"},
+ {Config{HostClaim: true, Region: "uk", HomeRegion: "ie"}, "uk"},
+ {Config{HostClaim: true, HibernateRegion: "eu-west-1"}, park.HomeRegion},
+ } {
+ if got := tc.cfg.dbClaimRegion(); got != tc.want {
+ t.Fatalf("%+v: %q", tc, got)
+ }
+ }
+}
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt
index 3ea4c1055..cf7a27c10 100644
--- a/internal/console/testdata/claim-authority-sites.txt
+++ b/internal/console/testdata/claim-authority-sites.txt
@@ -13,7 +13,7 @@
../hostops/move_enrollment.go|RegisterMove|a.Control.RegisterStart(ctx, h.Account, h.App, appID, id, reg, a.Now())
../hostops/move_enrollment.go|StartClaimMoveRecord|h.captureMoveStartBase(ctx, seq)
../hostops/move_fence_release.go|ReleaseFencedTarget|hostclaim.ReleaseFencedTarget(r)
-../hostops/move_fence_release.go|ReleaseFencedTarget|hostclaim.VerifyMoveTargetFence(ctx, g.w.raw, o, ref, g.authority)
+../hostops/move_fence_release.go|ReleaseFencedTarget|hostclaim.VerifyMoveTargetFence(ctx, g.w.raw, o, ref, MoveFenceRegions{Authority: g.authority, HomeRegion: o.Claim.Op.Intent["home_region"]})
../hostops/move_loss_authority.go|DeclareMoveLoss|a.Control.DeclareMoveLoss(ctx, a.Signer, a.Directory.Bucket, actor, account, app, host, box, in)
../hostops/move_loss_authority.go|InspectMoveLoss|a.Control.InspectMoveLoss(ctx, a.Signer, a.Directory.Bucket, actor, account, app, host, box)
../hostops/move_recovery_bridge.go|RecoverTerminatedMovingHost|hostclaim.ConvertMovingToRelocating(mh.Lifecycle(ctx), s, fresh, c.Op.ID, ref, authority, guard)
diff --git a/internal/host/claimassembly.go b/internal/host/claimassembly.go
index 555a1449f..9c51e3d7f 100644
--- a/internal/host/claimassembly.go
+++ b/internal/host/claimassembly.go
@@ -156,12 +156,13 @@ func (a ClaimAssembly) Build(ctx context.Context) (ClaimProviders, error) {
}
a.Guard.Claims = hostguard.StoreReader{Store: a.Claims}
// Continue is the moves C1 session table, nil unless both flags are on (D1).
- source := hostbox.WorkSource{Guard: a.Guard, Box: actor.Box, Region: actor.Region, Now: time.Now, Policy: a.Policy.Check, Local: a.Identity.Local(a.Local), Continue: a.Wake.Continue}
+ source := hostbox.WorkSource{HomeRegion: actor.HomeRegion, Guard: a.Guard, Box: actor.Box, Region: park.PlacementRegion(actor.Region), Now: time.Now, Policy: a.Policy.Check, Local: a.Identity.Local(a.Local), Continue: a.Wake.Continue}
a.Reconcile.Reconciler.Claims = a.Claims
a.Reconcile.Reconciler.Guard = a.Guard
a.Reconcile.Reconciler.Writes = hostwrite.Config{Enabled: true, Selector: source}
a.Reconcile.Reconciler.Box = actor.Box
- a.Reconcile.Reconciler.Region = actor.Region
+ a.Reconcile.Reconciler.Region = park.PlacementRegion(actor.Region)
+ a.Reconcile.Reconciler.HomeRegion = actor.HomeRegion
a.Reconcile.Reconciler.Now = time.Now
a.Reconcile.Reconciler.Local = park.Local{Guard: a.Guard, Claims: a.Claims, DataDir: a.Guard.DataDir, Box: actor.Box}
a.Reconcile.Box = actor.Box
@@ -177,7 +178,7 @@ func (a ClaimAssembly) Build(ctx context.Context) (ClaimProviders, error) {
a.Provision = a.Reconcile.Configure
check := claimBoundIdentity(a.Identity.Verify, actor)
data := hostbox.RequestStore{Store: a.Data, Check: check}
- writer := hostbox.Writer{Loss: a.LossAdmission, Claims: a.Claims, Data: data, Bucket: actor.Bucket, Region: actor.Region, Box: actor.Box, AdmissionEvidence: a.AdmissionEvidence, PublicationEvidence: a.PublicationEvidence, NewID: a.NewID}
+ writer := hostbox.Writer{HomeRegion: actor.HomeRegion, Loss: a.LossAdmission, Claims: a.Claims, Data: data, Bucket: actor.Bucket, Region: park.PlacementRegion(actor.Region), Box: actor.Box, AdmissionEvidence: a.AdmissionEvidence, PublicationEvidence: a.PublicationEvidence, NewID: a.NewID}
a.bindClaimWakeStop()
if local, ok := a.Local.(*productionClaimLocal); ok {
if err := local.bind(ctx, &a); err != nil {
@@ -227,7 +228,7 @@ func (a ClaimAssembly) Build(ctx context.Context) (ClaimProviders, error) {
}
return a.View(ctx, o)
}
- engine := park.Engine{Store: data, Claims: a.Claims, Bucket: actor.Bucket, Region: actor.Region, Leases: hostbox.AggregateLeases{Leases: hostbox.RenewingLeases{Region: actor.Cloud.Region}, Resolve: leases}, Now: time.Now, NewID: a.NewID, View: view, Authorize: authorize}
+ engine := park.Engine{HomeRegion: actor.HomeRegion, Store: data, Claims: a.Claims, Bucket: actor.Bucket, Region: park.PlacementRegion(actor.Region), Leases: hostbox.AggregateLeases{Leases: hostbox.RenewingLeases{Region: actor.Cloud.Region}, Resolve: leases}, Now: time.Now, NewID: a.NewID, View: view, Authorize: authorize}
engine.VerifyMovesSweep = func(ctx context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (park.MoveSweepProof, error) {
if err := check(ctx); err != nil {
return park.MoveSweepProof{}, err
@@ -267,7 +268,7 @@ func (e claimRegionalEnrollment) VerifyRegional(ctx context.Context, region, buc
if err != nil {
return err
}
- if a != e.actor || a.Region != region || a.Bucket != bucket {
+ if a != e.actor || !park.SameRegion(a.HomeRegion, park.PlacementRegion(a.Region), region) || a.Bucket != bucket {
return hostbox.ErrChanged
}
return nil
diff --git a/internal/host/claimcleanup_identity.go b/internal/host/claimcleanup_identity.go
index b87b8d456..750032d1d 100644
--- a/internal/host/claimcleanup_identity.go
+++ b/internal/host/claimcleanup_identity.go
@@ -40,6 +40,7 @@ func verifyClaimCleanupEnrollment(ctx context.Context, identity hostbox.Identity
if _, err := identity.Verify(ctx); err != nil {
return nil, err
}
+ actor.HomeRegion = identity.HomeRegion
actor.Eligible, actor.Quarantined = false, true
return &claimCleanupEnrollment{actor: actor, directory: directory}, nil
}
diff --git a/internal/host/claimconstruction.go b/internal/host/claimconstruction.go
index eee40d899..3a7330acc 100644
--- a/internal/host/claimconstruction.go
+++ b/internal/host/claimconstruction.go
@@ -258,7 +258,7 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla
var publisher ClaimFencedPublication
var groupD claimGroupD
if movesC1 {
- serving = &claimServingOwners{Data: hostbox.RequestStore{Store: owners.Data, Check: func(ctx context.Context) error { _, err := owners.Identity.Verify(ctx); return err }}, Bucket: actor.Bucket, Region: actor.Region, Box: actor.Box, NewID: newID, Now: time.Now, Sources: owners.Serving}
+ serving = &claimServingOwners{HomeRegion: actor.HomeRegion, Data: hostbox.RequestStore{Store: owners.Data, Check: func(ctx context.Context) error { _, err := owners.Identity.Verify(ctx); return err }}, Bucket: actor.Bucket, Region: park.PlacementRegion(actor.Region), Box: actor.Box, NewID: newID, Now: time.Now, Sources: owners.Serving}
groupD, err = claimGroupDWiring(claimEntryEnv(ctx), serving.owners(), owners.Moves, owners.Relocation)
if err != nil {
return nil, err
diff --git a/internal/host/claimhandover_recovery.go b/internal/host/claimhandover_recovery.go
index 37655558b..70bd9c624 100644
--- a/internal/host/claimhandover_recovery.go
+++ b/internal/host/claimhandover_recovery.go
@@ -12,6 +12,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/registry"
"strconv"
)
@@ -72,7 +73,7 @@ func (h claimCrossBoxHandover) recoveryEvidence(ctx context.Context, o hostclaim
if err != nil {
return err
}
- if verified.Box() != p.Box || verified.Region() != op.Intent["to"] {
+ if verified.Box() != p.Box || !park.SameRegion(op.Intent["home_region"], verified.Region(), op.Intent["to"]) {
return hostclaim.ErrFence
}
return nil
diff --git a/internal/host/claimlaunch_authority.go b/internal/host/claimlaunch_authority.go
index a6f9fd96a..7e7bb5e3d 100644
--- a/internal/host/claimlaunch_authority.go
+++ b/internal/host/claimlaunch_authority.go
@@ -3,6 +3,7 @@ package host
import (
"context"
"errors"
+ "github.com/carlosframework/platform/internal/park"
"os"
"path/filepath"
"strconv"
@@ -135,7 +136,7 @@ func (h *ClaimLaunchRoot) authorize(ctx context.Context, peer StopPeer, q hostbo
if c.Op.Kind != hostclaim.KindServing && boot >= state.SafeUntil {
return execlease.ErrLost
}
- if until, ok := c.QuietAfter[t.Region]; ok && !now.After(until) {
+ if until := park.QuietUntil(c.QuietAfter, actor.HomeRegion, t.Region); !until.IsZero() && !now.After(until) {
return hostbox.ErrChanged
}
return nil
diff --git a/internal/host/claimmovefence.go b/internal/host/claimmovefence.go
index 6a2014065..533632db8 100644
--- a/internal/host/claimmovefence.go
+++ b/internal/host/claimmovefence.go
@@ -36,6 +36,6 @@ func claimBoxFenceVerifier(check func(context.Context) error, store release.Stor
if json.Unmarshal([]byte(o.Claim.Op.Journal["fence."+o.Claim.Op.Intent["target"]]), &ref) != nil {
return hostclaim.ReturnFence{}, hostclaim.ErrFence
}
- return hostclaim.VerifyMoveTargetFence(ctx, store, o, ref, hostfence.ClaimFenceAuthority{Directory: authority.Directory, Now: time.Now})
+ return hostclaim.VerifyMoveTargetFence(ctx, store, o, ref, hostops.MoveFenceRegions{Authority: hostfence.ClaimFenceAuthority{Directory: authority.Directory, Now: time.Now}, HomeRegion: o.Claim.Op.Intent["home_region"]})
}
}
diff --git a/internal/host/claimowners.go b/internal/host/claimowners.go
index 8b335f3c4..5bfcb2204 100644
--- a/internal/host/claimowners.go
+++ b/internal/host/claimowners.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
+ "github.com/carlosframework/platform/internal/park"
"path/filepath"
"reflect"
"strconv"
@@ -306,6 +307,7 @@ func (d ClaimOwnerDispatch) PublicationEvidence(ctx context.Context, o hostclaim
// acknowledged (INV-11), and publication evidence binds the exact marker
// version this process wrote and the exact root version it named.
type claimServingOwners struct {
+ HomeRegion string
Data genselect.Reader
Bucket, Region, Box string
NewID func() string
@@ -330,7 +332,7 @@ func (s *claimServingOwners) serving(o hostclaim.Observed) error {
}
func (s *claimServingOwners) baseline(ctx context.Context, o hostclaim.Observed) (*genselect.Plan, error) {
- return genselect.Select(ctx, s.Data, genselect.Request{Bucket: s.Bucket, Region: s.Region, Host: o.Host, Holder: *o.Claim.Holder})
+ return genselect.Select(ctx, s.Data, genselect.Request{Bucket: s.Bucket, Region: s.Region, Host: o.Host, Holder: park.HolderRegionView(*o.Claim.Holder, s.HomeRegion, s.Region)})
}
// claimEmptyBootstrap: a created incarnation before its first writer.
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go
index 8a2deeb1c..cdfd3ff8a 100644
--- a/internal/host/claimproduction.go
+++ b/internal/host/claimproduction.go
@@ -118,7 +118,11 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r
return out, hostwrite.ErrUnavailable
}
authority := productionClaimRoster{Directory: directory, DataBucket: dataBucket, FloorDir: filepath.Join(r.OptDir, "system", "guard")}
- out.Identity = hostbox.IdentityProvider{AWSRegion: row.Cloud.Region, Account: row.Cloud.Account, Box: row.BoxID, Region: row.Region, Bucket: dataBucket, Namespace: row.Namespace, Certs: certs, Roster: authority, Evidence: productionBoxEvidence{store}}
+ _, homeRegion, err := claimRegionBinding(row.Region, os.Getenv("CARLOS_REGION"))
+ if err != nil {
+ return out, err
+ }
+ out.Identity = hostbox.IdentityProvider{HomeRegion: homeRegion, AWSRegion: row.Cloud.Region, Account: row.Cloud.Account, Box: row.BoxID, Region: row.Region, Bucket: dataBucket, Namespace: row.Namespace, Certs: certs, Roster: authority, Evidence: productionBoxEvidence{store}}
var actor hostbox.Actor
if r.Mode == "instance-control" {
out.cleanupEnrollment, err = verifyClaimCleanupEnrollment(ctx, out.Identity, directory)
@@ -355,13 +359,22 @@ func (p productionClaimRegional) View(ctx context.Context, o hostclaim.Observed)
if p.Parks == nil || p.Parks.View == nil {
return park.View{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 exact-version generation and files-manifest view"}}
}
- if _, err := p.Identity.Verify(ctx); err != nil {
+ actor, err := p.Identity.Verify(ctx)
+ if err != nil {
+ return park.View{}, err
+ }
+ if o.Claim == nil {
+ return park.View{}, park.ErrUnsafe
+ }
+ home, err := park.BoundHome(actor.HomeRegion, o.Claim.Op.Intent["home_region"])
+ if err != nil {
return park.View{}, err
}
- v, err := p.Parks.View(ctx, o)
+ v, err := p.Parks.View(ctx, park.HomeView(o, home))
if err != nil {
return park.View{}, err
}
+ v.HomeRegion = home
v.ArchivedIncarnations = claimArchivedLocations(o.Claim)
v.RefusedLocations = map[string]error{}
if _, err := claimLegacyIncarnation(o.Claim); err != nil {
@@ -393,7 +406,7 @@ func (p productionClaimRegional) Authorize(ctx context.Context, o hostclaim.Obse
if err != nil {
return err
}
- if o.Claim == nil || ob.Op != o.Claim.Op.ID || (ob.Owner != "box:"+a.Box && ob.Owner != "region:"+a.Region) || (ob.Box != "" && ob.Box != a.Box) || (ob.Region != "" && ob.Region != a.Region) {
+ if o.Claim == nil || ob.Op != o.Claim.Op.ID || (ob.Owner != "box:"+a.Box && (ob.Owner != "region:"+ob.Region || !park.SameRegion(a.HomeRegion, ob.Region, a.Region))) || (ob.Box != "" && ob.Box != a.Box) || (ob.Region != "" && !park.SameRegion(a.HomeRegion, ob.Region, a.Region)) {
return hostbox.ErrChanged
}
found := false
@@ -450,7 +463,7 @@ func (t claimAdminTokens) With(ctx context.Context, host string, effect func() e
if c.Op.Kind != sel.Kind || c.Op.ID != sel.Op || c.Op.Phase != sel.Phase || c.Holder.Incarnation != sel.Captured.Incarnation || c.Holder.Stamp != sel.Captured.Stamp || c.Holder.Session != sel.Session || c.Holder.Generation != sel.Generation {
return hostbox.ErrChanged
}
- if until, ok := c.QuietAfter[sel.Region]; ok && !t.Now().After(until) {
+ if until := park.QuietUntil(c.QuietAfter, actor.HomeRegion, sel.Region); !until.IsZero() && !t.Now().After(until) {
return hostbox.ErrChanged
}
if err := t.Local(ctx, c, sel); err != nil {
diff --git a/internal/host/claimrecoveryreceipt.go b/internal/host/claimrecoveryreceipt.go
index d9762f92e..b9d9c26d5 100644
--- a/internal/host/claimrecoveryreceipt.go
+++ b/internal/host/claimrecoveryreceipt.go
@@ -9,6 +9,7 @@ import (
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostevidence"
+ "github.com/carlosframework/platform/internal/park"
"strconv"
)
@@ -34,7 +35,7 @@ func (h claimCrossBoxHandover) recoveryStopReceipt(ctx context.Context, o hostcl
stamp, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalTargetStamp], 10, 64)
if err != nil || stamp == 0 || r.V != 1 || r.Kind != "stopped" || r.Host != o.Host || r.Box != p.Box || r.OperationID != c.Op.ID ||
r.Incarnation != c.Holder.Incarnation || r.WriterSession != p.Session || r.InstallationStamp != stamp ||
- r.Region != c.Op.Intent["to"] || r.Enrollment != c.Op.Intent["target_enrollment"] || r.Phase != hostclaim.PhaseReleasing ||
+ !park.SameRegion(c.Op.Intent["home_region"], r.Region, c.Op.Intent["to"]) || r.Enrollment != c.Op.Intent["target_enrollment"] || r.Phase != hostclaim.PhaseReleasing ||
key != stopEvidenceKey(o.Host, c.Op.ID, p.Box, r.ID) || r.FenceID == "" || r.ObligationID == "" || r.ClaimGen == 0 ||
r.EvidenceAt.IsZero() || r.RequestLimitNS != int64(hostbox.RequestLimit) || len(r.CgroupIdentities) == 0 ||
!stopDigest(r.InventorySHA256) || !stopDigest(r.LocalJournalSHA256) || !stopDigest(r.ProducerDrainDigest) {
@@ -45,7 +46,7 @@ func (h claimCrossBoxHandover) recoveryStopReceipt(ctx context.Context, o hostcl
return err
}
box, ok := roster.Roster.Boxes[p.Box]
- if !ok || box.Enrollment != r.Enrollment || box.Region != r.Region {
+ if !ok || box.Enrollment != r.Enrollment || !park.SameRegion("", box.Region, r.Region) {
return ErrClaimStopIdentity
}
sig, _, err := h.Evidence.Get(ctx, key+".sig")
diff --git a/internal/host/claimregion.go b/internal/host/claimregion.go
new file mode 100644
index 000000000..7fb2e8a41
--- /dev/null
+++ b/internal/host/claimregion.go
@@ -0,0 +1,20 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/park"
+)
+
+// The signed roster retains the claim's engine name. CARLOS_REGION supplies
+// this box's placement code; only its empty home placement binds an alias.
+func claimRegionBinding(roster, code string) (engine, home string, err error) {
+ engine = park.PlacementRegion(roster)
+ placement := park.PlacementRegion(code)
+ if roster == "" || engine == "" || placement == "" || (code != "" && engine != placement) {
+ return "", "", hostbox.ErrChanged
+ }
+ if code == "" {
+ home = engine
+ }
+ return engine, home, nil
+}
diff --git a/internal/host/movesfinal.go b/internal/host/movesfinal.go
index 0689533fe..151ff86f9 100644
--- a/internal/host/movesfinal.go
+++ b/internal/host/movesfinal.go
@@ -387,12 +387,12 @@ func (f *sourceFinal) engine(vs lease.VersionedStore) park.Engine {
if err := f.sourceOf(obs); err != nil {
return err
}
- if o.Op != f.g.ID || o.Region != cfg.parkRegion() {
+ if o.Op != f.g.ID || !park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) {
return fmt.Errorf("%w: not this finalization's chore", park.ErrUnsafe)
}
return nil
}
- return park.Engine{Store: vs, Claims: f.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases,
+ return park.Engine{HomeRegion: cfg.ParkHomeRegion, Store: vs, Claims: f.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases,
Now: f.now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, own), After: cfg.parkFault}
}
@@ -406,7 +406,7 @@ func (f *sourceFinal) remote(ctx context.Context, obs hostclaim.Observed) (bool,
var chore *hostclaim.Obligation
for i := range obs.Claim.Obligations {
o := &obs.Claim.Obligations[i]
- if o.Op == id && o.Kind == "park-replica" && o.Region == cfg.parkRegion() && o.Park != nil && !isStagingLocation(o.Park.Location) {
+ if o.Op == id && o.Kind == "park-replica" && park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) && o.Park != nil && !isStagingLocation(o.Park.Location) {
chore = o
}
}
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go
index e9259a026..5667d06e9 100644
--- a/internal/host/movesinstall.go
+++ b/internal/host/movesinstall.go
@@ -488,7 +488,7 @@ func (ir *installRun) parking(ctx context.Context) (bool, error) {
eng := ir.engine(vs)
pending := 0
for _, o := range obs.Claim.Obligations {
- if o.Op != id || o.Kind != "park-generation" || o.Region != cfg.parkRegion() {
+ if o.Op != id || o.Kind != "park-generation" || !park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) {
continue
}
w := park.Work{Host: host, ID: o.ID, Op: id}
diff --git a/internal/host/moveslandpark.go b/internal/host/moveslandpark.go
index 0c6890a52..b7fbb08ec 100644
--- a/internal/host/moveslandpark.go
+++ b/internal/host/moveslandpark.go
@@ -135,7 +135,7 @@ func isHexString(s string) bool {
// check (the arming requires all three).
func (ir *installRun) engine(vs lease.VersionedStore) park.Engine {
cfg := ir.cfg()
- return park.Engine{Store: vs, Claims: ir.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases,
+ return park.Engine{HomeRegion: cfg.ParkHomeRegion, Store: vs, Claims: ir.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases,
Now: cfg.Now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, ir.authorizeObligation), After: cfg.parkFault}
}
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index 0cb58ef87..a29c99f8c 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -623,11 +623,12 @@ type moveCfg struct {
// registry's; RemoveVerifyEntry is
// dbverify.RemoveHost; WriteGen is hostbox.WriteGenerationMarker, the
// one <dbPath>.gen writer (ruling G2).
- Guard hostguard.Guard
- Local park.Local
- ParkLeases park.Leases
- ParkView func(context.Context, hostclaim.Observed) (park.View, error)
- ParkAuthorize func(context.Context, hostclaim.Observed, hostclaim.Obligation) error
+ Guard hostguard.Guard
+ Local park.Local
+ ParkHomeRegion string
+ ParkLeases park.Leases
+ ParkView func(context.Context, hostclaim.Observed) (park.View, error)
+ ParkAuthorize func(context.Context, hostclaim.Observed, hostclaim.Obligation) error
// ParkRunHeld and ParkLocalAuthorize are claim Plan 12's pieces of the
// MoveParks capability (movesClaimPlan12, movessources.go): RunHeld runs
// a chore under a lease the caller holds (M5: the landing's residue park
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go
index 32d6ad17f..192deae17 100644
--- a/internal/host/movesphase1.go
+++ b/internal/host/movesphase1.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"io"
"log"
"os"
@@ -318,12 +319,12 @@ func (w *movesWorker) sourceRound(ctx context.Context, s release.Store, rec prov
// K12 and T9: the target region and box are in touched before the
// first staging write.
targetBox := obs.Claim.Op.Intent["target"]
- if !containsString(obs.Claim.Touched.Regions, rec.Move.To) || (targetBox != "" && !containsString(obs.Claim.Touched.Boxes, targetBox)) {
+ if !containsString(obs.Claim.Touched.Regions, park.PlacementRegion(rec.Move.To)) || (targetBox != "" && !containsString(obs.Claim.Touched.Boxes, targetBox)) {
var boxes []string
if targetBox != "" {
boxes = []string{targetBox}
}
- if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Touch(id, []string{rec.Move.To}, boxes)); err != nil {
+ if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Touch(id, []string{park.PlacementRegion(rec.Move.To)}, boxes)); err != nil {
return nil, nil, fmt.Errorf("touch %s in the host claim: %w", rec.Move.To, err)
}
}
diff --git a/internal/host/movespolicy.go b/internal/host/movespolicy.go
index de2b1fc22..e60ba6afd 100644
--- a/internal/host/movespolicy.go
+++ b/internal/host/movespolicy.go
@@ -297,7 +297,7 @@ func MovesClaimPolicyWithInputs(read MovesRecordReader, committed func(context.C
if len(c.Op.Participants) != 2 || c.Op.Participants[1] != s.Box || c.Op.Intent["target"] != s.Box || c.Op.Intent["source"] == s.Box {
return refuse("this box is not the move's target", nil)
}
- if c.Op.Intent["to"] != s.Region {
+ if !park.SameRegion(c.Op.Intent["home_region"], c.Op.Intent["to"], s.Region) {
return refuse("this region is not the move's target region", nil)
}
for _, lost := range c.Lost {
@@ -416,7 +416,7 @@ func movesRecoveryPolicy(ctx context.Context, read MovesRecordReader, c *hostcla
if len(c.Op.Participants) == 0 || c.Op.Participants[0] != s.Box || c.Op.Intent["source"] != s.Box || c.Op.Intent["target"] == s.Box {
return refuse("this box is not the move's source", nil)
}
- if c.Op.Intent["to"] == s.Region {
+ if park.SameRegion(c.Op.Intent["home_region"], c.Op.Intent["to"], s.Region) {
return refuse("this region is the move's target region", nil)
}
for _, lost := range c.Lost {
@@ -437,7 +437,7 @@ func movesRecoveryPolicy(ctx context.Context, read MovesRecordReader, c *hostcla
if rec == nil || rec.Host != c.Host || rec.Move == nil || rec.Move.ID != s.Op || rec.Move.Incarnation != c.Holder.Incarnation {
return refuse("the record is not this move", nil)
}
- if park.PlacementRegion(rec.Move.From) != s.Region {
+ if !park.PlacementMatches(c.Op.Intent["home_region"], rec.Move.From, s.Region) {
return refuse("this region is not the move's source region", nil)
}
var (
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go
index c147a9eb2..ac9f2a678 100644
--- a/internal/host/movesprobeowners.go
+++ b/internal/host/movesprobeowners.go
@@ -181,6 +181,7 @@ func (m *movesProbeOwners) valid() bool {
// probeLanding is what one landing probe's checks read.
type probeLanding struct {
+ homeRegion string
// committed is a landing wake after the commit (Plan 3 Task 7): the
// claim at landing/committed, the record committed.
committed bool
@@ -202,6 +203,7 @@ func (m *movesProbeOwners) landing(ctx context.Context, o hostclaim.Observed) (p
return v, hostbox.ErrUnsupported
}
c := o.Claim
+ v.homeRegion = c.Op.Intent["home_region"]
switch c.Op.Phase {
case hostclaim.PhaseLandingProbing:
case hostclaim.PhaseLandingCommitted:
@@ -210,7 +212,7 @@ func (m *movesProbeOwners) landing(ctx context.Context, o hostclaim.Observed) (p
return v, hostbox.ErrChanged
}
if c.Host != o.Host || c.Op.ID == "" || c.Op.Exec != m.Box || c.Op.Intent["target"] != m.Box ||
- c.Op.Intent["source"] == m.Box || c.Op.Intent["to"] != m.Region || len(c.Op.Participants) != 2 || c.Op.Participants[1] != m.Box {
+ c.Op.Intent["source"] == m.Box || !park.SameRegion(c.Op.Intent["home_region"], c.Op.Intent["to"], m.Region) || len(c.Op.Participants) != 2 || c.Op.Participants[1] != m.Box {
return v, hostbox.ErrChanged
}
for _, lost := range c.Lost {
@@ -227,7 +229,7 @@ func (m *movesProbeOwners) landing(ctx context.Context, o hostclaim.Observed) (p
return v, hostbox.ErrChanged
}
mv := rec.Move
- if mv.ID != v.op || mv.Incarnation != v.holder.Incarnation || mv.To != m.Region || mv.Inputs == nil || mv.Inputs.Release == "" {
+ if park.PlacementRegion(mv.From) == "" || mv.ID != v.op || mv.Incarnation != v.holder.Incarnation || !park.PlacementMatches(c.Op.Intent["home_region"], mv.To, m.Region) || mv.Inputs == nil || mv.Inputs.Release == "" {
return v, hostbox.ErrChanged
}
if v.committed {
@@ -403,8 +405,8 @@ func predecessorRegion(h *hostclaim.Holder, from string) string {
return from
}
-func (m *movesProbeOwners) request(host string, h *hostclaim.Holder) genselect.Request {
- return genselect.Request{Bucket: m.Bucket, Region: m.Region, Host: host, Holder: *h}
+func (m *movesProbeOwners) request(host string, h *hostclaim.Holder, home string) genselect.Request {
+ return genselect.Request{Bucket: m.Bucket, Region: m.Region, Host: host, Holder: park.HolderRegionView(*h, home, m.Region)}
}
func preparedPin(v probeLanding) genselect.PreparedPin {
@@ -442,7 +444,7 @@ func (m *movesProbeOwners) currentMarker(ctx context.Context, h *hostclaim.Holde
// certified remote once it is.
func (m *movesProbeOwners) baseline(ctx context.Context, host string, v probeLanding, prepared bool) (*genselect.Plan, error) {
if prepared {
- return genselect.SelectPrepared(ctx, m.Data, m.request(host, v.holder), preparedPin(v))
+ return genselect.SelectPrepared(ctx, m.Data, m.request(host, v.holder, v.homeRegion), preparedPin(v))
}
digest, err := manifestDigest(v.manifest)
if err != nil {
@@ -453,9 +455,9 @@ func (m *movesProbeOwners) baseline(ctx context.Context, host string, v probeLan
return nil, err
}
if !m.landingMarker(marker, host, v, digest) {
- return genselect.SelectPrepared(ctx, m.Data, m.request(host, v.holder), preparedPin(v))
+ return genselect.SelectPrepared(ctx, m.Data, m.request(host, v.holder, v.homeRegion), preparedPin(v))
}
- return genselect.Select(ctx, m.Data, m.request(host, v.holder))
+ return genselect.Select(ctx, m.Data, m.request(host, v.holder, v.homeRegion))
}
// localMarkers reads .files-gen (nil when absent) and <dbPath>.gen ("" when
@@ -986,7 +988,7 @@ func (m *movesProbeOwners) PublishFenced(ctx context.Context, o hostclaim.Observ
}
bounded, cancel := context.WithTimeout(ctx, hostbox.RequestLimit)
defer cancel()
- plan, err := genselect.Select(bounded, m.Data, m.request(o.Host, h))
+ plan, err := genselect.Select(bounded, m.Data, m.request(o.Host, h, o.Claim.Op.Intent["home_region"]))
if err != nil {
return ClaimPublicationReceipt{}, err
}
@@ -1213,7 +1215,7 @@ func (m *movesProbeOwners) PublicationEvidence(ctx context.Context, o hostclaim.
if !ok || err != nil || receipt.MarkerVersion == "" || receipt.Root.VersionID == "" || !strings.HasPrefix(receipt.Root.Key, prefix) {
return hostbox.ErrChanged
}
- plan, err := genselect.Select(ctx, m.Data, m.request(o.Host, h))
+ plan, err := genselect.Select(ctx, m.Data, m.request(o.Host, h, o.Claim.Op.Intent["home_region"]))
if err != nil {
return err
}
@@ -1302,7 +1304,7 @@ func (m *movesProbeOwners) selectCommitted(ctx context.Context, rt registry.Rout
if gen == v.op || !claimFreshGeneration(h, gen) {
return ClaimLaunchInputs{}, hostwrite.ErrUnavailable
}
- plan, err := genselect.Select(ctx, m.Data, m.request(o.Host, h))
+ plan, err := genselect.Select(ctx, m.Data, m.request(o.Host, h, o.Claim.Op.Intent["home_region"]))
if err != nil {
return ClaimLaunchInputs{}, err
}
@@ -1333,7 +1335,7 @@ func (m *movesProbeOwners) committedEvidence(ctx context.Context, o hostclaim.Ob
if !reflect.DeepEqual(in, m.committedAdmission(o, v, in.NewGeneration, baseline)) {
return hostbox.ErrChanged
}
- fresh, err := genselect.Select(ctx, m.Data, m.request(o.Host, h))
+ fresh, err := genselect.Select(ctx, m.Data, m.request(o.Host, h, o.Claim.Op.Intent["home_region"]))
if err != nil {
return err
}
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go
index b38e8a202..abf3362ea 100644
--- a/internal/host/movesreport.go
+++ b/internal/host/movesreport.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"io"
"io/fs"
"log"
@@ -245,7 +246,7 @@ func (w *movesWorker) targetReport(ctx context.Context, s release.Store, rec pro
rep.Checks[moves.CheckOneOwner] = checkOneOwner(fleet, rec.Move.To)
rep.Checks[moves.CheckClaim] = w.checkClaim(ctx, obs, rec)
rep.Checks[moves.CheckLane] = w.checkLane(ctx, rec, fleet)
- rep.Checks[moves.CheckQuiet] = checkQuiet(obs, rec.Move.To, now)
+ rep.Checks[moves.CheckQuiet] = checkQuiet(obs, park.PlacementRegion(rec.Move.To), now)
rep.Checks[moves.CheckArmed] = checkArmed(cfg)
// T11 from measured rates: this box's own, and the source's cross rate.
@@ -771,7 +772,7 @@ func (w *movesWorker) checkLane(ctx context.Context, rec provision.Record, fleet
// checkQuiet is T12: the claim's quiet_after for the target region passed.
func checkQuiet(obs hostclaim.Observed, region string, now time.Time) moves.Check {
- if until, ok := obs.Claim.QuietAfter[region]; ok && now.Before(until) {
+ if until := park.QuietUntil(obs.Claim.QuietAfter, obs.Claim.Op.Intent["home_region"], region); !until.IsZero() && now.Before(until) {
return moves.Check{Reason: t12NotQuiet(region, until)}
}
return moves.Check{OK: true}
diff --git a/internal/host/movesrevert.go b/internal/host/movesrevert.go
index 91ac4b200..cf5d0a42e 100644
--- a/internal/host/movesrevert.go
+++ b/internal/host/movesrevert.go
@@ -901,7 +901,7 @@ func (w *movesWorker) laneParkTarget(ctx context.Context, s release.Store, rec p
lane := moves.ScopeLocation(host, id)
var chores []hostclaim.Obligation
for _, o := range obs.Claim.Obligations {
- if o.Op == id && o.Kind == "park-replica" && o.Region == cfg.parkRegion() && o.Park != nil && o.Park.Location == lane {
+ if o.Op == id && o.Kind == "park-replica" && park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) && o.Park != nil && o.Park.Location == lane {
chores = append(chores, o)
}
}
@@ -919,12 +919,12 @@ func (w *movesWorker) laneParkTarget(ctx context.Context, s release.Store, rec p
own := func(_ context.Context, obs hostclaim.Observed, o hostclaim.Obligation) error {
c := obs.Claim
if obs.Absent || c == nil || !inOp(c.Op) || (!committed && c.Op.Exec == cfg.Box) ||
- o.Op != id || o.Kind != "park-replica" || o.Region != cfg.parkRegion() || o.Park == nil || o.Park.Location != lane {
+ o.Op != id || o.Kind != "park-replica" || !park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) || o.Park == nil || o.Park.Location != lane {
return fmt.Errorf("%w: not this move's lane park", park.ErrUnsafe)
}
return nil
}
- e := park.Engine{Store: vs, Claims: s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases,
+ e := park.Engine{HomeRegion: cfg.ParkHomeRegion, Store: vs, Claims: s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases,
Now: w.now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, own), After: cfg.parkFault}
for _, o := range chores {
err := e.Run(ctx, park.Work{Host: host, ID: o.ID, Op: id})
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go
index 2093e8405..efba050d9 100644
--- a/internal/host/movesrollback.go
+++ b/internal/host/movesrollback.go
@@ -1121,7 +1121,7 @@ func (r *rollbackRun) remote(ctx context.Context, obs hostclaim.Observed, j *mov
e := r.engine(vs)
waiting := 0
for _, o := range obs.Claim.Obligations {
- if o.Op != id || o.Kind != "park-replica" || o.Region != cfg.parkRegion() || o.Park == nil {
+ if o.Op != id || o.Kind != "park-replica" || !park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region) || o.Park == nil {
continue
}
work := park.Work{Host: host, ID: o.ID, Op: id}
@@ -1158,7 +1158,7 @@ func (r *rollbackRun) engine(vs lease.VersionedStore) park.Engine {
}
return r.authorizeChore(obs.Claim, o)
}
- return park.Engine{Store: vs, Claims: r.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases,
+ return park.Engine{HomeRegion: cfg.ParkHomeRegion, Store: vs, Claims: r.s, Bucket: cfg.OwnBucket, Region: cfg.parkRegion(), Leases: cfg.ParkLeases,
Now: r.now, NewID: obligationID, View: cfg.ParkView, Authorize: movesArmAuthorize(cfg.ParkAuthorize, own), After: cfg.parkFault}
}
@@ -1173,7 +1173,7 @@ func (r *rollbackRun) authorizeChore(c *hostclaim.Claim, o hostclaim.Obligation)
return fmt.Errorf("%w: not this rollback's chore", park.ErrUnsafe)
}
switch {
- case o.Kind == "park-replica" && o.Region == cfg.parkRegion():
+ case o.Kind == "park-replica" && park.PlacementMatches(cfg.ParkHomeRegion, cfg.Region, o.Region):
case o.Kind == "park-local" && o.Box == cfg.Box:
default:
return fmt.Errorf("%w: not this target's chore", park.ErrUnsafe)
diff --git a/internal/host/movessources.go b/internal/host/movessources.go
index fa02f2353..64907a727 100644
--- a/internal/host/movessources.go
+++ b/internal/host/movessources.go
@@ -199,14 +199,14 @@ func movesParkViewAt(records MovesRecordReader, data genselect.Reader, bucket st
if err != nil {
return park.View{}, err
}
- v := park.View{ClaimETag: obs.ETag, Region: region, AuthoritativeRegion: auth}
+ v := park.View{HomeRegion: obs.Claim.Op.Intent["home_region"], ClaimETag: obs.ETag, Region: region, AuthoritativeRegion: auth}
h := obs.Claim.Holder
if h == nil {
return v, nil
}
location := strings.TrimSuffix(h.Location, "/")
v.GenerationOf = func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }
- if auth != region {
+ if !park.SameRegion(v.HomeRegion, auth, region) {
return v, nil
}
listing, err := data.ListVersions(ctx, bucket, location+"/")
@@ -461,7 +461,7 @@ func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs host
return nil
}
for _, w := range want {
- if admitted == w {
+ if park.SameRegion(c.Op.Intent["home_region"], admitted, w) {
return nil
}
}
@@ -477,8 +477,8 @@ func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs host
if err := contradicts(from, to); err != nil {
return "", err
}
- if known && admitted == to {
- if region == from && c.Op.Phase != hostclaim.PhaseCommitted && m.State != provision.MoveCommitted {
+ if known && park.SameRegion(c.Op.Intent["home_region"], admitted, to) {
+ if park.SameRegion(c.Op.Intent["home_region"], region, from) && c.Op.Phase != hostclaim.PhaseCommitted && m.State != provision.MoveCommitted {
// J2/F4 (Paul's #236 ruling): the source's live set, its
// files live set among it, stays protected on the source
// until the record commits. Seen from the source's region the
@@ -640,7 +640,7 @@ func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders,
if err != nil {
return fmt.Errorf("the claim's MoveParks: %w", err)
}
- if want := cfg.parkRegion(); want == "" || engine.Bucket != cfg.OwnBucket || engine.Region != want {
+ if want := cfg.parkRegion(); want == "" || engine.Bucket != cfg.OwnBucket || !engine.OwnsRegion(want) {
return fmt.Errorf("%w: the claim's park engine is %s in %q, this box's moves use %s in %q (placement %q)", hostbox.ErrChanged, engine.Bucket, engine.Region, cfg.OwnBucket, want, cfg.Region)
}
if engine.Leases == nil || engine.View == nil || engine.Authorize == nil {
@@ -653,6 +653,7 @@ func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders,
if p12 == nil {
return &ClaimDependencyError{Contracts: movesPlan12Contracts}
}
+ cfg.ParkHomeRegion = engine.HomeRegion
cfg.ParkLeases, cfg.ParkView, cfg.ParkAuthorize = engine.Leases, engine.View, engine.Authorize
cfg.ParkRunHeld = func(ctx context.Context, e park.Engine, w park.Work, held park.HeldLease) error {
return e.RunHeld(ctx, w, held)
@@ -705,6 +706,10 @@ func productionRosterRegion(store release.Store, getenv func(string) string) fun
if err != nil {
return "", err
}
- return rr.row.Region, nil
+ _, _, err = claimRegionBinding(rr.row.Region, getenv("CARLOS_REGION"))
+ if err != nil {
+ return "", err
+ }
+ return park.PlacementRegion(getenv("CARLOS_REGION")), nil
}
}
diff --git a/internal/host/plan13_amendments_test.go b/internal/host/plan13_amendments_test.go
new file mode 100644
index 000000000..30aae691d
--- /dev/null
+++ b/internal/host/plan13_amendments_test.go
@@ -0,0 +1,46 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/registry"
+ "strings"
+ "testing"
+)
+
+func TestPlan13LandingInvalidSourceWithoutHistory(t *testing.T) {
+ for _, from := range []string{"@home", "IE"} {
+ f := newPOFixture(t)
+ c := f.poFlipClaim()
+ c.Holder.Admissions = nil
+ f.rec.Move.From = from
+ o := hostclaim.Observed{Host: poHost, Claim: c}
+ if _, err := f.owners.landing(t.Context(), o); err == nil {
+ t.Fatal("invalid source accepted", from)
+ }
+ if got := predecessorRegion(c.Holder, from); got != from || got == "" {
+ t.Fatal("invalid predecessor became chore-free", got)
+ }
+ }
+}
+
+func TestPlan13NamedAdmissionReadsHomeStorageWithC8Gate(t *testing.T) {
+ f := plan9D5Fixture(t)
+ f.owners.HomeRegion = "ie"
+ f.owners.Region = "@home"
+ f.file(t, "old", 9, 1, 5)
+ f.marker(t, "old")
+ o := hostclaim.Observed{Host: recoveryHost, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "inc", Location: "instances/" + recoveryHost, Generation: "new", Admissions: []hostclaim.AdmissionRecord{{Generation: "new", Predecessor: "old", Region: "ie", PredecessorRegion: "ie", Mode: "cold"}}}}}
+ plan, err := f.owners.baseline(t.Context(), o)
+ if err != nil || plan == nil || plan.MaxTXID != 5 {
+ t.Fatal(plan, err)
+ }
+ if o.Claim.Holder.Admissions[0].Region != "ie" {
+ t.Fatal("rewrote history")
+ }
+ f.owners.Sources.Files = nil
+ o.Claim.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: f.owners.Box}
+ if _, err := f.owners.Select(t.Context(), registry.Route{}, o, activator.StartPolicy{}); err == nil || !strings.Contains(err.Error(), "C8") {
+ t.Fatal("C8 provider gate removed", err)
+ }
+}
diff --git a/internal/host/plan13_policy_test.go b/internal/host/plan13_policy_test.go
new file mode 100644
index 000000000..7c36f0bfe
--- /dev/null
+++ b/internal/host/plan13_policy_test.go
@@ -0,0 +1,22 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "testing"
+)
+
+func TestPlan13HomeMovePolicy(t *testing.T) {
+ for _, home := range []string{"uk", ""} {
+ c := mpClaim(hostclaim.KindLanding, provision.LandConfigured)
+ c.Op.Intent["to"] = park.HomeRegion
+ c.Op.Intent["home_region"] = home
+ s := mpSelection(c, "restore")
+ policy, _ := mpPolicy()
+ err := policy(t.Context(), c, s, mpIdentity("restore"))
+ if (err == nil) != (home != "") {
+ t.Fatalf("home=%q: %v", home, err)
+ }
+ }
+}
diff --git a/internal/host/plan13_quiet_test.go b/internal/host/plan13_quiet_test.go
new file mode 100644
index 000000000..54e89f75f
--- /dev/null
+++ b/internal/host/plan13_quiet_test.go
@@ -0,0 +1,23 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+ "time"
+)
+
+func TestPlan13MoveQuietReadsLegacyAndHomeToken(t *testing.T) {
+ now := time.Unix(100, 0)
+ for _, stored := range []string{"ie", park.HomeRegion, "uk"} {
+ o := hostclaim.Observed{Claim: &hostclaim.Claim{Op: hostclaim.Op{Intent: map[string]string{"home_region": "ie"}}, QuietAfter: map[string]time.Time{stored: now.Add(time.Hour)}}}
+ got := checkQuiet(o, park.HomeRegion, now)
+ if got.OK != (stored == "uk") {
+ t.Fatalf("%q: %+v", stored, got)
+ }
+ got = checkQuiet(o, "uk", now)
+ if got.OK != (stored != "uk") {
+ t.Fatalf("named %q: %+v", stored, got)
+ }
+ }
+}
diff --git a/internal/host/plan13_receipt_test.go b/internal/host/plan13_receipt_test.go
new file mode 100644
index 000000000..850aa3c5f
--- /dev/null
+++ b/internal/host/plan13_receipt_test.go
@@ -0,0 +1,93 @@
+package host
+
+import (
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostevidence"
+ "github.com/carlosframework/platform/internal/release"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestPlan13RecoveryHomeReceipt(t *testing.T) {
+ for _, bad := range []string{"", "session", "stamp", "enrollment", "signature", "operation", "region", "digest", "unbound", "roster-name"} {
+ t.Run(bad, func(t *testing.T) {
+ f, o, h := plan12Handover(t, hostclaim.KindRestoring, "stop")
+ o.Claim.Op.Intent["to"] = "@home"
+ o.Claim.Op.Intent["home_region"] = "uk"
+ if bad == "unbound" {
+ delete(o.Claim.Op.Intent, "home_region")
+ }
+ key := ed25519.NewKeyFromSeed([]byte(strings.Repeat("b", 32)))
+ en := strings.Repeat("a", 32)
+ d := hostactors.Directory{Store: f.claims, PublicKey: f.signer.Public().(ed25519.PublicKey), Bucket: "bucket"}
+ roster := hostactors.Roster{V: 1, Revision: 1, DeploymentBucket: "bucket", Boxes: map[string]hostactors.Box{"box-2": {Enrollment: en, BoxID: "box-2", Label: "box-2", Namespace: "platform", Region: "uk", Pool: "default", State: "active", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-2", InstanceID: "i-target"}, EvidenceSHA256: strings.Repeat("d", 64), CensusPublicKey: key.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
+ if bad == "roster-name" {
+ b := roster.Boxes["box-2"]
+ b.Region = "ie"
+ roster.Boxes["box-2"] = b
+ }
+ wire, _ := release.SignRootEnvelope(release.RootSigner(f.signer), roster)
+ if _, err := f.claims.Put(t.Context(), hostactors.Key, wire, ""); err != nil {
+ t.Fatal(err)
+ }
+ r := StopReceipt{V: 1, ID: "stop", Kind: "stopped", Host: o.Host, Incarnation: o.Claim.Holder.Incarnation, InstallationStamp: 2, WriterSession: 2, OperationID: "m1", Phase: "releasing", ObligationID: "writer-session-2", Box: "box-2", Region: "uk", Enrollment: en, EvidenceAt: time.Now(), RequestLimitNS: int64(2 * time.Minute), ClaimGen: 1, FenceID: "fence", InventorySHA256: strings.Repeat("e", 64), LocalJournalSHA256: strings.Repeat("f", 64), ProducerDrainDigest: strings.Repeat("a", 64), CgroupIdentities: []string{"root-role"}}
+ switch bad {
+ case "session":
+ r.WriterSession++
+ case "stamp":
+ r.InstallationStamp++
+ case "enrollment":
+ r.Enrollment = strings.Repeat("e", 32)
+ case "operation":
+ r.OperationID = "other"
+ case "region":
+ r.Region = "ie"
+ }
+ body, _ := json.Marshal(r)
+ receiptKey := stopEvidenceKey(o.Host, "m1", "box-2", "stop")
+ sum := sha256.Sum256(body)
+ signer := key
+ if bad == "signature" {
+ signer = f.signer
+ }
+ if err := hostevidence.Publish(t.Context(), f.claims, signer, receiptKey, body); err != nil {
+ t.Fatal(err)
+ }
+ h.Directory = d
+ o.Claim.Op.Journal["target_stop_receipt"] = receiptKey
+ o.Claim.Op.Journal["target_stop_sha256"] = hex.EncodeToString(sum[:])
+ o.Claim.Op.Journal[hostclaim.JournalTargetStamp] = "2"
+ // Remove the unsigned report path: the receipt alone must suffice.
+ delete(o.Claim.Op.Journal, hostclaim.JournalEvidenceTarget)
+ if bad == "digest" {
+ o.Claim.Op.Journal["target_stop_sha256"] = strings.Repeat("0", 64)
+ }
+ err := h.recoveryEvidence(t.Context(), o)
+ stored, _, readErr := f.claims.Get(t.Context(), receiptKey)
+ if readErr != nil || string(stored) != string(body) {
+ t.Fatal("receipt bytes changed")
+ }
+ if (err == nil) != (bad == "") {
+ t.Fatal(bad, err)
+ }
+ })
+ }
+}
+
+func TestPlan13HomeRecoveryFenceBinding(t *testing.T) {
+ for _, home := range []string{"uk", "", "ie"} {
+ _, o, h := plan12Handover(t, hostclaim.KindRestoring, "fence")
+ o.Claim.Op.Intent["to"] = "@home"
+ o.Claim.Op.Intent["home_region"] = home
+ err := h.recoveryEvidence(t.Context(), o)
+ if (err == nil) != (home == "uk") {
+ t.Fatal(home, err)
+ }
+ }
+}
diff --git a/internal/host/plan13_region_test.go b/internal/host/plan13_region_test.go
new file mode 100644
index 000000000..51916c9e0
--- /dev/null
+++ b/internal/host/plan13_region_test.go
@@ -0,0 +1,44 @@
+package host
+
+import (
+ "errors"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+)
+
+func TestPlan13ClaimRegionBinding(t *testing.T) {
+ for _, tc := range []struct {
+ roster, code, engine, home string
+ bad bool
+ }{
+ {"ie", "", "ie", "ie", false}, {"uk", "uk", "uk", "", false},
+ {"uk", "ie", "", "", true}, {"", "", "", "", true}, {"@home", "", "", "", true}, {"ie", "@home", "", "", true},
+ } {
+ e, h, err := claimRegionBinding(tc.roster, tc.code)
+ if e != tc.engine || h != tc.home || (err != nil) != tc.bad {
+ t.Fatalf("%+v: %q %q %v", tc, e, h, err)
+ }
+ }
+}
+func TestPlan13VerifyRegionalHomeNames(t *testing.T) {
+ for _, home := range []string{"region", ""} {
+ identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "region", ControlBucket: "control", Revision: 1})
+ identity.HomeRegion = home
+ actor, err := identity.Verify(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ e := claimRegionalEnrollment{identity: identity, actor: actor}
+ for _, region := range []string{"region", park.HomeRegion, "other", ""} {
+ err = e.VerifyRegional(t.Context(), region, actor.Bucket, hostbox.RequestLimit)
+ want := region == "region" || (home != "" && region == park.HomeRegion)
+ if (err == nil) != want {
+ t.Fatalf("home %q region %q: %v", home, region, err)
+ }
+ }
+ if err = e.VerifyRegional(t.Context(), actor.Region, "other", hostbox.RequestLimit); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatal(err)
+ }
+ }
+}
diff --git a/internal/host/plan13_views_test.go b/internal/host/plan13_views_test.go
new file mode 100644
index 000000000..a065a7895
--- /dev/null
+++ b/internal/host/plan13_views_test.go
@@ -0,0 +1,121 @@
+package host
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "testing"
+)
+
+func TestPlan13ProductionRemoteHomeView(t *testing.T) {
+ f := newVwFixture(t)
+ f.rec.Region = ""
+ f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: provision.MoveLanding}
+ h := f.holder("src", "src@ie")
+ o := hostclaim.Observed{Host: vwHost, ETag: "tag", Claim: &hostclaim.Claim{Host: vwHost, Holder: h, Op: hostclaim.Op{ID: vwMove, Kind: hostclaim.KindLanding, Phase: "installing", Intent: map[string]string{"home_region": "ie"}}}}
+ identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "uk", ControlBucket: "control", Revision: 1})
+ p := productionClaimRegional{Identity: identity, Parks: &claimMoveParkSources{View: f.view("uk")}}
+ if _, err := p.View(t.Context(), hostclaim.Observed{}); err == nil {
+ t.Fatal("missing claim accepted")
+ }
+ v, err := p.View(t.Context(), o)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v.AuthoritativeRegion != park.HomeRegion || v.Region != "uk" || v.HomeRegion != "ie" {
+ t.Fatalf("view: %+v", v)
+ }
+ if h.Admissions[0].Region != "ie" {
+ t.Fatal("history rewritten")
+ }
+ if err := p.Authorize(t.Context(), o, hostclaim.Obligation{Op: vwMove, Owner: "region:@home", Region: park.HomeRegion}); err == nil {
+ t.Fatal("named worker owns home")
+ }
+}
+func TestPlan13MovesEngineBindings(t *testing.T) {
+ for _, home := range []string{"ie", ""} {
+ region := "uk"
+ if home != "" {
+ region = "ie"
+ }
+ e := park.Engine{HomeRegion: home, Region: region, Bucket: "data", Leases: &fakeParkLeases{}, View: func(context.Context, hostclaim.Observed) (park.View, error) { return park.View{}, nil }, Authorize: allowParkAuthorize}
+ providers := ClaimProviders{moveParks: &claimMoveParks{engine: e, leases: movesLeaseSet("data"), local: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, check: func(context.Context) error { return nil }}}
+ cfg := moveCfg{Region: region, OwnBucket: "data"}
+ if home != "" {
+ cfg.Region = ""
+ }
+ if err := armMovesParks(t.Context(), &cfg, providers, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil }); err != nil {
+ t.Fatal(err)
+ }
+ w := &movesWorker{cfg: cfg}
+ engines := []park.Engine{(&installRun{w: w}).engine(nil), (&sourceFinal{w: w}).engine(nil), (&rollbackRun{w: w}).engine(nil)}
+ for _, got := range engines {
+ if got.HomeRegion != home || !got.OwnsRegion(park.PlacementRegion(cfg.Region)) {
+ t.Fatalf("binding lost: %+v", got)
+ }
+ }
+ }
+}
+
+func TestPlan13HomeLaneEngineBinding(t *testing.T) {
+ h := newAbortHarness(t)
+ h.abort(t)
+ h.pass(t)
+ chore := h.laneChore(t)
+ if chore == nil {
+ t.Fatal("no chore")
+ }
+ h.pastHorizon(t, chore)
+ cfg := h.laneCfg()
+ cfg.Region = ""
+ engine := park.Engine{Region: "uk", HomeRegion: "uk", Bucket: cfg.OwnBucket, Leases: cfg.ParkLeases, Authorize: allowParkAuthorize, View: func(_ context.Context, o hostclaim.Observed) (park.View, error) {
+ return park.View{Region: park.HomeRegion, ClaimETag: o.ETag, AuthoritativeRegion: "ie"}, nil
+ }}
+ p := ClaimProviders{moveParks: &claimMoveParks{engine: engine, leases: movesLeaseSet(cfg.OwnBucket), local: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, check: func(context.Context) error { return nil }}}
+ if err := armMovesParks(t.Context(), &cfg, p, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil }); err != nil {
+ t.Fatal(err)
+ }
+ o, err := hostclaim.Read(t.Context(), h.s, p1Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = hostclaim.Update(t.Context(), h.s, o, func(c *hostclaim.Claim) error { c.Op.Intent["home_region"] = "uk"; return nil }); err != nil {
+ t.Fatal(err)
+ }
+ rec := h.read(t, p1Host)
+ rec.Move.To = ""
+ worker := &movesWorker{cfg: cfg, mirrors: map[string]lease.MirrorStore{}}
+ if err := worker.laneParkTarget(t.Context(), h.s, rec, hostclaim.KindMoving, hostclaim.PhaseAborting); err != nil {
+ t.Fatal(err)
+ }
+ if h.laneChore(t) != nil {
+ t.Fatal("named home chore was not acknowledged")
+ }
+}
+
+func TestPlan13MovesViewMapsAllHomePlacements(t *testing.T) {
+ for _, moving := range []bool{false, true} {
+ f := newVwFixture(t)
+ f.rec.Region = ""
+ h := f.holder("")
+ op := hostclaim.Op{Kind: hostclaim.KindServing}
+ if moving {
+ h = f.holder("src", "src@ie")
+ op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: vwMove, Intent: map[string]string{"home_region": "ie"}}
+ f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: provision.MoveCutting}
+ }
+ v, err := f.view(park.HomeRegion)(t.Context(), vwObs(&hostclaim.Claim{Holder: h, Op: op}))
+ if !moving {
+ if err == nil {
+ t.Fatal("unbound legacy home accepted")
+ }
+ continue
+ }
+ if err != nil || v.AuthoritativeRegion != park.HomeRegion || !v.MarkerKnown {
+ t.Fatal(moving, v, err)
+ }
+ }
+}
diff --git a/internal/hostbox/identity.go b/internal/hostbox/identity.go
index dc4bf55d5..90473432b 100644
--- a/internal/hostbox/identity.go
+++ b/internal/hostbox/identity.go
@@ -3,6 +3,7 @@ package hostbox
import (
"context"
"crypto/x509"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/boxid"
"github.com/carlosframework/platform/internal/hostclaim"
@@ -12,6 +13,7 @@ import (
// Actor is the verified projection of the sole control/host-actors.json roster.
// Roster authenticates that document and its revision before returning a row.
type Actor struct {
+ HomeRegion string
ControlBucket string
Box, Enrollment string
Revision uint64
@@ -31,6 +33,7 @@ type BoxEvidence interface {
}
type IdentityProvider struct {
+ HomeRegion string
AWSRegion string
Roster Roster
Evidence BoxEvidence
@@ -67,6 +70,10 @@ func (p IdentityProvider) Verify(ctx context.Context) (Actor, error) {
if ev.Box != p.Box || ev.Cloud != cloud {
return Actor{}, ErrChanged
}
+ if p.HomeRegion != "" && (p.HomeRegion != a.Region || park.PlacementRegion(p.HomeRegion) != p.HomeRegion) {
+ return Actor{}, ErrChanged
+ }
+ a.HomeRegion = p.HomeRegion
return a, nil
}
diff --git a/internal/hostbox/park.go b/internal/hostbox/park.go
index ee6b7b0bc..8383a8760 100644
--- a/internal/hostbox/park.go
+++ b/internal/hostbox/park.go
@@ -41,7 +41,7 @@ func (r Regional) Run(ctx context.Context, work park.Work) error {
if ob.ID != work.ID || ob.Op != work.Op {
continue
}
- if ob.Region != r.Engine.Region || ob.Owner != "region:"+r.Engine.Region || (ob.Kind != "park-replica" && ob.Kind != "park-generation") {
+ if !r.Engine.OwnsRegion(ob.Region) || ob.Owner != "region:"+ob.Region || (ob.Kind != "park-replica" && ob.Kind != "park-generation") {
return ErrUnsupported
}
return r.Engine.Run(ctx, work)
diff --git a/internal/hostbox/plan13_admission_test.go b/internal/hostbox/plan13_admission_test.go
new file mode 100644
index 000000000..49c329d9a
--- /dev/null
+++ b/internal/hostbox/plan13_admission_test.go
@@ -0,0 +1,46 @@
+//go:build unix
+
+package hostbox
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/genselect"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+)
+
+func TestPlan13AdmissionKeepsExistingEngineName(t *testing.T) {
+ for _, home := range []string{"ie", ""} {
+ t.Run("home="+home, func(t *testing.T) {
+ _, store, o := workFixture(t)
+ writer := Writer{HomeRegion: home, Claims: store, Box: "box", Region: park.PlacementRegion("ie"), AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error { return nil }}
+ in := hostclaim.Admission{Box: "box", Mode: "cold", NewGeneration: "old", Region: "ie", PredecessorRegion: "ie"}
+ old, err := writer.Admit(t.Context(), o, in, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Restart against the stored named admission, rather than a synthesized holder.
+ old, err = hostclaim.Read(t.Context(), store, o.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in.NewGeneration = "next"
+ in.ObservedSession = old.Claim.Holder.Session
+ in.ObservedGeneration = "old"
+ next, err := writer.Admit(t.Context(), old, in, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := next.Claim.Holder.Admissions
+ if len(a) != 2 || a[0].Region != "ie" || a[1].Region != "ie" || a[1].PredecessorRegion != "ie" || a[1].Predecessor != "old" {
+ t.Fatalf("%+v", a)
+ }
+ writer.Region = ""
+ in.Region = ""
+ if _, err = writer.Admit(t.Context(), next, in, nil); err == nil {
+ t.Fatal("unset admitted")
+ }
+ })
+ }
+}
diff --git a/internal/hostbox/plan13_region_test.go b/internal/hostbox/plan13_region_test.go
new file mode 100644
index 000000000..e5e058179
--- /dev/null
+++ b/internal/hostbox/plan13_region_test.go
@@ -0,0 +1,103 @@
+//go:build unix
+
+package hostbox
+
+import (
+ "context"
+ "errors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/release"
+ "testing"
+ "time"
+)
+
+func TestPlan13HomeQuietWindow(t *testing.T) {
+ for _, home := range []string{"ie", ""} {
+ t.Run("home="+home, func(t *testing.T) {
+ source, store, obs := workFixture(t)
+ source.HomeRegion = home
+ _, err := hostclaim.Update(t.Context(), store, obs, func(c *hostclaim.Claim) error {
+ c.QuietAfter = map[string]time.Time{park.HomeRegion: time.Unix(200, 0)}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ work, err := source.Select(t.Context(), obs.Host, "probe")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ran := false
+ err = work.With(t.Context(), hostwrite.Identity{}, func() error { ran = true; return nil })
+ if home != "" && (!errors.Is(err, ErrChanged) || ran) {
+ t.Fatalf("home: %v %v", ran, err)
+ }
+ if home == "" && err != nil {
+ t.Fatalf("named: %v", err)
+ }
+ h := obs.Claim.Holder
+ selected := RecordSelection{Host: obs.Host, Key: h.Key, Incarnation: h.Incarnation, Family: h.Family, ETag: "record"}
+ r := Reconciler{HomeRegion: home, Claims: store, Box: "box", Region: "ie", Now: source.Now, CheckRecord: func(context.Context, RecordSelection) error { return nil }, CheckLocal: func(context.Context, hostclaim.Observed, RecordSelection) error { return nil }}
+ current, err := hostclaim.Read(t.Context(), store, obs.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = r.check(t.Context(), current, selected)
+ if (err != nil) != (home != "") {
+ t.Fatalf("reconcile home=%q: %v", home, err)
+ }
+ })
+ }
+}
+func TestPlan13RegionalHomeAcknowledgesBothNames(t *testing.T) {
+ for _, region := range []string{park.HomeRegion, "ie", "uk"} {
+ t.Run(region, func(t *testing.T) {
+ claims := release.NewDirStore(t.TempDir())
+ bucket := leasetest.NewVersionedBucket()
+ bucket.AddBucket("data")
+ now := time.Unix(2000, 0).UTC()
+ host := "home.example"
+ inc, err := hostclaim.Incarnation("bab", "app", host, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), claims, hostclaim.Observed{Host: host, Absent: true}, func(c *hostclaim.Claim) error {
+ c.Holder = &hostclaim.Holder{Family: "instance", Key: "apps/bab/app/instances/" + host + ".json", Account: "bab", App: "app", Incarnation: inc, CreatedAt: now, Location: "instances/" + host + "/" + inc, Stamp: 1}
+ c.Op = hostclaim.Op{Kind: hostclaim.KindParking, ID: "op"}
+ c.Obligations = []hostclaim.Obligation{{ID: "id", Op: "op", Kind: "park-replica", Region: region, Owner: "region:" + region, Retirement: "ret", Park: &hostclaim.ParkScope{Location: c.Holder.Location, Incarnation: inc, EvidenceAt: time.Unix(1, 0).UTC(), RequestLimit: RequestLimit}}}
+ c.Touched = hostclaim.Touched{Complete: true, Regions: []string{region}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ e := park.Engine{HomeRegion: "ie", Claims: claims, Store: bucket, Bucket: "data", Region: "ie", Leases: workerLeases{}, Now: func() time.Time { return now }, NewID: func() string { return "id2" }, Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }, View: func(_ context.Context, o hostclaim.Observed) (park.View, error) {
+ return park.View{ClaimETag: o.ETag, Region: park.HomeRegion}, nil
+ }}
+ w := RegionalWorker{Regional: Regional{Engine: e, Owner: func(context.Context, hostclaim.Observed) error { return nil }}, Enrollment: enrollmentFunc(func(context.Context, string, string, time.Duration) error { return nil }), Evidence: evidenceFunc(func(context.Context, hostclaim.Observed, []lease.ObjectVersion) (string, time.Time, error) {
+ return "", now, nil
+ })}
+ hosts, err := w.Census(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if (len(hosts) == 1) != (region != "uk") {
+ t.Fatalf("census %v", hosts)
+ }
+ if err = w.Run(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ o, err := hostclaim.Read(t.Context(), claims, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if (len(o.Claim.Obligations) == 0) != (region != "uk") {
+ t.Fatalf("obligations %+v", o.Claim.Obligations)
+ }
+ })
+ }
+}
diff --git a/internal/hostbox/reconcile.go b/internal/hostbox/reconcile.go
index 7cf5d4a59..0081ca0ab 100644
--- a/internal/hostbox/reconcile.go
+++ b/internal/hostbox/reconcile.go
@@ -18,6 +18,7 @@ type RecordSelection struct {
DryRun bool
}
type Reconciler struct {
+ HomeRegion string
Claims release.Store
Guard hostguard.Guard
Local park.Local
@@ -60,7 +61,7 @@ func (r Reconciler) check(ctx context.Context, obs hostclaim.Observed, selected
if err := Serving(obs.Claim, Selection{Box: r.Box}); err != nil {
return err
}
- if until, ok := obs.Claim.QuietAfter[r.Region]; ok && !r.Now().After(until) {
+ if until := park.QuietUntil(obs.Claim.QuietAfter, r.HomeRegion, r.Region); !until.IsZero() && !r.Now().After(until) {
return ErrChanged
}
if err := r.CheckRecord(ctx, selected); err != nil {
diff --git a/internal/hostbox/regionalworker.go b/internal/hostbox/regionalworker.go
index 8171abf6e..a88b5f04a 100644
--- a/internal/hostbox/regionalworker.go
+++ b/internal/hostbox/regionalworker.go
@@ -102,7 +102,7 @@ func (w RegionalWorker) Census(ctx context.Context) ([]string, error) {
return nil, readErr
}
for _, o := range obs.Claim.Obligations {
- if o.Region == w.Regional.Engine.Region && o.Owner == "region:"+w.Regional.Engine.Region {
+ if w.Regional.Engine.OwnsRegion(o.Region) && o.Owner == "region:"+o.Region {
hosts[host] = true
break
}
@@ -197,7 +197,7 @@ func (w RegionalWorker) runHost(ctx context.Context, host string) error {
if !found {
continue
}
- if o.Owner != "region:"+w.Regional.Engine.Region || o.Region != w.Regional.Engine.Region {
+ if o.Owner != "region:"+o.Region || !w.Regional.Engine.OwnsRegion(o.Region) {
continue
}
if o.Kind != "park-replica" && o.Kind != "park-generation" {
diff --git a/internal/hostbox/work.go b/internal/hostbox/work.go
index 03a14ca05..caf015968 100644
--- a/internal/hostbox/work.go
+++ b/internal/hostbox/work.go
@@ -3,6 +3,7 @@ package hostbox
import (
"context"
"errors"
+ "github.com/carlosframework/platform/internal/park"
"sync"
"time"
@@ -22,6 +23,7 @@ type Selection struct {
}
type WorkSource struct {
+ HomeRegion string
Prepare func(context.Context, hostclaim.Observed) error
Guard hostguard.Guard
Box, Region string
@@ -86,7 +88,7 @@ func (w selectedWork) With(ctx context.Context, identity hostwrite.Identity, fn
}
switch selected.Action {
case "install", "restore", "spawn", "seed", "writer", "probe":
- if until, ok := c.QuietAfter[s.Region]; ok && !s.Now().After(until) {
+ if until := park.QuietUntil(c.QuietAfter, s.HomeRegion, s.Region); !until.IsZero() && !s.Now().After(until) {
return ErrChanged
}
}
diff --git a/internal/hostbox/writer.go b/internal/hostbox/writer.go
index 7a1409c47..f6d61eebf 100644
--- a/internal/hostbox/writer.go
+++ b/internal/hostbox/writer.go
@@ -3,6 +3,7 @@ package hostbox
import (
"context"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"strings"
"github.com/carlosframework/platform/internal/genselect"
@@ -12,6 +13,7 @@ import (
// Writer supplies transition adapters, not stop, certificate or marker evidence.
type Writer struct {
+ HomeRegion string
Loss *LossAdmission
Claims release.Store
Data genselect.Reader
@@ -25,7 +27,7 @@ func (w Writer) Baseline(ctx context.Context, observed hostclaim.Observed) (*gen
if w.Data == nil || w.Bucket == "" || w.Region == "" || observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil {
return nil, ErrUnsupported
}
- return genselect.Select(ctx, w.Data, genselect.Request{Bucket: w.Bucket, Region: w.Region, Host: observed.Host, Holder: *observed.Claim.Holder})
+ return genselect.Select(ctx, w.Data, genselect.Request{Bucket: w.Bucket, Region: w.Region, Host: observed.Host, Holder: park.HolderRegionView(*observed.Claim.Holder, w.HomeRegion, w.Region)})
}
func (w Writer) Admit(ctx context.Context, observed hostclaim.Observed, in hostclaim.Admission, baseline *genselect.Plan) (hostclaim.Observed, error) {
diff --git a/internal/hostclaim/generation.go b/internal/hostclaim/generation.go
index 8c7e71022..ff6173146 100644
--- a/internal/hostclaim/generation.go
+++ b/internal/hostclaim/generation.go
@@ -2,6 +2,7 @@ package hostclaim
import (
"fmt"
+ "github.com/carlosframework/platform/internal/regionname"
"reflect"
"strconv"
)
@@ -258,7 +259,7 @@ func CoverRegionPredecessors(observedOpID, observedPhase string, region string)
}
for i := range c.Holder.Admissions {
record := &c.Holder.Admissions[i]
- if !record.Covered && record.PredecessorRegion == region {
+ if !record.Covered && regionname.SameRegion(c.Op.Intent["home_region"], record.PredecessorRegion, region) {
record.Covered = true
}
}
@@ -284,7 +285,7 @@ func CoverParkedPredecessors(observedOpID, observedPhase string, region string,
if predecessor == "" {
predecessor = RootGeneration
}
- if !record.Covered && record.PredecessorRegion == region && parked[predecessor] {
+ if !record.Covered && regionname.SameRegion(c.Op.Intent["home_region"], record.PredecessorRegion, region) && parked[predecessor] {
record.Covered = true
}
}
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go
index 81899281e..aed39076a 100644
--- a/internal/hostclaim/move.go
+++ b/internal/hostclaim/move.go
@@ -8,6 +8,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/regionname"
"github.com/carlosframework/platform/internal/release"
)
@@ -68,6 +69,7 @@ func BeginMove(opID, by, sourceBox, targetBox, targetRegion string, now time.Tim
// holds the claim), and both boxes' enrollments from the signed roster.
// hostclaim only records them; hostops.BeginClaimMove does the proving.
type MoveAdmission struct {
+ HomeRegion string
AppID, AdmissionID, SourceEnrollment, TargetEnrollment string
}
@@ -81,9 +83,22 @@ func BeginAdmittedMove(opID, by, sourceBox, targetBox, targetRegion string, now
if a.AppID == "" || a.AdmissionID == "" || a.AdmissionID != opID || !validEnrollment(a.SourceEnrollment) || !validEnrollment(a.TargetEnrollment) || a.SourceEnrollment == a.TargetEnrollment {
return fmt.Errorf("%w: an admitted move needs its app, its registration and both enrollments", ErrMalformed)
}
+ if a.HomeRegion != "" && regionname.PlacementRegion(a.HomeRegion) != a.HomeRegion {
+ return ErrMalformed
+ }
+ regions := []string{targetRegion}
+ if regionname.SameRegion(a.HomeRegion, targetRegion, regionname.HomeRegion) {
+ regions = append(regions, a.HomeRegion, regionname.HomeRegion)
+ }
+ if err := StartGuard(c, regions, []string{targetBox}); err != nil {
+ return err
+ }
if err := BeginMove(opID, by, sourceBox, targetBox, targetRegion, now)(c); err != nil {
return err
}
+ if a.HomeRegion != "" {
+ c.Op.Intent["home_region"] = a.HomeRegion
+ }
c.Op.Intent["app_id"] = a.AppID
c.Op.Intent["admission_id"] = a.AdmissionID
c.Op.Intent["source_enrollment"] = a.SourceEnrollment
diff --git a/internal/hostclaim/plan13_amendments_test.go b/internal/hostclaim/plan13_amendments_test.go
new file mode 100644
index 000000000..f9bb655a7
--- /dev/null
+++ b/internal/hostclaim/plan13_amendments_test.go
@@ -0,0 +1,68 @@
+package hostclaim
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestPlan13BoundHomeMoveStartCleanup(t *testing.T) {
+ for _, target := range []string{"@home", "ie"} {
+ c := &Claim{Holder: &Holder{}, Op: Op{Kind: KindServing, Exec: "src"}, Obligations: []Obligation{{ID: "cleanup", Op: "old", Kind: "park-replica", Region: "ie", Owner: "region:ie"}}}
+ before := append([]Obligation(nil), c.Obligations...)
+ a := MoveAdmission{AppID: "app", AdmissionID: "move", SourceEnrollment: strings.Repeat("a", 32), TargetEnrollment: strings.Repeat("b", 32), HomeRegion: "ie"}
+ if err := BeginAdmittedMove("move", "person", "src", "dst", target, time.Now(), a)(c); !errors.Is(err, ErrPendingChore) {
+ t.Fatal("pending home cleanup allowed move", target, err)
+ }
+ if !reflect.DeepEqual(c.Obligations, before) || c.Op.Kind != KindServing {
+ t.Fatal("refusal mutated claim")
+ }
+ }
+}
+
+func TestPlan13WholeLocationCoversHomeWithoutObjects(t *testing.T) {
+ for _, release := range []bool{false, true} {
+ for _, bound := range []bool{false, true} {
+ c := probedLanding(t)
+ home, region := "ie", "@home"
+ if release {
+ c = targetWroteLanding(t)
+ home = "uk"
+ c.Holder.Admissions = append(c.Holder.Admissions, AdmissionRecord{Region: "uk", PredecessorRegion: "uk", Session: 10, Box: "uk-box", Generation: "gen-r2", Predecessor: moveA, Mode: "cold"})
+ c.Holder.Session, c.Holder.Generation = 10, "gen-r2"
+ if err := RollBack(moveA, rbA)(c); err != nil {
+ t.Fatal(err)
+ }
+ c.Op.Intent["to"] = region
+ }
+ if bound {
+ c.Op.Intent["home_region"] = home
+ }
+ n := 0
+ ids := func() string { n++; return fmt.Sprint("park", n) }
+ var err error
+ if release {
+ err = TargetReleased(moveA, "uk-box", region, stoppedTarget, releaseScope(c), ids)(c)
+ } else {
+ src, lane := commitScopes(c)
+ err = Commit(moveA, region, "ie-box", "uk", src, lane, ids)(c)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ idx := 0
+ if release {
+ idx = 1
+ }
+ if c.Holder.Admissions[idx].Covered != bound {
+ t.Fatalf("release=%v bound=%v: %+v", release, bound, c.Holder.Admissions)
+ }
+ if c.Holder.Admissions[idx].PredecessorRegion != home {
+ t.Fatal("rewrote admission identity")
+ }
+ }
+ }
+}
diff --git a/internal/hostclaim/relocating.go b/internal/hostclaim/relocating.go
index fbce8d0e5..1cc29e179 100644
--- a/internal/hostclaim/relocating.go
+++ b/internal/hostclaim/relocating.go
@@ -18,7 +18,8 @@ const KindRelocating = "relocating"
// source and exact fence reference. A snapshot of an undecided record is not
// an implementation of this interface. While the claim is moving the source
// is its "source" and the region is the record's; once relocating they are
-// its "source_box" and "region". The returned region is the record's.
+// its "source_box" and "region". The returned region is the verified engine
+// identity of the record placement, never an empty placement code.
type PreFlipGuard interface {
CheckMovingBeforeFlip(context.Context, Observed, string, FenceRef) (string, error)
}
diff --git a/internal/hostops/backfill.go b/internal/hostops/backfill.go
index 31bc6363c..b413e3ebb 100644
--- a/internal/hostops/backfill.go
+++ b/internal/hostops/backfill.go
@@ -82,6 +82,7 @@ type Backfill struct {
Directory hostactors.Directory
}
type ProductionAuthority struct {
+ HomeRegion string
Store release.Store
Control *ControlWriter
Directory hostactors.Directory
diff --git a/internal/hostops/lifecycle_record.go b/internal/hostops/lifecycle_record.go
index 93530fa23..d8be7fa65 100644
--- a/internal/hostops/lifecycle_record.go
+++ b/internal/hostops/lifecycle_record.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -84,11 +85,11 @@ func (s conditionalRecordStore) putLifecycle(ctx context.Context, key string, bo
return "", ErrIdentity
}
var from, to string
- if json.Unmarshal(before["region"], &from) != nil || json.Unmarshal(next["region"], &to) != nil {
+ if (len(before["region"]) != 0 && json.Unmarshal(before["region"], &from) != nil) || (len(next["region"]) != 0 && json.Unmarshal(next["region"], &to) != nil) || !provision.ValidRegion(from) || !provision.ValidRegion(to) {
return "", ErrIdentity
}
if from != to {
- flip := o.Claim.Op.Kind == hostclaim.KindMoving && o.Claim.Op.Phase == "sealed" && a.State == "cutting" && a.CutPhase == "archived" && a.FlippedAt == nil && a.CommittedAt == nil && a.Rollback == nil && b.State == "landing" && b.CutPhase == "flipped" && b.FlippedAt != nil && b.LandingDeadline != nil && b.LandingDeadline.After(*b.FlippedAt) && b.Rollback == nil && from == b.From && to == b.To && to == o.Claim.Op.Intent["to"]
+ flip := o.Claim.Op.Kind == hostclaim.KindMoving && o.Claim.Op.Phase == "sealed" && a.State == "cutting" && a.CutPhase == "archived" && a.FlippedAt == nil && a.CommittedAt == nil && a.Rollback == nil && b.State == "landing" && b.CutPhase == "flipped" && b.FlippedAt != nil && b.LandingDeadline != nil && b.LandingDeadline.After(*b.FlippedAt) && b.Rollback == nil && from == b.From && to == b.To && park.PlacementMatches(o.Claim.Op.Intent["home_region"], to, o.Claim.Op.Intent["to"])
restore := o.Claim.Op.Kind == hostclaim.KindLanding && o.Claim.Op.Phase != "committed" && a.State == "landing" && a.CommittedAt == nil && a.Rollback == nil && b.State == "rolling_back" && b.CommittedAt == nil && b.Rollback != nil && b.Rollback.ID != "" && b.Rollback.RequestedAt != nil && b.Rollback.TargetPhase == "releasing" && b.Rollback.SourcePhase == "waiting" && b.Rollback.ReleaseDeadline != nil && b.Rollback.ReleaseDeadline.Equal(b.Rollback.RequestedAt.Add(15*time.Minute)) && from == b.To && to == b.From
// The rollback region is the original record's Move.From (a.From ==
// b.From above), never claim input: the claim carries no source region.
diff --git a/internal/hostops/move_enrollment.go b/internal/hostops/move_enrollment.go
index f00908be9..7c646ac46 100644
--- a/internal/hostops/move_enrollment.go
+++ b/internal/hostops/move_enrollment.go
@@ -3,6 +3,7 @@ package hostops
import (
"context"
"errors"
+ "github.com/carlosframework/platform/internal/park"
"sync/atomic"
"time"
@@ -59,10 +60,20 @@ func (a ProductionAuthority) RegisterMove(ctx context.Context, o hostclaim.Obser
// the roster. The admission is reread inside the transition, so the last
// admission read precedes the one claim CAS, which itself runs under h's
// lifecycle grant (the data boundary rereads admission again at the Put).
-func BeginClaimMove(ctx context.Context, s release.Store, d hostactors.Directory, h *StartHandle, o hostclaim.Observed, id, by, target, to string, now time.Time) (hostclaim.Observed, error) {
+func BeginClaimMove(ctx context.Context, s release.Store, d hostactors.Directory, h *StartHandle, o hostclaim.Observed, id, by, target, to string, now time.Time, homeNames ...string) (hostclaim.Observed, error) {
if h == nil || h.w == nil || s == nil || o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" || id == "" || id != h.id || now.IsZero() {
return hostclaim.Observed{}, ErrIdentity
}
+ home := ""
+ if len(homeNames) > 1 {
+ return hostclaim.Observed{}, ErrIdentity
+ }
+ if len(homeNames) == 1 {
+ home = homeNames[0]
+ }
+ if home != "" && park.PlacementRegion(home) != home {
+ return hostclaim.Observed{}, ErrIdentity
+ }
holder := o.Claim.Holder
if holder.Family != "instance" || holder.Account != h.account || holder.App != h.app {
return hostclaim.Observed{}, ErrIdentity
@@ -74,7 +85,7 @@ func BeginClaimMove(ctx context.Context, s release.Store, d hostactors.Directory
}
src, sourceOK := roster.Roster.Boxes[source]
dst, targetOK := roster.Roster.Boxes[target]
- if !sourceOK || !targetOK || source == target || src.State != "active" || dst.State != "active" || src.Region == "" || dst.Region != to || src.Region == to {
+ if !sourceOK || !targetOK || source == target || src.State != "active" || dst.State != "active" || src.Region == "" || dst.Region == "" || park.PlacementRegion(park.HomePlacementCode(home, dst.Region)) != park.PlacementRegion(to) || park.PlacementRegion(park.HomePlacementCode(home, src.Region)) == park.PlacementRegion(to) {
return hostclaim.Observed{}, ErrIdentity
}
// Lookup also refuses a box with a termination fence.
@@ -95,15 +106,21 @@ func BeginClaimMove(ctx context.Context, s release.Store, d hostactors.Directory
return hostclaim.Observed{}, err
}
inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt)
- if err != nil || inc != holder.Incarnation || rec.Region != src.Region || rec.Move != nil {
+ if err != nil || inc != holder.Incarnation || !ClaimMoveRosterPlaces(roster.Roster, source, target, rec.Region, to, home) || rec.Move != nil {
return hostclaim.Observed{}, ErrIdentity
}
- admitted := hostclaim.MoveAdmission{AppID: h.appID, AdmissionID: h.id, SourceEnrollment: src.Enrollment, TargetEnrollment: dst.Enrollment}
+ admitted := hostclaim.MoveAdmission{HomeRegion: home, AppID: h.appID, AdmissionID: h.id, SourceEnrollment: src.Enrollment, TargetEnrollment: dst.Enrollment}
return hostclaim.Update(h.Lifecycle(ctx), s, o, func(c *hostclaim.Claim) error {
if err := liveMoveRegistration(ctx, s, h, o); err != nil {
return err
}
- return hostclaim.BeginAdmittedMove(id, by, source, target, to, now, admitted)(c)
+ if err := hostclaim.BeginAdmittedMove(id, by, source, target, park.PlacementRegion(to), now, admitted)(c); err != nil {
+ return err
+ }
+ if home != "" {
+ c.Op.Intent["home_region"] = home
+ }
+ return nil
})
}
@@ -134,7 +151,7 @@ func StartClaimMoveRecord(ctx context.Context, s release.Store, h *StartHandle,
return nil, ErrIdentity
}
c := moving.Claim
- if c.Op.Kind != hostclaim.KindMoving || c.Op.Phase != hostclaim.PhaseMoving || p.ID != c.Op.ID || p.Incarnation != c.Holder.Incarnation || p.To != c.Op.Intent["to"] ||
+ if c.Op.Kind != hostclaim.KindMoving || c.Op.Phase != hostclaim.PhaseMoving || p.ID != c.Op.ID || p.Incarnation != c.Holder.Incarnation || !park.PlacementMatches(c.Op.Intent["home_region"], p.To, c.Op.Intent["to"]) ||
c.Op.Intent["admission_id"] != h.id || c.Op.Intent["app_id"] != h.appID {
return nil, ErrIdentity
}
diff --git a/internal/hostops/move_fence_regions.go b/internal/hostops/move_fence_regions.go
new file mode 100644
index 000000000..869122619
--- /dev/null
+++ b/internal/hostops/move_fence_regions.go
@@ -0,0 +1,34 @@
+package hostops
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/termfence"
+)
+
+// MoveFenceRegions preserves the verifier's roster/enrollment checks, then
+// projects only its region binding into the move's engine vocabulary.
+// Signed fence bytes and the cloud region are never edited.
+type MoveFenceRegions struct {
+ Authority hostclaim.FenceAuthority
+ HomeRegion string
+}
+
+func (a MoveFenceRegions) ValidateFence(ctx context.Context, f termfence.Record) (hostclaim.FenceBinding, error) {
+ if a.Authority == nil {
+ return hostclaim.FenceBinding{}, hostclaim.ErrFence
+ }
+ binding, err := a.Authority.ValidateFence(ctx, f)
+ if err != nil {
+ return hostclaim.FenceBinding{}, err
+ }
+ if binding.Region == "" || (a.HomeRegion != "" && park.PlacementRegion(a.HomeRegion) != a.HomeRegion) {
+ return hostclaim.FenceBinding{}, hostclaim.ErrFence
+ }
+ binding.Region = park.PlacementRegion(park.HomePlacementCode(a.HomeRegion, binding.Region))
+ if binding.Region == "" {
+ return hostclaim.FenceBinding{}, hostclaim.ErrFence
+ }
+ return binding, nil
+}
diff --git a/internal/hostops/move_fence_release.go b/internal/hostops/move_fence_release.go
index 2a7063128..3124c1c96 100644
--- a/internal/hostops/move_fence_release.go
+++ b/internal/hostops/move_fence_release.go
@@ -58,7 +58,7 @@ func (g *MoveRecoveryGrant) ReleaseFencedTarget(ctx context.Context, ref hostcla
}
return writeRecord()
}
- fence, err := hostclaim.VerifyMoveTargetFence(ctx, g.w.raw, o, ref, g.authority)
+ fence, err := hostclaim.VerifyMoveTargetFence(ctx, g.w.raw, o, ref, MoveFenceRegions{Authority: g.authority, HomeRegion: o.Claim.Op.Intent["home_region"]})
if err != nil {
return err
}
diff --git a/internal/hostops/move_loss_admission.go b/internal/hostops/move_loss_admission.go
index a2b798571..0cad51332 100644
--- a/internal/hostops/move_loss_admission.go
+++ b/internal/hostops/move_loss_admission.go
@@ -2,6 +2,7 @@ package hostops
import (
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
"strconv"
"strings"
)
@@ -41,7 +42,7 @@ func (m *moveLossOwner) ValidateLossRecovery(c hostclaim.Claim, loss hostclaim.L
c.Op.ID != in.Move || c.Op.Kind != hostclaim.KindRestoring || c.Op.Phase != hostclaim.PhaseProbing ||
c.Op.Intent["rollback"] != in.Rollback || c.Op.Exec != in.Source || c.Op.Intent["source"] != in.Source ||
c.Op.Intent["target"] != in.Target || c.Op.Intent["to"] != in.TargetRegion ||
- !hostclaim.LostTargetReleased(&c, loss.ID) || a.Box != in.Source || a.Region != in.SourceRegion ||
+ !hostclaim.LostTargetReleased(&c, loss.ID) || a.Box != in.Source || !park.SameRegion(c.Op.Intent["home_region"], a.Region, in.SourceRegion) ||
a.Mode != "cold" || a.Coverage != "" || a.KeepStamp || a.PredecessorCertified {
return hostclaim.ErrLossRecord
}
diff --git a/internal/hostops/move_loss_owner.go b/internal/hostops/move_loss_owner.go
index 8204632b3..37beecd57 100644
--- a/internal/hostops/move_loss_owner.go
+++ b/internal/hostops/move_loss_owner.go
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/park"
"io"
"reflect"
"strconv"
@@ -313,13 +314,13 @@ func lossIntentAt(ctx context.Context, store release.Store, pub ed25519.PublicKe
// rollback, phase, Dirty and return lane.
func recordLossIntent(in *moveLossIntent, rec provision.Record, etag string, seq uint64, op hostclaim.Op, claimETag string, prior *supersededLoss) error {
m := rec.Move
- if m == nil || m.ID != in.Move || m.Incarnation != in.Incarnation || m.To != in.TargetRegion || m.From == "" || m.From == m.To {
+ if m == nil || m.ID != in.Move || m.Incarnation != in.Incarnation || !park.PlacementMatches(op.Intent["home_region"], m.To, in.TargetRegion) || park.PlacementRegion(m.From) == "" || m.From == m.To {
return hostresolve.ErrResolve
}
if m.State == provision.MoveCommitted || m.CommittedAt != nil {
return moveLossRefusal(provision.RefusedCommitted)
}
- in.SourceRegion, in.RecordETag, in.RecordSeq = m.From, etag, seq
+ in.SourceRegion, in.RecordETag, in.RecordSeq = park.PlacementRegion(m.From), etag, seq
switch m.State {
case provision.MoveLanding:
if op.Kind != hostclaim.KindLanding || m.Rollback != nil {
diff --git a/internal/hostops/move_recovery_bridge.go b/internal/hostops/move_recovery_bridge.go
index 52e1fe31b..42e0b6ded 100644
--- a/internal/hostops/move_recovery_bridge.go
+++ b/internal/hostops/move_recovery_bridge.go
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "github.com/carlosframework/platform/internal/park"
"strconv"
"time"
@@ -72,7 +73,9 @@ var _ hostclaim.PreFlipGuard = movingRecordDecision{}
// CheckMovingBeforeFlip reads the move's own intent while the claim is
// moving ("source", "to") and the conversion's once relocating
// ("source_box", "region", "move_to_region"). The region it returns is the
-// record's; ConvertMovingToRelocating compares it with the fence binding.
+// verified roster identity for the record placement; the home code stays
+// empty in the decision. ConvertMovingToRelocating compares the returned
+// identity with the unmodified fence binding.
// ConvertMovingToRelocating calls it again inside its transition, so the
// admission reread here is the last read before the one claim CAS.
func (g movingRecordDecision) CheckMovingBeforeFlip(ctx context.Context, o hostclaim.Observed, opID string, ref hostclaim.FenceRef) (string, error) {
@@ -105,7 +108,11 @@ func (g movingRecordDecision) CheckMovingBeforeFlip(ctx context.Context, o hostc
if d.MovingIdentity != moving || c.Gen < d.ClaimGen {
return "", hostclaim.ErrConflict
}
- source, region, to = c.Op.Intent["source"], rec.Region, c.Op.Intent["to"]
+ region = park.PlacementRegion(rec.Region)
+ if rec.Region == "" && c.Op.Intent["home_region"] != "" {
+ region = c.Op.Intent["home_region"]
+ }
+ source, to = c.Op.Intent["source"], c.Op.Intent["to"]
case hostclaim.KindRelocating:
convertedGen, err := strconv.ParseUint(c.Op.Journal["conversion_claim_gen"], 10, 64)
raw, e := json.Marshal(d)
@@ -125,7 +132,7 @@ func (g movingRecordDecision) CheckMovingBeforeFlip(ctx context.Context, o hostc
return "", err
}
if source == "" || region == "" || d.DecidedAt.IsZero() || d.Fence != string(raw) || d.Enrollment != ref.Enrollment || d.Enrollment != c.Op.Intent["source_enrollment"] || d.SourceBox != source ||
- d.Region != region || rec.Region != d.Region || rec.Move.From != d.Region || to == "" || rec.Move.To != to || rec.Move.FlippedAt != nil || rec.Move.CommittedAt != nil || rec.Move.Rollback != nil {
+ !park.PlacementMatches(c.Op.Intent["home_region"], d.Region, region) || rec.Region != d.Region || rec.Move.From != d.Region || !park.PlacementMatches(c.Op.Intent["home_region"], rec.Move.To, to) || rec.Move.FlippedAt != nil || rec.Move.CommittedAt != nil || rec.Move.Rollback != nil {
return "", ErrIdentity
}
switch d.PreviousState {
@@ -136,10 +143,10 @@ func (g movingRecordDecision) CheckMovingBeforeFlip(ctx context.Context, o hostc
if g.placement == nil {
return "", ErrContract
}
- if err := g.placement.CheckOneBoxRegion(ctx, source, d.Region); err != nil {
+ if err := g.placement.CheckOneBoxRegion(ctx, source, region); err != nil {
return "", err
}
- return d.Region, nil
+ return region, nil
}
// ConversionDecisionDigest binds the converted claim to the exact record
@@ -242,10 +249,10 @@ func RecoverTerminatedMovingHost(ctx context.Context, s release.Store, mh *Start
return err
}
region := rec.Region
- if region == "" || region != fence.Region() || rec.Move == nil || rec.Move.From != region {
+ if !park.PlacementMatches(c.Op.Intent["home_region"], region, fence.Region()) || rec.Move == nil || rec.Move.From != region {
return hostclaim.ErrFence
}
- if err := placement.CheckOneBoxRegion(ctx, source, region); err != nil {
+ if err := placement.CheckOneBoxRegion(ctx, source, fence.Region()); err != nil {
return err
}
raw, err := json.Marshal(ref)
diff --git a/internal/hostops/move_recovery_grant.go b/internal/hostops/move_recovery_grant.go
index 284c96077..74e1fa99c 100644
--- a/internal/hostops/move_recovery_grant.go
+++ b/internal/hostops/move_recovery_grant.go
@@ -481,10 +481,10 @@ func (g *MoveRecoveryGrant) Convert(ctx context.Context, ref hostclaim.FenceRef)
}
return hostclaim.VerifyOperationFence(ctx, g.w.raw, authority, c.Op, c.Op.Intent["source_box"])
}
- if c.Op.Kind != hostclaim.KindMoving || c.Op.Intent["source_enrollment"] != ref.Enrollment || fence.Box() != c.Op.Intent["source"] || fence.Region() != rec.Region {
+ if c.Op.Kind != hostclaim.KindMoving || c.Op.Intent["source_enrollment"] != ref.Enrollment || fence.Box() != c.Op.Intent["source"] || !park.PlacementMatches(c.Op.Intent["home_region"], rec.Region, fence.Region()) {
return hostclaim.ErrFence
}
- if err := placement.CheckOneBoxRegion(ctx, fence.Box(), rec.Region); err != nil {
+ if err := placement.CheckOneBoxRegion(ctx, fence.Box(), fence.Region()); err != nil {
return err
}
if rec.Move == nil || rec.Move.ID != g.o.OpID || rec.Move.Incarnation != h.Incarnation {
diff --git a/internal/hostops/move_region.go b/internal/hostops/move_region.go
new file mode 100644
index 000000000..206203b37
--- /dev/null
+++ b/internal/hostops/move_region.go
@@ -0,0 +1,27 @@
+package hostops
+
+import (
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/park"
+)
+
+// ClaimMoveRosterPlaces is shared by the console preflight and the start CAS.
+func ClaimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to string, homeNames ...string) bool {
+ if len(homeNames) > 1 {
+ return false
+ }
+ home := ""
+ if len(homeNames) == 1 {
+ home = homeNames[0]
+ }
+ if home != "" && park.PlacementRegion(home) != home {
+ return false
+ }
+ src, sourceOK := r.Boxes[source]
+ dst, targetOK := r.Boxes[target]
+ if !sourceOK || !targetOK || source == target || src.State != "active" || dst.State != "active" || src.Enrollment == "" || dst.Enrollment == "" || src.Region == "" || dst.Region == "" {
+ return false
+ }
+ from, into := park.PlacementRegion(park.HomePlacementCode(home, src.Region)), park.PlacementRegion(park.HomePlacementCode(home, dst.Region))
+ return from != "" && into != "" && from != into && from == park.PlacementRegion(recRegion) && into == park.PlacementRegion(to)
+}
diff --git a/internal/hostops/plan13_conversion_test.go b/internal/hostops/plan13_conversion_test.go
new file mode 100644
index 000000000..5e3574339
--- /dev/null
+++ b/internal/hostops/plan13_conversion_test.go
@@ -0,0 +1,78 @@
+package hostops
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "testing"
+)
+
+func TestPlan13PreFlipHomeConversion(t *testing.T) {
+ for _, direction := range []string{"source-home", "target-home", "named"} {
+ for _, route := range []string{"grant", "held"} {
+ t.Run(direction+"/"+route, func(t *testing.T) {
+ w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {
+ if direction == "source-home" {
+ r.Region, r.Move.From = "", ""
+ c.Op.Intent["home_region"] = "ie"
+ }
+ if direction == "target-home" {
+ r.Move.To = ""
+ c.Op.Intent["to"] = park.HomeRegion
+ c.Op.Intent["home_region"] = "uk"
+ }
+ })
+ run := func() error {
+ if route == "held" {
+ return RecoverTerminatedMovingHost(t.Context(), w.productionData(t, w.raw), w.mh, w.authority, w.host, w.ref, w.now)
+ }
+ return w.control.RunRecovery(t.Context(), func(ctx context.Context) error {
+ return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { return g.Convert(ctx, w.ref) }, w.authority)
+ })
+ }
+ for i := 0; i < 3; i++ {
+ if err := run(); err != nil {
+ t.Fatalf("pass %d: %v", i, err)
+ }
+ }
+ c := w.claim(t).Claim
+ if c.Op.Kind != hostclaim.KindRelocating || c.Op.Intent["region"] != "ie" {
+ t.Fatalf("claim: %+v", c.Op)
+ }
+ r, err := provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ from, to := "ie", "uk"
+ if direction == "source-home" {
+ from = ""
+ }
+ if direction == "target-home" {
+ to = ""
+ }
+ if r.Region != from || r.Move.From != from || r.Move.To != to || r.Move.Relocation.Region != from {
+ t.Fatalf("rewritten record: %+v", r)
+ }
+ tag := w.claim(t).ETag
+ rtag := w.recordTag(t)
+ if err := run(); err != nil {
+ t.Fatal(err)
+ }
+ if tag != w.claim(t).ETag || rtag != w.recordTag(t) {
+ t.Fatal("replay wrote")
+ }
+ })
+ }
+ }
+}
+func TestPlan13PreFlipUnboundHomeRefuses(t *testing.T) {
+ w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { r.Region, r.Move.From = "", "" })
+ tag := w.recordTag(t)
+ if err := RecoverTerminatedMovingHost(t.Context(), w.productionData(t, w.raw), w.mh, w.authority, w.host, w.ref, w.now); err == nil {
+ t.Fatal("unbound home accepted")
+ }
+ if tag != w.recordTag(t) {
+ t.Fatal("refusal wrote")
+ }
+}
diff --git a/internal/hostops/plan13_enrollment_test.go b/internal/hostops/plan13_enrollment_test.go
new file mode 100644
index 000000000..43d9fd2f6
--- /dev/null
+++ b/internal/hostops/plan13_enrollment_test.go
@@ -0,0 +1,50 @@
+package hostops
+
+import (
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "testing"
+)
+
+func TestPlan13BeginClaimMoveHomeAndNamed(t *testing.T) {
+ for _, tc := range []struct{ name, home, from, to, target, want string }{
+ {"out-home", "ie", "", "uk", "uk-box", "uk"},
+ {"into-home", "uk", "ie", "", "uk-box", park.HomeRegion},
+ {"named", "", "ie", "uk", "uk-box", "uk"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newMoveStartFixture(t, nil)
+ rec, etag, err := provision.GetRecordETag(t.Context(), f.raw, "bab", "app", f.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.Region = tc.from
+ raw, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.raw.Store.Put(t.Context(), f.raw.recordKey, raw, etag); err != nil {
+ t.Fatal(err)
+ }
+ o := f.read(t)
+ // This is an existing claim, including its named touched region.
+ h, err := f.authority.RegisterMove(t.Context(), o, "canonical", moveStartID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := BeginClaimMove(t.Context(), f.raw, f.authority.Directory, h, o, moveStartID, "operator", tc.target, tc.to, f.now, tc.home)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Claim.Op.Intent["to"] != tc.want || got.Claim.Touched.Regions[0] != "ie" {
+ t.Fatalf("%+v", got.Claim)
+ }
+ if got.Claim.Op.Intent["home_region"] != tc.home {
+ t.Fatal("lost home binding")
+ }
+ _ = hostclaim.KindMoving
+ })
+ }
+}
diff --git a/internal/hostops/plan13_fence_test.go b/internal/hostops/plan13_fence_test.go
new file mode 100644
index 000000000..1ac1e2e2b
--- /dev/null
+++ b/internal/hostops/plan13_fence_test.go
@@ -0,0 +1,73 @@
+package hostops
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "strings"
+ "testing"
+)
+
+func TestPlan13FenceRecoveryHomeAndNamed(t *testing.T) {
+ for _, which := range []string{"source-home", "target-home", "named"} {
+ t.Run(which, func(t *testing.T) {
+ w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {
+ c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindLanding, hostclaim.PhaseProbing, "source"
+ c.Op.Intent["source"], c.Op.Intent["target"], c.Op.Intent["to"], c.Op.Intent["target_enrollment"] = "target", "source", "ie", strings.Repeat("a", 32)
+ c.Op.Participants = []string{"target", "source"}
+ c.Obligations = nil
+ c.Op.Journal = map[string]string{hostclaim.JournalSourceStamp: "1", hostclaim.JournalSrcGen: "src"}
+ r.Region = "uk"
+ r.Move.From, r.Move.To, r.Move.State = "uk", "ie", provision.MoveRollingBack
+ if which == "source-home" {
+ r.Region = ""
+ r.Move.From = ""
+ c.Op.Intent["home_region"] = "uk"
+ }
+ if which == "target-home" {
+ r.Move.To = ""
+ c.Op.Intent["to"] = park.HomeRegion
+ c.Op.Intent["home_region"] = "ie"
+ }
+ at := r.CreatedAt
+ key := "control/box-fences/" + strings.Repeat("a", 32) + ".json"
+ r.Move.Rollback = &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", Reason: provision.RollbackTargetFenced, Evidence: key, RequestedAt: &at, TargetPhase: provision.RbTargetLost, LossRef: key, LostAt: &at, SourcePhase: provision.RbSourceWaiting}
+ })
+ run := func() error {
+ return w.control.RunRecovery(t.Context(), func(ctx context.Context) error {
+ return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { return g.ReleaseFencedTarget(ctx, w.ref) }, w.authority)
+ })
+ }
+ if err := run(); err != nil {
+ t.Fatal(err)
+ }
+ c := w.claim(t).Claim
+ if c.Op.Phase != hostclaim.PhaseReleased {
+ t.Fatal(c.Op)
+ }
+ want := "ie"
+ if which == "target-home" {
+ want = park.HomeRegion
+ }
+ for _, ob := range c.Obligations {
+ if ob.Region != want {
+ t.Fatalf("%+v", ob)
+ }
+ }
+ if err := run(); err != nil {
+ t.Fatal("record completion", err)
+ }
+ rec, err := provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if which == "source-home" && (rec.Region != "" || rec.Move.From != "") {
+ t.Fatal("source placement rewritten")
+ }
+ if which == "target-home" && rec.Move.To != "" {
+ t.Fatal("target placement rewritten")
+ }
+ })
+ }
+}
diff --git a/internal/hostops/plan13_loss_test.go b/internal/hostops/plan13_loss_test.go
new file mode 100644
index 000000000..de3da639b
--- /dev/null
+++ b/internal/hostops/plan13_loss_test.go
@@ -0,0 +1,77 @@
+package hostops
+
+import (
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "testing"
+)
+
+func TestPlan13LossSourceRegionIsEngineName(t *testing.T) {
+ for _, tc := range []struct{ from, to, want string }{
+ {"", "uk", park.HomeRegion}, {"ie", "uk", "ie"}, {"uk", "", "uk"},
+ } {
+ t.Run(tc.from+"-"+tc.to, func(t *testing.T) {
+ in := moveLossIntent{Move: lossOp, Incarnation: "inc", Target: "target", TargetRegion: park.PlacementRegion(tc.to)}
+ rec := provision.Record{Region: tc.to, Move: &provision.MoveState{ID: lossOp, Incarnation: "inc", From: tc.from, To: tc.to, State: provision.MoveLanding}}
+ op := hostclaim.Op{ID: lossOp, Kind: hostclaim.KindLanding}
+ if err := recordLossIntent(&in, rec, "etag", 1, op, "claim-etag", nil); err != nil {
+ t.Fatal(err)
+ }
+ if in.SourceRegion != tc.want || rec.Move.From != tc.from || rec.Region != tc.to {
+ t.Fatalf("%+v %+v", in, rec)
+ }
+ raw, err := json.Marshal(in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var persisted map[string]any
+ if err = json.Unmarshal(raw, &persisted); err != nil {
+ t.Fatal(err)
+ }
+ if persisted["source_region"] != tc.want {
+ t.Fatal(persisted)
+ }
+ })
+ }
+}
+func TestPlan13HomeLossDeclarationKeepsPlacement(t *testing.T) {
+ w := newLossWorld(t, "landing", func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {
+ r.Move.From = ""
+ c.Op.Intent["home_region"] = "ie"
+ })
+ inspect := w.inspect(t, "uk-label")
+ after, err := w.declare(w.submission(inspect))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec, _ := w.record(t)
+ if rec.Region != "" || rec.Move.From != "" {
+ t.Fatalf("record leaked engine token: %+v", rec)
+ }
+ loss, err := hostclaim.ReadLossRecord(t.Context(), w.raw, w.pub, inspect.Details.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in, _, err := lossIntentOf(loss.Record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if in.SourceRegion != park.HomeRegion || after.Claim.Op.Kind != hostclaim.KindRestoring {
+ t.Fatalf("%+v", in)
+ }
+}
+
+func TestPlan13LossRecoveryNamedHomeAdmission(t *testing.T) {
+ for _, home := range []string{"ie", "", "uk"} {
+ in := moveLossIntent{Kind: moveLossIntentKind, Move: "move", Incarnation: "inc", Rollback: "rb_abcdefghijklmnopqrstuvwxyz", Target: "target", Source: "source", TargetRegion: "uk", SourceRegion: park.HomeRegion, RecordKey: "record", RecordETag: "etag", RecordState: moveLossRecordLanding}
+ raw, _ := in.encode()
+ loss := hostclaim.LossRecord{ID: "loss_1", OpID: "move", Box: "target", HolderIncarnation: "inc", ConfirmedConsequences: hostclaim.LossConsequences{RecoveryIntent: raw}}
+ c := hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "inc", Key: "record", Stamp: 7}, Op: hostclaim.Op{Kind: hostclaim.KindRestoring, ID: "move", Phase: hostclaim.PhaseProbing, Exec: "source", Intent: map[string]string{"source": "source", "target": "target", "to": "uk", "rollback": in.Rollback, "home_region": home}, Journal: map[string]string{hostclaim.JournalSourceStamp: "7", "lost_target_released": "loss_1"}}}
+ err := MoveLossRecoveryOwner().ValidateLossRecovery(c, loss, hostclaim.Admission{Box: "source", Region: "ie", Mode: "cold"})
+ if (err == nil) != (home == "ie") {
+ t.Fatal(home, err)
+ }
+ }
+}
diff --git a/internal/hostops/plan13_remote_test.go b/internal/hostops/plan13_remote_test.go
new file mode 100644
index 000000000..c80bd3996
--- /dev/null
+++ b/internal/hostops/plan13_remote_test.go
@@ -0,0 +1,37 @@
+package hostops
+
+import (
+ "context"
+ "errors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/park"
+ "testing"
+)
+
+func TestPlan13RemoteAdapterUsesBoundHome(t *testing.T) {
+ f := newMoveStartFixture(t, nil)
+ o := f.read(t)
+ _, err := hostclaim.Update(t.Context(), f.raw, o, func(c *hostclaim.Claim) error {
+ c.Obligations = []hostclaim.Obligation{{ID: "chore", Op: "old", Kind: "park-replica", Region: "@home", Owner: "region:@home", Retirement: "ret"}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ sentinel := errors.New("scope reached")
+ for _, home := range []string{"ie", ""} {
+ e := park.Engine{Claims: f.raw, Region: "ie", HomeRegion: home, Leases: plan13NoLeases{}, Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }, View: func(context.Context, hostclaim.Observed) (park.View, error) { return park.View{}, nil }}
+ err := RunOwnedRemote(t.Context(), &e, park.Work{Host: f.host, ID: "chore", Op: "old"}, func(context.Context, hostclaim.Observed, hostclaim.Obligation) (hostclaim.ParkScope, error) {
+ return hostclaim.ParkScope{}, sentinel
+ })
+ if errors.Is(err, sentinel) != (home != "") {
+ t.Fatal(home, err)
+ }
+ }
+}
+
+type plan13NoLeases struct{}
+
+func (plan13NoLeases) Acquire(context.Context, string, string) (park.HeldLease, error) {
+ return nil, errors.New("unexpected lease")
+}
diff --git a/internal/hostops/production_create.go b/internal/hostops/production_create.go
index 39631761c..01d09bd6e 100644
--- a/internal/hostops/production_create.go
+++ b/internal/hostops/production_create.go
@@ -16,6 +16,7 @@ import (
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/provision"
)
@@ -127,7 +128,7 @@ func (a ProductionAuthority) selectExecutor(ctx context.Context, r Record) (stri
if b.Namespace == a.Namespace && b.Pool == pool && b.State == "active" {
servingScope++
}
- if b.Namespace != a.Namespace || b.Region != r.Holder.Region || b.Pool != pool || b.State != "active" {
+ if b.Namespace != a.Namespace || !park.SameRegion(a.HomeRegion, b.Region, r.Holder.Region) || b.Pool != pool || b.State != "active" {
continue
}
fenced, err := a.Directory.TerminationFence(ctx, roster, ref, a.Now())
@@ -201,7 +202,11 @@ func (a ProductionAuthority) StartCreate(ctx context.Context, before hostclaim.O
if err != nil {
return hostclaim.Observed{}, err
}
- scope := hostactors.Scope{Namespace: a.Namespace, Regions: []string{r.Holder.Region}, Pools: []string{pool}, Participants: []string{box}, ConsoleID: a.ConsoleID}
+ region := r.Holder.Region
+ if region == park.HomeRegion {
+ region = a.HomeRegion
+ }
+ scope := hostactors.Scope{Namespace: a.Namespace, Regions: []string{region}, Pools: []string{pool}, Participants: []string{box}, ConsoleID: a.ConsoleID}
// Final admission check is after census/reservation, immediately before
// the one CAS against the observation saved before registration.
if err = a.Create(ctx, before, r, id); err != nil {
diff --git a/internal/hostops/remote.go b/internal/hostops/remote.go
index 09c99302d..67bec086f 100644
--- a/internal/hostops/remote.go
+++ b/internal/hostops/remote.go
@@ -26,7 +26,7 @@ func RunOwnedRemote(ctx context.Context, e *park.Engine, w park.Work, scope Park
if ob.ID != w.ID || ob.Op != w.Op {
continue
}
- if ob.Region != e.Region || ob.Owner != "region:"+e.Region || (ob.Kind != "park-replica" && ob.Kind != "park-generation") {
+ if !e.OwnsRegion(ob.Region) || ob.Owner != "region:"+ob.Region || (ob.Kind != "park-replica" && ob.Kind != "park-generation") {
return ErrIdentity
}
if ob.Park == nil {
diff --git a/internal/park/home_view.go b/internal/park/home_view.go
new file mode 100644
index 000000000..599e662d7
--- /dev/null
+++ b/internal/park/home_view.go
@@ -0,0 +1,33 @@
+package park
+
+import "github.com/carlosframework/platform/internal/hostclaim"
+
+// HolderRegionView is for read-only generation selection. Never persist it
+// or use it to validate signed stop/loss evidence; those keep exact tuples.
+func HolderRegionView(h hostclaim.Holder, home, region string) hostclaim.Holder {
+ if home == "" || !SameRegion(home, region, HomeRegion) {
+ return h
+ }
+ h.Admissions = append([]hostclaim.AdmissionRecord(nil), h.Admissions...)
+ for i := range h.Admissions {
+ a := &h.Admissions[i]
+ if SameRegion(home, a.Region, region) {
+ a.Region = region
+ }
+ if SameRegion(home, a.PredecessorRegion, region) {
+ a.PredecessorRegion = region
+ }
+ }
+ return h
+}
+
+func HomeView(o hostclaim.Observed, home string) hostclaim.Observed {
+ if home == "" || o.Claim == nil || o.Claim.Holder == nil {
+ return o
+ }
+ c := *o.Claim
+ h := HolderRegionView(*c.Holder, home, HomeRegion)
+ c.Holder = &h
+ o.Claim = &c
+ return o
+}
diff --git a/internal/park/plan13_holder_test.go b/internal/park/plan13_holder_test.go
new file mode 100644
index 000000000..0530849e8
--- /dev/null
+++ b/internal/park/plan13_holder_test.go
@@ -0,0 +1,25 @@
+package park
+
+import (
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "testing"
+)
+
+func TestPlan13HolderProjectionPreservesEvidence(t *testing.T) {
+ h := hostclaim.Holder{Admissions: []hostclaim.AdmissionRecord{{Region: "ie", PredecessorRegion: HomeRegion}, {Region: "uk", PredecessorRegion: ""}}}
+ got := HolderRegionView(h, "ie", "ie")
+ if got.Admissions[0].PredecessorRegion != "ie" || got.Admissions[1].Region != "uk" || got.Admissions[1].PredecessorRegion != "" {
+ t.Fatal(got)
+ }
+ if h.Admissions[0].PredecessorRegion != HomeRegion {
+ t.Fatal("rewrote input")
+ }
+ got = HolderRegionView(h, "ie", HomeRegion)
+ if got.Admissions[0].Region != HomeRegion {
+ t.Fatal(got)
+ }
+ got = HolderRegionView(h, "", "uk")
+ if got.Admissions[0].Region != "ie" || got.Admissions[0].PredecessorRegion != HomeRegion {
+ t.Fatal("unbound alias")
+ }
+}
diff --git a/internal/park/plan13_observe_test.go b/internal/park/plan13_observe_test.go
new file mode 100644
index 000000000..8f7e7c283
--- /dev/null
+++ b/internal/park/plan13_observe_test.go
@@ -0,0 +1,97 @@
+package park
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "testing"
+)
+
+func TestPlan13ObserveRemoteBinding(t *testing.T) {
+ f := newFixture(t)
+ o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error { c.Op.Intent = map[string]string{"home_region": "ie"}; return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.e.Region = "uk"
+ f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) {
+ return View{Region: "uk", HomeRegion: "ie", ClaimETag: o.ETag}, nil
+ }
+ _, v, err := f.e.observe(t.Context(), f.w, f.held, f.ob)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v.HomeRegion != "ie" || f.e.OwnsRegion(HomeRegion) {
+ t.Fatal("history binding lost or ownership widened")
+ }
+ c := &hostclaim.Claim{Holder: &hostclaim.Holder{Location: f.ob.Park.Location, Generation: "new", Admissions: []hostclaim.AdmissionRecord{{Generation: "new", Predecessor: "old", Region: "ie", PredecessorRegion: HomeRegion, Mode: "cold"}}}, Op: hostclaim.Op{Kind: hostclaim.KindServing}}
+ v.GenerationOf = func(Ref) (string, bool) { return "old", true }
+ v.Region = HomeRegion
+ v.AuthoritativeRegion = HomeRegion
+ v.MarkerKnown = true
+ v.MarkerGeneration = "old"
+ live, err := LiveGenerations(c, v, c.Holder.Location)
+ if err != nil || !live["old"] || !live["new"] {
+ t.Fatal(live, err)
+ }
+}
+func TestPlan13CompletedHomeParkCoversNamedPredecessor(t *testing.T) {
+ f := newFixture(t)
+ f.e.HomeRegion = "ie"
+ f.e.Region = HomeRegion
+ o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error {
+ c.Obligations[0].Region = HomeRegion
+ c.Obligations[0].Owner = "region:" + HomeRegion
+ c.Holder.Generation = "next"
+ c.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "next", Predecessor: "old", Region: "uk", PredecessorRegion: "ie", Mode: "cold"}, {Generation: "foreign", Predecessor: "old", Region: "fr", PredecessorRegion: "fr", Mode: "cold"}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) {
+ return View{Region: HomeRegion, ClaimETag: o.ETag, AuthoritativeRegion: "uk", GenerationOf: func(Ref) (string, bool) { return "old", true }}, nil
+ }
+ f.b.Put("data", f.ob.Park.Location+"/g-old/0000/a", []byte("old"))
+ if err := f.e.Run(t.Context(), f.w); err != nil {
+ t.Fatal(err)
+ }
+ done, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(done.Claim.Obligations) != 0 || !done.Claim.Holder.Admissions[0].Covered || done.Claim.Holder.Admissions[1].Covered {
+ t.Fatalf("coverage: %+v", done.Claim.Holder.Admissions)
+ }
+}
+
+func TestPlan13SweepRejectsConflictingHome(t *testing.T) {
+ f := newFixture(t)
+ f.e.HomeRegion = "ie"
+ o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error { c.Op.Intent = map[string]string{"home_region": "uk"}; return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.e.Sweep(t.Context(), f.w.Host, "", f.e.Now(), f.ob.Park.RequestLimit); err == nil {
+ t.Fatal("conflicting home binding accepted")
+ }
+}
+
+func TestPlan13SweepAbsentClaimRefuses(t *testing.T) {
+ f := newFixture(t)
+ f.e.View = func(context.Context, hostclaim.Observed) (View, error) { return View{}, nil }
+ if _, err := f.e.Sweep(t.Context(), "absent.example", "", f.e.Now(), f.ob.Park.RequestLimit); err == nil {
+ t.Fatal("absent claim accepted")
+ }
+}
diff --git a/internal/park/plan13_region_test.go b/internal/park/plan13_region_test.go
new file mode 100644
index 000000000..8f962bb39
--- /dev/null
+++ b/internal/park/plan13_region_test.go
@@ -0,0 +1,51 @@
+package park
+
+import (
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "testing"
+ "time"
+)
+
+func TestPlan13RegionNames(t *testing.T) {
+ for _, tc := range []struct{ home, region, want string }{
+ {"ie", "ie", ""}, {"ie", "", ""}, {"ie", "uk", "uk"}, {"", "ie", "ie"},
+ } {
+ if got := HomePlacementCode(tc.home, tc.region); got != tc.want {
+ t.Fatalf("%+v: %q", tc, got)
+ }
+ }
+ for _, tc := range []struct {
+ home, a, b string
+ want bool
+ }{
+ {"ie", "ie", HomeRegion, true}, {"ie", HomeRegion, "ie", true}, {"ie", "uk", HomeRegion, false},
+ {"", "ie", HomeRegion, false}, {"ie", "", HomeRegion, false}, {"ie", "", "", false},
+ {"ie", "uk", "uk", true}, {"home", "home", HomeRegion, true},
+ } {
+ if got := SameRegion(tc.home, tc.a, tc.b); got != tc.want {
+ t.Fatalf("%+v: %v", tc, got)
+ }
+ }
+ q := map[string]time.Time{"ie": time.Unix(5, 0), HomeRegion: time.Unix(9, 0), "uk": time.Unix(12, 0)}
+ if !QuietUntil(q, "ie", "ie").Equal(q[HomeRegion]) || !QuietUntil(q, "ie", "uk").Equal(q["uk"]) {
+ t.Fatal("quiet aliases")
+ }
+}
+func TestPlan13OldAdmissionLiveSet(t *testing.T) {
+ c := &hostclaim.Claim{Holder: &hostclaim.Holder{Location: "instances/h/i", Generation: "new", Admissions: []hostclaim.AdmissionRecord{
+ {Generation: "new", Predecessor: "old", Region: "ie", PredecessorRegion: "ie", Mode: "cold"},
+ }}, Op: hostclaim.Op{Kind: hostclaim.KindServing}}
+ v := View{HomeRegion: "ie", Region: HomeRegion, AuthoritativeRegion: HomeRegion, MarkerKnown: true, MarkerGeneration: "old", GenerationOf: func(Ref) (string, bool) { return "old", true }}
+ got, err := LiveGenerations(c, v, c.Holder.Location)
+ if err != nil || !got["old"] || !got["new"] {
+ t.Fatalf("%v %v", got, err)
+ }
+ if c.Holder.Admissions[0].Region != "ie" {
+ t.Fatal("rewrote history")
+ }
+ v.Region = "uk"
+ got, err = LiveGenerations(c, v, c.Holder.Location)
+ if err != nil || len(got) != 0 {
+ t.Fatalf("foreign: %v %v", got, err)
+ }
+}
diff --git a/internal/park/region.go b/internal/park/region.go
index 8b680c3e5..2f62067ea 100644
--- a/internal/park/region.go
+++ b/internal/park/region.go
@@ -1,6 +1,6 @@
package park
-import "regexp"
+import "github.com/carlosframework/platform/internal/regionname"
// HomeRegion is the engine region of the home placement (home-region parks,
// part 1, Paul 2026-10-11). The home's placement code is "", and "" already
@@ -17,22 +17,10 @@ import "regexp"
// staging lease (control/staging-leases/@home/<host>, keyed in the host's
// wiring). It never reaches an archive path: the archive is
// parked/<incarnation>/<host>/<retirement>/.
-const HomeRegion = "@home"
-
-// placementCode is a named placement code: provision.ValidRegion without the
-// empty code. TestHomeRegionIsReserved holds the two together.
-var placementCode = regexp.MustCompile(`^[a-z0-9-]{1,16}$`)
+const HomeRegion = regionname.HomeRegion
// PlacementRegion is the engine region of a placement code: HomeRegion for
// the home's "", a named code unchanged, and "" (unset, which every engine
// entry refuses) for anything that is not a placement code, HomeRegion
// itself included.
-func PlacementRegion(code string) string {
- switch {
- case code == "":
- return HomeRegion
- case placementCode.MatchString(code):
- return code
- }
- return ""
-}
+func PlacementRegion(code string) string { return regionname.PlacementRegion(code) }
diff --git a/internal/park/region_names.go b/internal/park/region_names.go
new file mode 100644
index 000000000..3faa5ee25
--- /dev/null
+++ b/internal/park/region_names.go
@@ -0,0 +1,62 @@
+package park
+
+import (
+ "github.com/carlosframework/platform/internal/regionname"
+ "time"
+)
+
+// HomePlacementCode is the console door's home-name rule. It consumes a
+// roster/API name, not an engine region; @home is never a placement code.
+func HomePlacementCode(home, region string) string { return regionname.HomePlacementCode(home, region) }
+
+// SameRegion compares nonempty engine identities under the verified home binding.
+func SameRegion(home, a, b string) bool { return regionname.SameRegion(home, a, b) }
+
+func QuietUntil(bounds map[string]time.Time, home, region string) time.Time {
+ var latest time.Time
+ for r, until := range bounds {
+ if SameRegion(home, r, region) && latest.Before(until) {
+ latest = until
+ }
+ }
+ return latest
+}
+
+func (e Engine) OwnsRegion(region string) bool {
+ return SameRegion(e.HomeRegion, e.Region, region)
+}
+
+func (v View) sameRegion(a, b string) bool { return SameRegion(v.HomeRegion, a, b) }
+
+// PlacementMatches compares a record code with a nonempty engine identity.
+func PlacementMatches(home, code, engine string) bool {
+ return SameRegion(home, PlacementRegion(code), engine)
+}
+
+// BoundHome joins the verified local binding and the admitted operation's
+// binding for history reads. It never changes an engine's ownership.
+func BoundHome(local, operation string) (string, error) {
+ for _, home := range []string{local, operation} {
+ if home != "" && PlacementRegion(home) != home {
+ return "", ErrUnsafe
+ }
+ }
+ if local != "" && operation != "" && local != operation {
+ return "", ErrUnsafe
+ }
+ if local != "" {
+ return local, nil
+ }
+ return operation, nil
+}
+
+// EnginePlacement projects a verified engine identity into record vocabulary.
+func EnginePlacement(home, engine string) (string, bool) {
+ if engine == "" {
+ return "", false
+ }
+ if SameRegion(home, engine, HomeRegion) {
+ return "", true
+ }
+ return engine, PlacementRegion(engine) == engine
+}
diff --git a/internal/park/remote.go b/internal/park/remote.go
index 4d033959c..4a63164e7 100644
--- a/internal/park/remote.go
+++ b/internal/park/remote.go
@@ -54,6 +54,7 @@ type Leases interface {
}
type Engine struct {
+ HomeRegion string
Store lease.VersionedStore
Claims release.Store
Bucket, Region string
@@ -325,9 +326,14 @@ func (e Engine) observe(ctx context.Context, w Work, held HeldLease, expected ho
if err != nil {
return obs, v, err
}
- if v.ClaimETag != obs.ETag || v.Region != e.Region {
+ if v.ClaimETag != obs.ETag || !e.OwnsRegion(v.Region) {
return obs, v, ErrChanged
}
+ home, err := BoundHome(e.HomeRegion, obs.Claim.Op.Intent["home_region"])
+ if err != nil {
+ return obs, v, err
+ }
+ v.HomeRegion = home
return obs, v, nil
}
func (e Engine) selected(ctx context.Context, w Work, o hostclaim.Obligation, held HeldLease, vs []lease.ObjectVersion) (hostclaim.Observed, View, []lease.ObjectVersion, error) {
@@ -348,7 +354,7 @@ func (e Engine) selected(ctx context.Context, w Work, o hostclaim.Obligation, he
if isLock(w.Host, o.Park.Location, x.Key) {
return obs, v, nil, ErrUnsafe
}
- if obs.Claim.Holder != nil && obs.Claim.Op.Kind != hostclaim.KindParking && o.Park.Location == obs.Claim.Holder.Location && v.Region == v.AuthoritativeRegion {
+ if obs.Claim.Holder != nil && obs.Claim.Op.Kind != hostclaim.KindParking && o.Park.Location == obs.Claim.Holder.Location && v.sameRegion(v.Region, v.AuthoritativeRegion) {
if x.Key == v.Marker.Key {
if o.Kind == "park-generation" {
continue
@@ -414,7 +420,7 @@ func (e Engine) run(ctx context.Context, w Work, own HeldLease) error {
if err := validateScope(w.Host, o); err != nil {
return err
}
- if o.Region != e.Region {
+ if !e.OwnsRegion(o.Region) {
return ErrUnsafe
}
var held HeldLease
@@ -550,7 +556,17 @@ func (e Engine) run(ctx context.Context, w Work, own HeldLease) error {
if c.Holder == nil {
return nil
}
- return hostclaim.CoverParkedPredecessors(obs.Claim.Op.ID, obs.Claim.Op.Phase, o.Region, generations)(c)
+ if err := hostclaim.CoverParkedPredecessors(obs.Claim.Op.ID, obs.Claim.Op.Phase, o.Region, generations)(c); err != nil {
+ return err
+ }
+ if e.HomeRegion != "" && e.OwnsRegion(HomeRegion) {
+ other := HomeRegion
+ if o.Region == HomeRegion {
+ other = e.HomeRegion
+ }
+ return hostclaim.CoverParkedPredecessors(obs.Claim.Op.ID, obs.Claim.Op.Phase, other, generations)(c)
+ }
+ return nil
}
if len(vs) > 0 {
next := e.NewID()
diff --git a/internal/park/sweep.go b/internal/park/sweep.go
index 6ab70bf84..a772760ff 100644
--- a/internal/park/sweep.go
+++ b/internal/park/sweep.go
@@ -18,6 +18,7 @@ import (
// Pinned includes the exact noncurrent versions needed by the installed root.
// MarkerKnown means its read succeeded; an absent marker may denote @root.
type View struct {
+ HomeRegion string
// ArchivedIncarnations labels proven retired locations independently of
// the moves-sweep proof's incarnation. Missing entries never invent labels.
ArchivedIncarnations map[string]string
@@ -51,7 +52,7 @@ func protected(c *hostclaim.Claim, v View, location string) (map[string]bool, er
if v.AuthoritativeRegion == "" {
return nil, ErrUnsafe
}
- if v.Region != v.AuthoritativeRegion {
+ if !v.sameRegion(v.Region, v.AuthoritativeRegion) {
return live, nil
}
if !v.MarkerKnown || v.GenerationOf == nil {
@@ -82,7 +83,7 @@ func protected(c *hostclaim.Claim, v View, location string) (map[string]bool, er
want, region := current, v.AuthoritativeRegion
for i := len(h.Admissions) - 1; i >= 0; i-- {
a := h.Admissions[i]
- if generation(a.Generation) != want || a.Region != region {
+ if generation(a.Generation) != want || !v.sameRegion(a.Region, region) {
continue
}
if a.Mode != "cold" {
@@ -94,14 +95,14 @@ func protected(c *hostclaim.Claim, v View, location string) (map[string]bool, er
}
want = pred
region = a.PredecessorRegion
- if region == "" || region == v.AuthoritativeRegion {
+ if region == "" || v.sameRegion(region, v.AuthoritativeRegion) {
if a.Covered {
return nil, ErrUnsafe
}
live[want] = true
}
if want == marker {
- if region != "" && region != v.AuthoritativeRegion {
+ if region != "" && !v.sameRegion(region, v.AuthoritativeRegion) {
return nil, ErrUnsafe
}
return live, nil
@@ -141,7 +142,7 @@ func Decide(obs hostclaim.Observed, v View, versions []lease.ObjectVersion, lega
return d, nil
}
for _, o := range c.Obligations {
- if o.Region == v.Region {
+ if v.sameRegion(o.Region, v.Region) {
return d, nil
}
}
@@ -161,7 +162,7 @@ func Decide(obs hostclaim.Observed, v View, versions []lease.ObjectVersion, lega
if isLock(c.Host, loc, x.Key) {
continue
}
- if refusal := v.RefusedLocations[loc]; refusal != nil && !(c.Holder != nil && loc == c.Holder.Location && v.Region == v.AuthoritativeRegion) {
+ if refusal := v.RefusedLocations[loc]; refusal != nil && !(c.Holder != nil && loc == c.Holder.Location && v.sameRegion(v.Region, v.AuthoritativeRegion)) {
if !refused[loc] {
d.Alerts = append(d.Alerts, Alert{loc, refusal.Error()})
refused[loc] = true
@@ -177,7 +178,7 @@ func Decide(obs hostclaim.Observed, v View, versions []lease.ObjectVersion, lega
candidate.Incarnation = v.ArchivedIncarnations[loc]
}
}
- if c.Holder != nil && loc == c.Holder.Location && v.Region == v.AuthoritativeRegion {
+ if c.Holder != nil && loc == c.Holder.Location && v.sameRegion(v.Region, v.AuthoritativeRegion) {
if liveErr != nil {
continue
}
@@ -328,10 +329,18 @@ func (e Engine) sweep(ctx context.Context, expected hostclaim.Observed, expected
if bound && (obs.ETag != expected.ETag || !reflect.DeepEqual(obs.Claim, expected.Claim)) {
return empty, ErrChanged
}
+ if obs.Absent || obs.Claim == nil {
+ return empty, ErrUnsafe
+ }
v, err := e.View(ctx, obs)
if err != nil {
return empty, err
}
+ home, err := BoundHome(e.HomeRegion, obs.Claim.Op.Intent["home_region"])
+ if err != nil {
+ return empty, err
+ }
+ v.HomeRegion = home
listingStarted := e.Now()
vs, err := e.Discover(ctx, host)
listedAt := e.Now()
diff --git a/internal/provision/move_relocation.go b/internal/provision/move_relocation.go
index 707c8351c..7da7131f5 100644
--- a/internal/provision/move_relocation.go
+++ b/internal/provision/move_relocation.go
@@ -44,7 +44,7 @@ func MoveRelocatingDecision(g MoveGuard, box, enrollment, region, fence string,
return err
}
var ref struct{ Enrollment, Key, SHA256 string }
- if len(movingIdentity) != 64 || len(holderIdentity) != 64 || claimGen == 0 || claimETag == "" || box == "" || enrollment == "" || region == "" || now.IsZero() ||
+ if len(movingIdentity) != 64 || len(holderIdentity) != 64 || claimGen == 0 || claimETag == "" || box == "" || enrollment == "" || !ValidRegion(region) || now.IsZero() ||
json.Unmarshal([]byte(fence), &ref) != nil || ref.Enrollment != enrollment || ref.Key != "control/box-fences/"+enrollment+".json" || len(ref.SHA256) != 64 ||
rec.Region != region || rec.Move.From != region {
return fmt.Errorf("%w: the relocation decision does not match this move", ErrMoveConflict)
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 91cdd9cd5..6cbe9dc46 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -474,13 +474,13 @@ func Reviewed(ctx context.Context, s Store, k string) error {
var plan9B8CensusInventory = []censusEntry{
{"internal/hostbox/parkleases.go", "ReplicaParkLeaseSet", "key-site", "lit:instances", "0ca8d9b743402fda9d15cc215464423b35e21c1c2b47f91294d76188b7d43fb6"},
{"internal/host/claimretirementcensus.go", "ClaimRetirementCensus", "key-site", "lit:instances", "abc0bd8c5f5d328abfac8821f42f8f23954723ed6506747f33b0197824e2611f"},
- {"internal/host/claimproduction.go", "productionClaimRegional.View", "key-site", "lit:instances", "61ff9ff47054b6a96cb24467c7aaf41c112b7c5129987af5ce27e0b7f862348e"},
- {"internal/hostops/move_loss_admission.go", "moveLossOwner.ValidateLossRecovery", "key-site", "key:RecordKey", "ea2aa35a069ffdc846ecb208c33940245fc865795188818e86ea7a93135dd6a9"},
+ {"internal/host/claimproduction.go", "productionClaimRegional.View", "key-site", "lit:instances", "85fa91931c06b2fb1f8e53512a219190fcb146b9919b1e625b8562c299209840"},
+ {"internal/hostops/move_loss_admission.go", "moveLossOwner.ValidateLossRecovery", "key-site", "key:RecordKey", "1ad4af4595c764fc5051e50c2b187472cb20ce73c3b3e8245b8527228f326f5f"},
{"internal/host/claimretirementcensus.go", "claimArchivedLocations", "key-site", "lit:instances", "9ba8ea4ecb5ad63621c1322467b9a80f8f5703896ba1fc3f7cb1addd56418950"},
{"internal/host/claimretirementcensus.go", "claimLegacyIncarnation", "key-site", "lit:instances", "31a14ea105c67e00a24f06b9500a221ee5bfc47402b9c33cecfde807e51db530"},
- {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.ReleaseFencedTarget", "record-writer", "key:WriteRecord,key:writeRecord", "13e4506d5825781237b85eb28db5641e25278f4ba9ea2e5475b6adca03d3ee12"},
+ {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.ReleaseFencedTarget", "record-writer", "key:WriteRecord,key:writeRecord", "f164e9f612ec87ddf02e6eaafd466549ebc2623b33aa1b8aae5122e1d3120a83"},
{"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.StampLostMoveTargetParked", "record-writer", "key:WriteRecord", "911ab90f0786d646b494a62c2d061852c35273dc538408636ebf2ec613fa2198"},
- {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Convert", "record-writer", "key:WriteRecord", "794963a24a0a50d980505f83ac912973a10267b21f74bd41c56584ab9b0b25a1"},
+ {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Convert", "record-writer", "key:WriteRecord", "bbb8c3aea28bcd7b3c45e5c4df3951509bb4e21158cdb67cea91a4486f3381da"},
// The recovery pass's move log writer: one create-only entry of a move's
// log under its report prefix, for a host whose claim still names the
// app; never a record (Plan 6 Task 3 fix round 3, ruling 1).
@@ -612,11 +612,11 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/console/apiinstancemove.go", "Server.registerMovesPhase1Routes", "key-site", "lit:instances", "5af589d288ba22856f631481c40cc001aac342ea74cee6ee0464ec77d0320bbc"},
{"internal/console/apiinstances.go", "Server.instanceManifest", "key-site", "lit:instances", "e6160ffe84abda9163527ccb33e2ffff2109b35908adb2f3a754534325658fdf"},
{"internal/console/apiinstances.go", "Server.handleAPIInstanceList", "key-site", "lit:instances", "73a40e68abf796870589df197fc72697bfe201fe0b3ca6c14829f1fd5761f758"},
- {"internal/console/apiinstances.go", "Server.handleAPIInstanceCreate", "record-writer", "key:CreateRecord", "20f862446f119f25d7d72eab6edd1b7cfb1528afd5b4c352e0cb8442f5080b13"},
+ {"internal/console/apiinstances.go", "Server.handleAPIInstanceCreate", "record-writer", "key:CreateRecord", "9b185998d430710c3ba14c4ace8c19a487d15b7f391b222c6e6479168a0c7603"},
{"internal/console/apiinstances.go", "Server.handleAPIInstanceUpdate", "record-writer", "key:UpdateRecord", "4b792c410399631eb3b38c78829df85dda045651bbc32df769767cf52f015b6d"},
{"internal/console/apiscan.go", "Server.writeScanRecord", "nonrecord", "sel:Put", "c3c092f4b7be9bf6bf09d0fe1e2c9fe52892ee97e57f6c7ab964b85fe36fb5c6"},
{"internal/console/apisidecars.go", "Server.handleAPISidecarList", "key-site", "lit:sidecars", "6a5e461256c985ea4924bf7ba7c0793ae601a59efe8ef570913138ef8b47441a"},
- {"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "a7d5eb534b540499b9355b455966e733d61c904be5cc5a76a168833df59de1c3"},
+ {"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "f3b049692e3289d9bb3c77e9e6a55dbdfac78bd9ff90af893b6cc753e76a0164"},
{"internal/console/apistatusinstances.go", "Server.handleAPIStatusInstances", "key-site", "lit:instances", "2b4b9a2bea5f2fc5727869a5dcb8ae96c020e72571621f19898b195ca3b61aaa"},
{"internal/console/apisystemupdate.go", "Server.mirrorSystemArtifact", "nonrecord", "sel:Put", "76a8dcb558e3723ad5774788da37c22234c229dc3257ded5a6c7b04746c69c2f"},
{"internal/console/apiupdates.go", "Server.handleAPIUpdatesPut", "record-writer", "key:UpdateRecord", "53044a537795302e7ffd8fa72799b7a77e7bed277f00ad17ed1fd465b7f7cfaa"},
@@ -705,18 +705,18 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/host/movesmeasure.go", "measureS3", "nonrecord", "sel:Put", "3ae9fc093b453e19a5b19be0929533b30a0e2a3e998ba8dc766f41287db2caca"},
{"internal/host/movespass.go", "legacyMoveRound", "record-writer", "key:SetRecordMove", "1e13a3ea099d45318d3b64b067448c87dc4a136cd909348f526e4583ac00150a"},
{"internal/host/movesphase1.go", "writeMoveLedger", "nonrecord", "sel:Put", "597a70ac06ceb4e9eb52bb9f61b28a4937e703fa4a0916f64bd95d9594f0556e"},
- {"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "c5bc2e6cb57b819ae1da485d37093c9a9ed3cdc9e9e16e8a8e312fd5ad2e2fe3"},
+ {"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "810e5a9abac7dea9220f83eae924cea99b0eca0f002764c88ec35de26a356dff"},
{"internal/host/movesphase1.go", "movesWorker.refreshCrossRate", "nonrecord", "sel:CopyVersioned", "7e03e680bd61be26dddcf168cacd2427bb535571dfd326c4a3130ebe65ea1968"},
{"internal/host/movesphase1.go", "movesWorker.writeSourceReport", "nonrecord", "sel:PutReplace", "370407cca2c1b91f6e5e7f135238f04c0dce9d7986e6e1ee9bebbf88cad0b9e0"},
{"internal/host/movesphase1.go", "movesWorker.decideReady", "record-writer", "key:SetRecordMove", "d29c22eafacfb894b96cd0c40ec447b6ab02f46ea087201685627861f992085d"},
- {"internal/host/movesreport.go", "movesWorker.targetReport", "nonrecord", "sel:PutReplace", "fce9296957f2a8f91840ddeb5c3553a0a248bc11f7f98a08d0ae7d00ae1c2eb4"},
+ {"internal/host/movesreport.go", "movesWorker.targetReport", "nonrecord", "sel:PutReplace", "7a8eaabb9c56b2352969ea49124481a1d3687376b1f9ab6b9ce9ae49d0573843"},
{"internal/host/movesstore.go", "laneProbe", "nonrecord", "sel:PutIfAbsent", "f4a17a1143e7370b9039b2e66e736606015c46619bab92c553ce7b1ad6b7918b"},
{"internal/host/snapshot_loop.go", "registrySnapshotPass", "nonrecord", "sel:Put", "6b01b46b361c3a2d1d432659a0d234c01975849b9a8ac5571858fdd78d376849"},
{"internal/host/snapshot_loop.go", "putReplacing", "nonrecord", "sel:Put", "11e8639effce142eab707b67cbdd425d0d7cea510ddf95d292bdba8f79c9b510"},
{"internal/hostactors/censuskey.go", "Directory.BindCensusKey", "nonrecord", "sel:Put", "7d9d673435e8c8a6aac3105e3a754bde2adcbc5d603945dd599cef703f334a5b"},
{"internal/hostactors/invitation.go", "Directory.Invite", "nonrecord", "sel:Put", "356618a75a23d0157d8e0402e20187a42956f67a4332d698bb22bf05133ac489"},
{"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "99d6830102942188370207b21a87245056b1a56af1f95344b0337091e720823f"},
- {"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "5d554df4562248c6e6467f5009910dc4e5502bc09679baa55f0d2cd1bdc06037"},
+ {"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "477bcb896b5ae8ec80af592d68eceb7992ad8a23cd6cff6043ad03b3ef2a8085"},
{"internal/hostbox/requeststore.go", "RequestStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "3a50049ce7ca7f2cbc770bef46218944cdc7000205a86c20e4932f0b510da28f"},
{"internal/hostbox/requeststore.go", "RequestStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "3666cbe5d2b91c1c652ffb4459c10a374fbd92e325e4e5c574fa3525d0ce2ffe"},
{"internal/hostclaim/adopt.go", "Adopt", "key-site", "lit:instances", "24c06b99cce6c638acb19826985cfadd3c736fd0ace7289bcf7ac3dfe784f05a"},
@@ -746,7 +746,7 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/hostops/lifecycle_grant.go", "authorizeSignedControl", "reference", "str:Put", "cf012aebc7b4e0ea8dfef48ea3817a7466f1dabacd7cf1e5be07b2302bf183ba"},
{"internal/hostops/lifecycle_record.go", "type lifecycleRecordKey", "key-site", "key:lifecycleRecordKey", "09ab1fd62d0be032bd735d1f60488cdb19128f8573b00bbd7a70f582208da75d"},
{"internal/hostops/lifecycle_record.go", "setLifecycleRecordMove", "record-writer", "key:SetRecordMove,key:lifecycleRecordKey", "37691dfd5158c644b12e28383243f92a7868e075eade79be79e7149a18f8d420"},
- {"internal/hostops/lifecycle_record.go", "conditionalRecordStore.putLifecycle", "record-writer", "key:putSequencedRecord", "03ac054bfed5f28429eaaefca22ab940ad71bace1897064e1c770cec361bcc03"},
+ {"internal/hostops/lifecycle_record.go", "conditionalRecordStore.putLifecycle", "record-writer", "key:putSequencedRecord", "75f03db96d509d340fc3edf62a5ff60957ab75d08d6586fea6e342c5a9c3eb8d"},
{"internal/hostops/mail_gate.go", "ControlWriter.AcquireMailGate", "nonrecord", "sel:Put", "f37147b214f176da0565268aba714f15216f3bdd9c5f279d6c404098a499b482"},
{"internal/hostops/mail_gate.go", "ControlWriter.ReleaseMailGate", "nonrecord", "sel:Put", "aea8bc583a0a28cd61e8c81c6d466b02e4fb6a086ba921f01fdd7dfdb286191c"},
{"internal/hostops/mail_gate.go", "ControlWriter.RecordMailGateMember", "nonrecord", "sel:Put", "ed87514c0cfd15664765deb5aa0051c37b14ecebf0ba1a77459da4f8b7e7465f"},
@@ -847,8 +847,8 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/objstore/sidecar.go", "claimRawPutSidecar", "nonrecord", "sel:Put", "233e3d808dd462ff83aecdac5176b180556b948c552aa26f6e0b778df91a24c7"},
{"internal/ops/litestream.go", "FleetDBsWithSkipped", "key-site", "lit:instances", "f62291df0685db15529c4946dca358a29aba66fc31e81aab299de579e37f60f1"},
{"internal/park/remote.go", "locationOf", "key-site", "lit:instances", "83b3efca31b3faadcd5997dc181d81b7bb8fc866cc4a448683bf8423b1bea866"},
- {"internal/park/remote.go", "Engine.run", "versioned-transport", "sel:CopyVersion,sel:PutIfAbsent", "3de5b215e4b7aba67768243891b3f6d8ff8c77a465aefe5168c789cb012b1ea9"},
- {"internal/park/sweep.go", "Decide", "key-site", "lit:instances", "d03c3af1430816d3f9517a3944e553291805f97a317e54b96a02b9fbfbb5efb5"},
+ {"internal/park/remote.go", "Engine.run", "versioned-transport", "sel:CopyVersion,sel:PutIfAbsent", "538230265e42b2d828bc8f842034d200b4fe3b0c908bd369a3d309c1c79af78d"},
+ {"internal/park/sweep.go", "Decide", "key-site", "lit:instances", "5f42dc7646841e2cf6cb8835f9b74710bb02ab5b3e9a31a9639bdea80e5f4573"},
{"internal/park/sweep.go", "Engine.Discover", "key-site", "lit:instances", "69b83e6e859c1c9809ba76d46cb0192888df1ecdcd161b4f2b6435733eecdfa3"},
{"internal/provision/claim_app_writes.go", "UpdateRecord", "record-writer", "key:UpdateRecord,key:claimRawUpdateRecord", "caf714cb78e1280d489c7ad5b3cefb1420e812d541108b1fe13d733abed97229"},
{"internal/provision/provision.go", "ManifestKey", "key-site", "lit:instances", "b8ee0e77bdfade19af79a4012c411c1eb0b6ddca16037e5536fa8597f7cde270"},
diff --git a/internal/regionname/region.go b/internal/regionname/region.go
new file mode 100644
index 000000000..d56e208f8
--- /dev/null
+++ b/internal/regionname/region.go
@@ -0,0 +1,58 @@
+package regionname
+
+import "regexp"
+
+// HomeRegion is the engine region of the home placement (home-region parks,
+// part 1, Paul 2026-10-11). The home's placement code is "", and "" already
+// means unset to the engine and to the claim: an engine, view, chore or
+// admission with region "" is refused or read as "holds nothing anywhere".
+// So home is never "" here. It is this token, mapped once from the placement
+// code by PlacementRegion where a box's or a record's code becomes an engine
+// region.
+//
+// The "@" is outside the placement-code alphabet (provision.ValidRegion:
+// lowercase a-z, 0-9 and hyphen), so no named region, one named "home"
+// included, can ever equal it. It reaches the claim's obligation region, its
+// "region:" owner, its quiet_after key and one control key, the home lane's
+// staging lease (control/staging-leases/@home/<host>, keyed in the host's
+// wiring). It never reaches an archive path: the archive is
+// parked/<incarnation>/<host>/<retirement>/.
+const HomeRegion = "@home"
+
+// placementCode is a named placement code: provision.ValidRegion without the
+// empty code. TestHomeRegionIsReserved holds the two together.
+var placementCode = regexp.MustCompile(`^[a-z0-9-]{1,16}$`)
+
+// PlacementRegion is the engine region of a placement code: HomeRegion for
+// the home's "", a named code unchanged, and "" (unset, which every engine
+// entry refuses) for anything that is not a placement code, HomeRegion
+// itself included.
+func PlacementRegion(code string) string {
+ switch {
+ case code == "":
+ return HomeRegion
+ case placementCode.MatchString(code):
+ return code
+ }
+ return ""
+}
+
+func HomePlacementCode(home, region string) string {
+ if home != "" && region == home {
+ return ""
+ }
+ return region
+}
+
+// SameRegion preserves exact stored identities. The only alias is a verified
+// home roster name and @home. Empty always means unset at this boundary.
+func SameRegion(home, a, b string) bool {
+ if a == "" || b == "" {
+ return false
+ }
+ if a == b {
+ return true
+ }
+ return home != "" && PlacementRegion(home) == home &&
+ ((a == home && b == HomeRegion) || (a == HomeRegion && b == home))
+}