carlos / platform Public

moves: the park view's region is the claim's verified roster row's (Task 9a fix round 1, region source)

Registration took the view's region from CARLOS_REGION and refused a box
without it. The view's region now comes from the claim's own verified
identity, so view region == claim region (M17) by construction:

- claimproduction.go: OpenClaimOwners' roster read (release key, host-actor
  enrollment, signed directory, the box's row with matching enrollment and
  instance) moves into readProductionRosterRow; OpenClaimOwners calls it
  unchanged.
- movessources.go: productionRosterRegion answers that row's region;
  movesParkViewAt resolves it on first use (cachedRegion keeps the first
  success; a failure refuses that call and is asked again).
  registerProductionMovesParks takes the resolver and no longer reads
  CARLOS_REGION.
- The home region, "", is a region like any other: the view no longer
  refuses it, nor a move whose from or to is home (a move with from == to
  still refuses).

Tests: TestHomeBoxArms (the view seen from "", a move from home, arming an
engine whose region is ""), TestParkViewRegionIsTheRosters (asked once,
cached, a failure retried), TestRegisterProductionMovesParksNeedsBothSwitches
(both switches on, no CARLOS_REGION: registers; the roster is not read at
registration).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 853ce0393ab6a4a33d7feb91ec79aaa98b57050f parent d66523f
4 files changed, +182 −42
  • internal/host/agent.go +1 −1
  • internal/host/claimproduction.go +39 −14
  • internal/host/movessources.go +72 −17
  • internal/host/movessources_test.go +70 −10
