carlos / platform Public

hostclaim: the pre-flip conversion reads the move's own intent (C7 contract with moves)

BeginMove writes "source", "target" and "to"; the conversion required
source_box, source_region and admission_id, so it could convert no move
moves creates. It now takes the source from "source", requires the
source_enrollment the request door records from the roster, checks the
region as guard == fence binding, and keeps the move history under move_*.
admission_id is journaled when present; it becomes required with claim
Plan 5 Task 7's persistent admission. A contract test converts a claim
BeginMove produced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8536d0b7dd5ca9896025c981962bd77e9ef0ca33 parent 17362ea
2 files changed, +60 −19
  • internal/hostclaim/relocating.go +15 −10
  • internal/hostclaim/relocating_test.go +45 −9

CI passed — run details

diff --git a/internal/hostclaim/relocating.go b/internal/hostclaim/relocating.go
index b5565460c..b3f27d7b5 100644
--- a/internal/hostclaim/relocating.go
+++ b/internal/hostclaim/relocating.go
@@ -66,7 +66,10 @@ func ConvertMovingToRelocating(ctx context.Context, s release.Store, observed Ob
if opID == "" || before.Op.ID != opID {
return Observed{}, ErrStaleOp
}
- if before.Holder == nil || before.Op.Intent["admission_id"] == "" || before.Holder.Family != "instance" || before.Op.Kind != KindMoving || before.Op.Phase == "landing" || before.Op.Phase == "committed" {
+ // The move's own intent, as BeginMove writes it: "source" and "target",
+ // plus the source enrollment the request door records from the roster.
+ source := before.Op.Intent["source"]
+ if before.Holder == nil || !validBoxReference(source) || source == "none" || before.Op.Intent["source_enrollment"] == "" || before.Holder.Family != "instance" || before.Op.Kind != KindMoving || before.Op.Phase == "landing" || before.Op.Phase == "committed" {
return Observed{}, ErrWrongState
}
// Check the instance record even while the claim still says moving: the
@@ -79,7 +82,7 @@ func ConvertMovingToRelocating(ctx context.Context, s release.Store, observed Ob
if err != nil {
return Observed{}, err
}
- if region == "" || region != fence.binding.Region || before.Op.Intent["source_box"] != fence.record.Box || before.Op.Intent["source_enrollment"] != ref.Enrollment || before.Op.Intent["source_region"] != region || (before.Op.Exec != fence.record.Box && before.Op.Exec != "none" && before.Op.Exec != "") {
+ if region == "" || region != fence.binding.Region || source != fence.record.Box || before.Op.Intent["source_enrollment"] != ref.Enrollment || (before.Op.Exec != fence.record.Box && before.Op.Exec != "none" && before.Op.Exec != "") {
return Observed{}, ErrFence
}
raw, err := json.Marshal(ref)
@@ -112,7 +115,9 @@ func ConvertMovingToRelocating(ctx context.Context, s release.Store, observed Ob
c.Op.Journal["source_generation"] = c.Holder.Generation
c.Op.Journal["roster_revision"] = strconv.FormatUint(fence.binding.RosterRevision, 10)
c.Op.Journal["roster_etag"] = fence.binding.RosterETag
- c.Op.Journal["admission_id"] = before.Op.Intent["admission_id"]
+ if admission := before.Op.Intent["admission_id"]; admission != "" {
+ c.Op.Journal["admission_id"] = admission
+ }
c.Op.Journal["conversion_claim_gen"] = strconv.FormatUint(before.Gen, 10)
c.Op.Journal["conversion_claim_etag"] = observed.ETag
c.Op.Kind, c.Op.Phase, c.Op.Exec = KindRelocating, "pinning", "none"
@@ -120,16 +125,16 @@ func ConvertMovingToRelocating(ctx context.Context, s release.Store, observed Ob
c.Op.Intent = map[string]string{}
}
c.Op.Intent["region"] = region
+ c.Op.Intent["source_box"] = source
// The original move target is in a different region. It is not a selected
// same-region replacement. Keep the move history without authorizing it.
- if target := c.Op.Intent["target_box"]; target != "" {
- c.Op.Intent["move_target_box"] = target
- delete(c.Op.Intent, "target_box")
- }
- if enrollment := c.Op.Intent["target_enrollment"]; enrollment != "" {
- c.Op.Intent["move_target_enrollment"] = enrollment
- delete(c.Op.Intent, "target_enrollment")
+ for from, to := range map[string]string{"target": "move_target_box", "to": "move_to_region", "target_enrollment": "move_target_enrollment"} {
+ if v := c.Op.Intent[from]; v != "" {
+ c.Op.Intent[to] = v
+ }
+ delete(c.Op.Intent, from)
}
+ delete(c.Op.Intent, "source")
participants := make([]string, 0, len(c.Op.Participants))
for _, box := range c.Op.Participants {
if box != fence.record.Box {
diff --git a/internal/hostclaim/relocating_test.go b/internal/hostclaim/relocating_test.go
index 88d2cf2a7..d7a13ab81 100644
--- a/internal/hostclaim/relocating_test.go
+++ b/internal/hostclaim/relocating_test.go
@@ -34,6 +34,7 @@ func (a c7Authority) ValidateFence(_ context.Context, f termfence.Record) (Fence
type c7Guard struct {
calls int
rejectAt int
+ region string
}
func (g *c7Guard) CheckMovingBeforeFlip(_ context.Context, o Observed, id string, ref FenceRef) (string, error) {
@@ -44,6 +45,9 @@ func (g *c7Guard) CheckMovingBeforeFlip(_ context.Context, o Observed, id string
if g.rejectAt > 0 && g.calls >= g.rejectAt {
return "", ErrWrongState
}
+ if g.region != "" {
+ return g.region, nil
+ }
return "ie", nil
}
@@ -65,7 +69,8 @@ func c7Fixture(t *testing.T) (*c7Store, Observed, FenceRef, c7Authority) {
o, err = Update(ctx, s, o, func(c *Claim) error {
c.Holder.Generation, c.Holder.Stamp = "gen-current", 17
c.Op = Op{ID: "move-1", Kind: KindMoving, Phase: "releasing", Exec: "adi-knitting",
- Intent: map[string]string{"admission_id": "admission-existing", "source_box": "adi-knitting", "source_enrollment": strings.Repeat("a", 32), "source_region": "ie", "target_box": "bri-knitting", "target_enrollment": strings.Repeat("c", 32)},
+ // The intent BeginMove writes, plus the source enrollment the door adds.
+ Intent: map[string]string{"to": "uk", "source": "adi-knitting", "target": "bri-knitting", "source_enrollment": strings.Repeat("a", 32)},
Journal: map[string]string{"pin": "keep"}, Participants: []string{"adi-knitting", "bri-knitting"}}
c.Obligations = []Obligation{{ID: "staging", Op: "move-1", Kind: "park-replica", Region: "uk", Owner: "region:uk", Retirement: "retire-1", Park: &ParkScope{Location: "moves/" + testHost + "/move-1", Incarnation: c.Holder.Incarnation, EvidenceAt: time.Now().UTC(), RequestLimit: 120 * time.Second}}}
return nil
@@ -95,7 +100,7 @@ func TestC7ConversionPreservesOwnershipAndChores(t *testing.T) {
t.Fatal(err)
}
c := after.Claim
- if c.Op.Journal["admission_id"] != "admission-existing" || c.Op.Journal["source_stamp"] != "17" || c.Op.Journal["source_generation"] != "gen-current" || c.Op.Journal["roster_revision"] != "7" || c.Op.Journal["roster_etag"] != "roster-etag" || c.Op.Journal["reason"] != "source-terminated-before-flip" || c.Op.Journal["conversion_claim_etag"] != before.ETag {
+ if _, ok := c.Op.Journal["admission_id"]; ok || c.Op.Journal["source_stamp"] != "17" || c.Op.Journal["source_generation"] != "gen-current" || c.Op.Journal["roster_revision"] != "7" || c.Op.Journal["roster_etag"] != "roster-etag" || c.Op.Journal["reason"] != "source-terminated-before-flip" || c.Op.Journal["conversion_claim_etag"] != before.ETag {
t.Fatalf("missing conversion authority: %+v", c.Op.Journal)
}
if s.puts != 1 || c.Gen != before.Claim.Gen+1 || guard.calls != 2 {
@@ -107,7 +112,7 @@ func TestC7ConversionPreservesOwnershipAndChores(t *testing.T) {
if c.Op.ID != "move-1" || c.Op.Kind != KindRelocating || c.Op.Phase != "pinning" || c.Op.Exec != "none" || c.Op.Intent["region"] != "ie" {
t.Fatalf("wrong conversion: %+v", c.Op)
}
- if c.Op.Intent["target_box"] != "" || c.Op.Intent["move_target_box"] != "bri-knitting" || !reflect.DeepEqual(c.Op.Participants, []string{"bri-knitting"}) {
+ if c.Op.Intent["target_box"] != "" || c.Op.Intent["target"] != "" || c.Op.Intent["move_target_box"] != "bri-knitting" || c.Op.Intent["move_to_region"] != "uk" || c.Op.Intent["source_box"] != "adi-knitting" || !reflect.DeepEqual(c.Op.Participants, []string{"bri-knitting"}) {
t.Fatalf("target or barrier: %+v", c.Op)
}
var saved FenceRef
@@ -123,7 +128,7 @@ func TestC7ConversionPreservesOwnershipAndChores(t *testing.T) {
}
func TestC7ConversionRefusesWithoutWriting(t *testing.T) {
- for _, tc := range []string{"landing-record", "flip-during-verify", "landing-claim", "wrong-op", "wrong-source", "wrong-enrollment", "wrong-region", "foreign-exec", "journal-rebind", "authority", "digest", "no-guard", "missing-admission"} {
+ for _, tc := range []string{"landing-record", "flip-during-verify", "landing-claim", "wrong-op", "wrong-source", "wrong-enrollment", "wrong-region", "foreign-exec", "journal-rebind", "authority", "digest", "no-guard", "missing-enrollment", "missing-source"} {
t.Run(tc, func(t *testing.T) {
s, o, ref, a := c7Fixture(t)
id := "move-1"
@@ -138,11 +143,11 @@ func TestC7ConversionRefusesWithoutWriting(t *testing.T) {
case "wrong-op":
id = "other"
case "wrong-source":
- o.Claim.Op.Intent["source_box"] = "other"
+ o.Claim.Op.Intent["source"] = "other"
case "wrong-enrollment":
o.Claim.Op.Intent["source_enrollment"] = strings.Repeat("d", 32)
case "wrong-region":
- o.Claim.Op.Intent["source_region"] = "uk"
+ guard = &c7Guard{region: "uk"}
case "foreign-exec":
o.Claim.Op.Exec = "target"
case "journal-rebind":
@@ -153,8 +158,10 @@ func TestC7ConversionRefusesWithoutWriting(t *testing.T) {
ref.SHA256 = strings.Repeat("0", 64)
case "no-guard":
guard = nil
- case "missing-admission":
- delete(o.Claim.Op.Intent, "admission_id")
+ case "missing-enrollment":
+ delete(o.Claim.Op.Intent, "source_enrollment")
+ case "missing-source":
+ delete(o.Claim.Op.Intent, "source")
}
raw, _ := json.Marshal(o.Claim)
if _, err := ConvertMovingToRelocating(context.Background(), s, o, id, ref, a, guard); err == nil {
@@ -212,7 +219,8 @@ func TestC7RelocatingPhaseValidation(t *testing.T) {
_, o, _, _ := c7Fixture(t)
c := o.Claim
c.Op.Kind, c.Op.Phase = KindRelocating, phase
- c.Op.Intent["region"] = "ie"
+ // A relocating op's own intent (region HA's shape), not a move's.
+ c.Op.Intent = map[string]string{"region": "ie", "source_box": "adi-knitting", "target_box": "bri-knitting"}
switch phase {
case "starting", "releasing":
c.Op.Exec = "adi-knitting"
@@ -249,3 +257,31 @@ func TestC7JournalFenceCannotNameAnotherBox(t *testing.T) {
t.Fatal("journal key rebound to another box")
}
}
+
+// The C7 contract with moves: a claim BeginMove produced, plus the source
+// enrollment the request door records, converts.
+func TestC7ConvertsAClaimBeginMoveProduced(t *testing.T) {
+ s, o, ref, a := c7Fixture(t)
+ ctx := context.Background()
+ var err error
+ o, err = Update(ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindServing, Exec: "adi-knitting"}
+ c.Obligations = nil
+ if err := BeginMove("move-1", "member", "adi-knitting", "bri-knitting", "uk", time.Now())(c); err != nil {
+ return err
+ }
+ c.Op.Intent["source_enrollment"] = ref.Enrollment
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.puts = 0
+ after, err := ConvertMovingToRelocating(ctx, s, o, "move-1", ref, a, &c7Guard{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after.Claim.Op.Kind != KindRelocating || after.Claim.Op.Phase != "pinning" || after.Claim.Op.Intent["source_box"] != "adi-knitting" {
+ t.Fatalf("not converted: %+v", after.Claim.Op)
+ }
+}