carlos / platform Public

hostbox: reconcile refuses a relocating claim's activation path in its first check, before the guard is activated or the inventory written

Claim Plan 11's policy gate admits no reconcile action under a relocating
claim, so Install used to activate the guard and write the inventory (a
claim CAS) and only then be refused at work.With; a dry run reported nil.
checkOn now refuses every relocating path that is not hold-only, so the
dry run, the activation reader and the inventory callback agree. The two
tests assert the guard directory and the claim ETag are unchanged.

Merge review fix round 1, finding 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev bcaf791040523b89238f4fd9ab05db085e02d751 parent 0aa4789
2 files changed, +48 −17
  • internal/hostbox/reconcile.go +10 −0
  • internal/hostbox/reconcile_relocation_test.go +38 −17
diff --git a/internal/hostbox/reconcile.go b/internal/hostbox/reconcile.go
index 766e2cb9a..cd8023ec6 100644
--- a/internal/hostbox/reconcile.go
+++ b/internal/hostbox/reconcile.go
@@ -144,6 +144,16 @@ func (r Reconciler) checkOn(ctx context.Context, obs hostclaim.Observed, selecte
default:
return ErrChanged
}
+ if !holdOnly {
+ // Claim Plan 11's policy gate admits no "reconcile" action under
+ // a relocating claim (R1: only op.exec's probe), so the
+ // activation path would end refused at work.With after it had
+ // activated the guard and written the inventory. Refuse here,
+ // before anything is written; the dry run, the activation reader
+ // and the inventory callback all agree. The hold-only write
+ // takes no policy and is unchanged.
+ return ErrChanged
+ }
}
if until, ok := obs.Claim.QuietAfter[r.Region]; ok && !holdOnly && !r.Now().After(until) {
return ErrChanged
diff --git a/internal/hostbox/reconcile_relocation_test.go b/internal/hostbox/reconcile_relocation_test.go
index 3587ce194..826263ece 100644
--- a/internal/hostbox/reconcile_relocation_test.go
+++ b/internal/hostbox/reconcile_relocation_test.go
@@ -71,8 +71,9 @@ func relocate(t *testing.T, r Reconciler, host, phase string) hostclaim.Observed
// finishes both, so neither reacquires the host lock.
//
// Claim Plan 11's policy gate admits no "reconcile" action under a
-// relocating claim (R1: only op.exec's probe), so the activation path's
-// guarded write is refused there; the hold-only path, which takes no
+// relocating claim (R1: only op.exec's probe), so the activation path is
+// refused by the first check, before the guard is activated or the
+// inventory written, dry run or not; the hold-only path, which takes no
// policy, still keeps the fenced source's row held.
func TestReconcileRelocationSourceReadyThenHeldWithTheRealFenceObserver(t *testing.T) {
r, selected, holds, guard := relocationReconciler(t)
@@ -80,14 +81,24 @@ func TestReconcileRelocationSourceReadyThenHeldWithTheRealFenceObserver(t *testi
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
writes := 0
- if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) {
- t.Fatalf("the source before its fence closed: %v (claim Plan 11 refuses a relocating reconcile)", err)
+ before := snapshotDir(t, guard.DataDir)
+ for _, dry := range []bool{true, false} {
+ sel := selected
+ sel.DryRun = dry
+ if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) {
+ t.Fatalf("the source before its fence closed (dry run %v): %v (claim Plan 11 refuses a relocating reconcile)", dry, err)
+ }
}
if writes != 0 || len(*holds) != 0 {
t.Fatalf("writes %d holds %v", writes, *holds)
}
+ if after := snapshotDir(t, guard.DataDir); !maps.Equal(before, after) {
+ t.Fatal("a refused relocating reconcile changed the guard directory")
+ }
+ if now, err := hostclaim.Read(ctx, r.Claims, selected.Host); err != nil || now.ETag != obs.ETag {
+ t.Fatalf("a refused relocating reconcile wrote the claim: %v", err)
+ }
// Root begins the source's stop: the durable admission fence closes.
- // (The inventory write above moved the claim: read it fresh.)
captured, err := hostguard.Capture(guard.DataDir, selected.Host)
if err != nil {
t.Fatal(err)
@@ -329,13 +340,13 @@ func targetReconciler(t *testing.T, r Reconciler) (Reconciler, hostguard.Guard,
}
// Final review Important 2: with region HA on, a relocation target that
-// has no local guard record yet mints its row through Install. Its row
-// case (held, as op.exec and a participant) does not depend on the fence,
-// so the observer, which errors without a record, is never asked; the
-// guarded path activates the installation. Claim Plan 11's policy gate
-// then refuses the write itself: it admits no "reconcile" action under a
-// relocating claim (R1).
-func TestReconcileRelocationTargetWithNoLocalRecordMintsItsRow(t *testing.T) {
+// has no local guard record yet decides its row (held, as op.exec and a
+// participant) without the fence observer, which errors without a record
+// and is never asked. Claim Plan 11's policy gate admits no "reconcile"
+// action under a relocating claim (R1), so the activation path is refused
+// by the first check, dry run or not: nothing is activated or written,
+// and no row is minted (stage B settles how one is).
+func TestReconcileRelocationTargetWithNoLocalRecordIsRefusedBeforeActivation(t *testing.T) {
r, selected, holds, _ := relocationReconciler(t)
r, guard, calls := targetReconciler(t, r)
ctx := context.Background()
@@ -357,15 +368,25 @@ func TestReconcileRelocationTargetWithNoLocalRecordMintsItsRow(t *testing.T) {
}
*calls = 0
writes := 0
- if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) {
- t.Fatalf("the target's first install: %v (claim Plan 11 refuses a relocating reconcile)", err)
+ before := snapshotDir(t, guard.DataDir)
+ for _, dry := range []bool{true, false} {
+ sel := selected
+ sel.DryRun = dry
+ if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) {
+ t.Fatalf("the target's first install (dry run %v): %v (claim Plan 11 refuses a relocating reconcile)", dry, err)
+ }
}
if writes != 0 || len(*holds) != 0 || *calls != 0 {
t.Fatalf("writes %d holds %v observer calls %d", writes, *holds, *calls)
}
- captured, err := hostguard.Capture(guard.DataDir, selected.Host)
- if err != nil || captured.Stamp != obs.Claim.Holder.Stamp {
- t.Fatalf("the target's installation was not activated: %+v %v", captured, err)
+ if after := snapshotDir(t, guard.DataDir); !maps.Equal(before, after) {
+ t.Fatal("a refused relocating reconcile changed the target's guard directory")
+ }
+ if _, err := hostguard.Capture(guard.DataDir, selected.Host); err == nil {
+ t.Fatal("the target's installation was activated")
+ }
+ if now, err := hostclaim.Read(ctx, r.Claims, selected.Host); err != nil || now.ETag != obs.ETag {
+ t.Fatalf("a refused relocating reconcile wrote the claim: %v", err)
}
}