diff --git a/internal/host/agent.go b/internal/host/agent.go
index a03febbea..e71dd6fb0 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -283,7 +283,7 @@ func runAgent(args []string, buildClaims ClaimFactory) {
// Task 5's expected refusals are quiet in the regional worker's log and
// its anomalies page once (movesrefusals.go), with both switches on.
movesRefusalLog := newMovesRefusals(metricsBoxLabel(os.Getenv, readMachineID()), PageOperator, log.Printf, time.Now)
- if err := registerProductionMovesParks(claimCtx, claimEntryEnv(claimCtx), claimStore(claimCtx), *cfg, movesRefusalLog); err != nil {
+ if err := registerProductionMovesParks(claimCtx, claimEntryEnv(claimCtx), claimStore(claimCtx), *cfg, productionRosterRegion(claimStore(claimCtx), os.Getenv), movesRefusalLog); err != nil {
log.Printf("agent: moves: the park providers are not registered: %v", err)
}
claims, err := newClaimWiring(claimCtx, claimEntryEnv(claimCtx), buildClaims)
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go
index 82cdfa57c..9f903b913 100644
--- a/internal/host/claimproduction.go
+++ b/internal/host/claimproduction.go
@@ -10,6 +10,7 @@ import (
"time"
"crypto/ecdh"
+ "crypto/ed25519"
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/boxid"
@@ -59,32 +60,56 @@ func RegisterProductionClaimMoveSources(s ClaimProductionMoveSources) error {
return nil
}
-// ProductionClaimOwnerProvider constructs claim-owned authority directly. Only
-// C8 and the moves regional snapshot/lease inventory come from the moves owner.
-type ProductionClaimOwnerProvider struct{}
+// productionRosterRow is this box's verified roster row and what read it:
+// the release key, the box's host-actor enrollment, the signed directory,
+// and the directory's row for the box, whose enrollment and cloud instance
+// are the enrollment's.
+type productionRosterRow struct {
+ pub ed25519.PublicKey
+ enrollment hostfloor.Enrollment
+ directory hostactors.Directory
+ row hostactors.Box
+}
-func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store release.Store, r ClaimRuntimeResources) (ClaimOwnerBindings, error) {
- var out ClaimOwnerBindings
- if store == nil || r.Registry == nil || (r.Mode != "agent" && r.Mode != "adopt" && r.Mode != "instance-control") {
- return out, hostwrite.ErrUnavailable
- }
- pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+// readProductionRosterRow is OpenClaimOwners' roster read, shared with the
+// moves park view's region (productionRosterRegion, Plan 5 Task 9a fix
+// round 1) so the two cannot disagree.
+func readProductionRosterRow(ctx context.Context, store release.Store, getenv func(string) string) (productionRosterRow, error) {
+ var rr productionRosterRow
+ pub, err := release.ParsePublicKey(getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
if err != nil {
- return out, err
+ return rr, err
}
enrollment, err := hostfloor.ReadEnrollment("/etc/carlos/host-actor.json")
if err != nil {
- return out, err
+ return rr, err
}
- directory := hostactors.Directory{Store: store, PublicKey: pub, Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET")}
+ directory := hostactors.Directory{Store: store, PublicKey: pub, Bucket: getenv("CARLOS_DEPLOYMENT_BUCKET")}
roster, err := directory.Read(ctx)
if err != nil {
- return out, err
+ return rr, err
}
row, ok := roster.Roster.Boxes[enrollment.Box]
if !ok || row.Enrollment != enrollment.Enrollment || row.Cloud.InstanceID != enrollment.InstanceID {
- return out, hostbox.ErrChanged
+ return rr, hostbox.ErrChanged
+ }
+ return productionRosterRow{pub: pub, enrollment: enrollment, directory: directory, row: row}, nil
+}
+
+// ProductionClaimOwnerProvider constructs claim-owned authority directly. Only
+// C8 and the moves regional snapshot/lease inventory come from the moves owner.
+type ProductionClaimOwnerProvider struct{}
+
+func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store release.Store, r ClaimRuntimeResources) (ClaimOwnerBindings, error) {
+ var out ClaimOwnerBindings
+ if store == nil || r.Registry == nil || (r.Mode != "agent" && r.Mode != "adopt" && r.Mode != "instance-control") {
+ return out, hostwrite.ErrUnavailable
+ }
+ rr, err := readProductionRosterRow(ctx, store, os.Getenv)
+ if err != nil {
+ return out, err
}
+ pub, enrollment, directory, row := rr.pub, rr.enrollment, rr.directory, rr.row
certs, err := boxid.EmbeddedCerts()
if err != nil {
return out, err
diff --git a/internal/host/movessources.go b/internal/host/movessources.go
index c56663ef6..e3caad3ed 100644
--- a/internal/host/movessources.go
+++ b/internal/host/movessources.go
@@ -39,6 +39,7 @@ import (
"os"
"sort"
"strings"
+ "sync"
"time"
"github.com/carlosframework/platform/internal/activator"
@@ -98,8 +99,8 @@ func movesPlan12Parks() (movesClaimPlan12, error) {
}
// movesParkView is the park view the claim's engine and the moves arms read
-// (park.View): bound to the observation's ETag, seen from region (this box's
-// placement code, which M17 requires to be the claim's region too), naming
+// (park.View): bound to the observation's ETag, seen from region (in
+// production the claim's own region, movesParkViewAt), naming
// the authoritative region by the spec's table (§5.1, movesAuthoritative).
// In the authoritative region it reads the location's generation.json by
// exact version (MarkerKnown, MarkerGeneration, Marker; absent is the origin)
@@ -107,13 +108,53 @@ func movesPlan12Parks() (movesClaimPlan12, error) {
// GenerationOf is the landing's classifier over the holder's location.
// Anything it cannot read or place is an error, never a smaller live set.
func movesParkView(records MovesRecordReader, data genselect.Reader, bucket, region string) func(context.Context, hostclaim.Observed) (park.View, error) {
+ return movesParkViewAt(records, data, bucket, fixedRegion(region))
+}
+
+// fixedRegion is a region resolver that always answers region.
+func fixedRegion(region string) func(context.Context) (string, error) {
+ return func(context.Context) (string, error) { return region, nil }
+}
+
+// cachedRegion resolves once and keeps the first success; a failure is
+// returned and asked again next time. A box's roster region does not change
+// under a running agent; if it did, the engine's own check (the view's
+// region against its own) refuses.
+func cachedRegion(resolve func(context.Context) (string, error)) func(context.Context) (string, error) {
+ var mu sync.Mutex
+ var cached *string
+ return func(ctx context.Context) (string, error) {
+ mu.Lock()
+ defer mu.Unlock()
+ if cached != nil {
+ return *cached, nil
+ }
+ r, err := resolve(ctx)
+ if err != nil {
+ return "", err
+ }
+ cached = &r
+ return r, nil
+ }
+}
+
+// movesParkViewAt is movesParkView seen from the region regionOf answers.
+// Production's is the claim's verified roster row's region
+// (productionRosterRegion), the region the claim's engine runs in, so the
+// view's region is the claim's by construction (M17; fix round 1). The home
+// region, "", is a region like any other.
+func movesParkViewAt(records MovesRecordReader, data genselect.Reader, bucket string, regionOf func(context.Context) (string, error)) func(context.Context, hostclaim.Observed) (park.View, error) {
return func(ctx context.Context, obs hostclaim.Observed) (park.View, error) {
- if ctx == nil || records == nil || data == nil || bucket == "" || region == "" {
+ if ctx == nil || records == nil || data == nil || bucket == "" || regionOf == nil {
return park.View{}, hostwrite.ErrUnavailable
}
if obs.Absent || obs.Claim == nil || obs.ETag == "" || obs.Host == "" || obs.Claim.Host != obs.Host {
return park.View{}, park.ErrUnsafe
}
+ region, err := regionOf(ctx)
+ if err != nil {
+ return park.View{}, fmt.Errorf("this box's region: %w", err)
+ }
auth, err := movesAuthoritative(ctx, records, obs, region)
if err != nil {
return park.View{}, err
@@ -357,7 +398,7 @@ func movesAuthoritative(ctx context.Context, records MovesRecordReader, obs host
return "", err
}
m := rec.Move
- if m == nil || m.ID != c.Op.ID || m.Legacy() || m.From == "" || m.To == "" {
+ if m == nil || m.ID != c.Op.ID || m.Legacy() || m.From == m.To {
if l := rec.LastMove; m == nil && l != nil && l.ID == c.Op.ID && known {
return admitted, nil // the record closed first (M4); the claim's history decides
}
@@ -504,11 +545,11 @@ type movesParkSources struct {
// newMovesParkSources builds the three providers. plan12 is the guard
// (movesPlan12Parks in production): while it refuses, the view and the
// lease set refuse with its error, so nothing is queued or run through them.
-func newMovesParkSources(records MovesRecordReader, data genselect.Reader, bucket, region string, evidence hostbox.RegionalEvidence, plan12 func() (movesClaimPlan12, error)) (movesParkSources, error) {
- if records == nil || data == nil || bucket == "" || region == "" || evidence == nil || plan12 == nil {
+func newMovesParkSources(records MovesRecordReader, data genselect.Reader, bucket string, regionOf func(context.Context) (string, error), evidence hostbox.RegionalEvidence, plan12 func() (movesClaimPlan12, error)) (movesParkSources, error) {
+ if records == nil || data == nil || bucket == "" || regionOf == nil || evidence == nil || plan12 == nil {
return movesParkSources{}, hostwrite.ErrUnavailable
}
- view, leases := movesParkView(records, data, bucket, region), movesLeaseSet(bucket)
+ view, leases := movesParkViewAt(records, data, bucket, regionOf), movesLeaseSet(bucket)
return movesParkSources{
View: func(ctx context.Context, obs hostclaim.Observed) (park.View, error) {
if _, err := plan12(); err != nil {
@@ -566,18 +607,18 @@ func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders,
// registerProductionMovesParks is the agent's registration, before
// newClaimWiring: only with both CARLOS_HOST_CLAIM and CARLOS_MOVES_PHASE1
// on (ruling H1), over the deployment store's records, this box's data
-// bucket read by exact version (lease.NewVersionedS3, through rootaws) and
-// its placement region, behind the claim Plan 12 guard. Off, it registers
-// nothing and the claim keeps its named refusals.
-// Task 5's evidence goes through refusals, which pages its anomalies once
-// (movesrefusals.go).
-func registerProductionMovesParks(ctx context.Context, getenv func(string) string, store release.Store, hib activator.Config, refusals *movesRefusals) error {
+// bucket read by exact version (lease.NewVersionedS3, through rootaws), and
+// the region regionOf answers, behind the claim Plan 12 guard. Production's
+// regionOf is productionRosterRegion: the claim's verified roster row, the
+// home region "" included (fix round 1). Off, it registers nothing and the
+// claim keeps its named refusals. Task 5's evidence goes through refusals,
+// which pages its anomalies once (movesrefusals.go).
+func registerProductionMovesParks(ctx context.Context, getenv func(string) string, store release.Store, hib activator.Config, regionOf func(context.Context) (string, error), refusals *movesRefusals) error {
if !hostwrite.Enabled(getenv) || !moves.Enabled(getenv) {
return nil
}
- region := strings.TrimSpace(getenv("CARLOS_REGION"))
- if ctx == nil || store == nil || hib.Bucket == "" || hib.Region == "" || region == "" || refusals == nil {
- return errors.Join(hostwrite.ErrUnavailable, errors.New("the moves park view needs the deployment store, the data bucket and CARLOS_REGION"))
+ if ctx == nil || store == nil || hib.Bucket == "" || hib.Region == "" || regionOf == nil || refusals == nil {
+ return errors.Join(hostwrite.ErrUnavailable, errors.New("the moves park view needs the deployment store, the data bucket and this box's roster region"))
}
data, err := lease.NewVersionedS3(ctx, map[string]string{hib.Bucket: hib.Region})
if err != nil {
@@ -585,9 +626,23 @@ func registerProductionMovesParks(ctx context.Context, getenv func(string) strin
}
records := MovesRecordsFrom(store)
evidence := refusals.evidence(movesRetirementEvidence{Records: records, Now: time.Now})
- src, err := newMovesParkSources(records, data, hib.Bucket, region, evidence, movesPlan12Parks)
+ src, err := newMovesParkSources(records, data, hib.Bucket, cachedRegion(regionOf), evidence, movesPlan12Parks)
if err != nil {
return err
}
return registerMovesParks(src)
}
+
+// productionRosterRegion is this box's region as the claim knows it: the
+// region of its verified roster row (readProductionRosterRow), the same row
+// OpenClaimOwners builds the claim's identity, and so its engine's region,
+// from.
+func productionRosterRegion(store release.Store, getenv func(string) string) func(context.Context) (string, error) {
+ return func(ctx context.Context) (string, error) {
+ rr, err := readProductionRosterRow(ctx, store, getenv)
+ if err != nil {
+ return "", err
+ }
+ return rr.row.Region, nil
+ }
+}
diff --git a/internal/host/movessources_test.go b/internal/host/movessources_test.go
index 3e314e326..dcef3995a 100644
--- a/internal/host/movessources_test.go
+++ b/internal/host/movessources_test.go
@@ -394,7 +394,7 @@ func TestArmMovesPassRefusesAMismatchedBucketOrRegion(t *testing.T) {
func TestMovesParksRefuseUntilClaimPlan12(t *testing.T) {
f := newVwFixture(t)
evidence := movesRetirementEvidence{Records: f.records(), Now: time.Now}
- src, err := newMovesParkSources(f.records(), f.bucket, "data", "ie", evidence, movesPlan12Parks)
+ src, err := newMovesParkSources(f.records(), f.bucket, "data", fixedRegion("ie"), evidence, movesPlan12Parks)
if err != nil {
t.Fatal(err)
}
@@ -413,7 +413,7 @@ func TestMovesParksRefuseUntilClaimPlan12(t *testing.T) {
t.Fatal("the registered evidence is not the sweep verifier")
}
// With claim Plan 12 the same sources answer.
- src, err = newMovesParkSources(f.records(), f.bucket, "data", "ie", evidence, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil })
+ src, err = newMovesParkSources(f.records(), f.bucket, "data", fixedRegion("ie"), evidence, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil })
if err != nil {
t.Fatal(err)
}
@@ -516,8 +516,9 @@ func TestCommittedLaneViewNamesTheTargetWithItsMarker(t *testing.T) {
}
// TestRegisterProductionMovesParksNeedsBothSwitches (ruling H1): with either
-// switch off the agent registers nothing; with both on it needs this box's
-// placement region.
+// switch off the agent registers nothing. With both on it registers without
+// CARLOS_REGION: the view's region is the claim's verified roster row's
+// (fix round 1, region source), resolved when the view is first asked.
func TestRegisterProductionMovesParksNeedsBothSwitches(t *testing.T) {
claimMoveSources.Lock()
oldSource, oldParks := claimMoveSources.Source, claimMoveSources.Parks
@@ -531,20 +532,79 @@ func TestRegisterProductionMovesParksNeedsBothSwitches(t *testing.T) {
env := func(vals map[string]string) func(string) string { return func(k string) string { return vals[k] } }
hib := activator.Config{Bucket: "data-ie", Region: "eu-west-1"}
store := release.NewDirStore(t.TempDir())
+ asked := 0
+ roster := func(context.Context) (string, error) { asked++; return "ie", nil }
for name, vals := range map[string]map[string]string{
- "claims off": {moves.SwitchEnv: "on", "CARLOS_REGION": "ie"},
- "moves off": {"CARLOS_HOST_CLAIM": "1", moves.SwitchEnv: "off", "CARLOS_REGION": "ie"},
+ "claims off": {moves.SwitchEnv: "on"},
+ "moves off": {"CARLOS_HOST_CLAIM": "1", moves.SwitchEnv: "off"},
} {
- if err := registerProductionMovesParks(t.Context(), env(vals), store, hib, newMovesRefusals("ie-box", nil, t.Logf, time.Now)); err != nil {
+ if err := registerProductionMovesParks(t.Context(), env(vals), store, hib, roster, newMovesRefusals("ie-box", nil, t.Logf, time.Now)); err != nil {
t.Fatalf("%s: %v", name, err)
}
if claimMoveSources.Parks != nil {
t.Fatalf("%s: registered", name)
}
}
- err := registerProductionMovesParks(t.Context(), env(map[string]string{"CARLOS_HOST_CLAIM": "1", moves.SwitchEnv: "on"}), store, hib, newMovesRefusals("ie-box", nil, t.Logf, time.Now))
- if !errors.Is(err, hostwrite.ErrUnavailable) || claimMoveSources.Parks != nil {
- t.Fatalf("no placement region: %v", err)
+ if err := registerProductionMovesParks(t.Context(), env(map[string]string{"CARLOS_HOST_CLAIM": "1", moves.SwitchEnv: "on"}), store, hib, roster, newMovesRefusals("ie-box", nil, t.Logf, time.Now)); err != nil {
+ t.Fatalf("both switches on, no CARLOS_REGION: %v", err)
+ }
+ if claimMoveSources.Parks == nil || asked != 0 {
+ t.Fatalf("registered %v, roster asked %d times at registration", claimMoveSources.Parks != nil, asked)
+ }
+}
+
+// TestHomeBoxArms (fix round 1, region source): the home region, "", is a
+// region like any other. A home box's view is seen from "" and its arms take
+// an engine whose region is "".
+func TestHomeBoxArms(t *testing.T) {
+ f := newVwFixture(t)
+ f.rec.Region = ""
+ view := movesParkViewAt(f.records(), f.bucket, "data", fixedRegion(""))
+ obs := vwObs(&hostclaim.Claim{Holder: f.holder(""), Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: vwMove, Phase: hostclaim.PhaseMoving, Exec: "box"}})
+ f.rec.Move = &provision.MoveState{ID: vwMove, Incarnation: f.inc, From: "", To: "uk", State: provision.MoveCutting}
+ v, err := view(t.Context(), obs)
+ if err != nil || v.Region != "" || v.AuthoritativeRegion != "" || !v.MarkerKnown {
+ t.Fatalf("the home view %+v, %v", v, err)
+ }
+ auth := func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }
+ providers := ClaimProviders{moveParks: &claimMoveParks{
+ engine: park.Engine{Bucket: "data-home", Region: "", Leases: &fakeParkLeases{}, View: view, Authorize: auth},
+ leases: movesLeaseSet("data-home"), check: func(context.Context) error { return nil }}}
+ c := moveCfg{OwnBucket: "data-home", Region: ""}
+ if err := armMovesParks(t.Context(), &c, providers, func() (movesClaimPlan12, error) { return &fakePlan12{}, nil }); err != nil {
+ t.Fatalf("a home box did not arm: %v", err)
+ }
+ if !parkHooksArmed(c) {
+ t.Fatal("hooks unset")
+ }
+}
+
+// TestParkViewRegionIsTheRosters (fix round 1, region source): the view
+// asks the roster resolver once, caches a success, and a failure refuses
+// that call and asks again on the next.
+func TestParkViewRegionIsTheRosters(t *testing.T) {
+ f := newVwFixture(t)
+ calls, fail := 0, true
+ resolve := cachedRegion(func(context.Context) (string, error) {
+ calls++
+ if fail {
+ return "", hostbox.ErrChanged
+ }
+ return "uk", nil
+ })
+ view := movesParkViewAt(f.records(), f.bucket, "data", resolve)
+ obs := vwObs(&hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}})
+ if _, err := view(t.Context(), obs); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("a roster failure: %v", err)
+ }
+ fail = false
+ for i := 0; i < 3; i++ {
+ if v, err := view(t.Context(), obs); err != nil || v.Region != "uk" {
+ t.Fatalf("view %+v, %v", v, err)
+ }
+ }
+ if calls != 2 {
+ t.Fatalf("the roster was asked %d times, want 2", calls)
}
}