carlos / platform Public

moves: the dirty fenced-target page rests on the grant's verified-dirty outcome (Task 5 fix round 1, item 1)

ReleaseFencedTarget returns a FencedTargetOutcome. VerifiedDirty is set only
with ErrPending and only after VerifyMoveTargetFence accepted the fence (the
J4 refusal of a dirty target). Any other ErrPending (a withdrawn move
registration from moveAdmission, a spent grant) leaves it unset. The console
pages only on VerifiedDirty.

hostops: the body moves unchanged into releaseFencedTarget behind a wrapper
that returns the outcome; the census rows for its two hostclaim calls name the
new function. Tests: TestPlan12RecoveryFenceGrant gains a dirty-unregistered
mode and checks the outcome; the console regression withdraws the
registration between the observation and the call, and asserts no page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2be0b79eec92004a1d1db432c2f37a3825d2ebce parent 1a42b87
7 files changed, +118 −25
  • internal/console/movesrecovery.go +9 −6
  • internal/console/movesrecovery_test.go +60 −8
  • internal/console/testdata/claim-authority-sites.txt +2 −2
  • internal/hostops/move_fence_release.go +17 −1
  • internal/hostops/plan12_fence_home_test.go +8 −2
  • internal/hostops/plan12_fence_test.go +18 −5
  • internal/hostops/plan13_fence_test.go +4 −1
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go
index fa85b55af..c2eca793e 100644
--- a/internal/console/movesrecovery.go
+++ b/internal/console/movesrecovery.go
@@ -88,7 +88,8 @@ import (
// evidence, the pass stops calling it. A dirty target (one that released with
// writes the source does not have) cannot be finished from a dead box: the
// grant refuses it after it verified the fence, the pass pages once that an
-// operator must declare the target lost, and leaves it for J4. Its fence
+// operator must declare the target lost, and leaves it for J4. The page rests
+// only on the grant's verified-dirty outcome, never on a bare ErrPending. Its fence
// comes after the claim's rollback half, which the grant does not take for
// it. The console logs the fence's record writes, and its claim rollback, as
// its own move writes.
@@ -454,11 +455,13 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant
if f := s.moveFenceCall; f != nil {
f(*in.TargetFence)
}
- err := g.ReleaseFencedTarget(ctx, *in.TargetFence)
- if errors.Is(err, hostops.ErrPending) && rec.Move.Rollback != nil && rec.Move.Rollback.Dirty {
- // The grant refuses a dirty target only after it verified the
- // fence, so the page rests on a fence the grant accepted. J4,
- // the operator's declaration, takes it from here.
+ out, err := g.ReleaseFencedTarget(ctx, *in.TargetFence)
+ if out.VerifiedDirty && errors.Is(err, hostops.ErrPending) {
+ // Only the grant's verified-dirty outcome pages (fix round 1,
+ // item 1): it is given after the fence verified. Any other
+ // ErrPending (a withdrawn registration, a spent grant) is not
+ // evidence of a terminated target. J4, the operator's
+ // declaration, takes it from here.
s.pageMove(ctx, movePageTargetFencedDirty, o, c, rec, now)
return nil
}
diff --git a/internal/console/movesrecovery_test.go b/internal/console/movesrecovery_test.go
index 9a43e1ab5..03bfd13a2 100644
--- a/internal/console/movesrecovery_test.go
+++ b/internal/console/movesrecovery_test.go
@@ -1685,14 +1685,7 @@ func TestRecoveryPassFlipsBeforeTheFence(t *testing.T) {
// the target lost, and leaves it for J4.
func TestRecoveryPassLeavesADirtyFencedTargetToJ4AndPagesOnce(t *testing.T) {
w, _, calls := fencedTargetWorld(t, "code-mvpass-fence-dirty")
- rbID, at := newRollbackID(t), time.Now().UTC().Add(-time.Minute)
- deadline := at.Add(time.Hour)
- j17SetRecord(t, w.fx, func(r *provision.Record) {
- r.Region = "ie"
- r.Move.State = provision.MoveRollingBack
- r.Move.Rollback = &provision.MoveRollback{ID: rbID, RequestedAt: &at, Reason: provision.RollbackLandingDeadline, TargetPhase: provision.RbTargetReleased, Dirty: true,
- SourcePhase: provision.RbSourceWaiting, ReleaseDeadline: &deadline}
- })
+ rbID := dirtyFencedRollback(t, w)
w.pass(t) // the claim's rollback half, which the fence does not take for a dirty target
if c := w.claim(t); c.Op.Kind != hostclaim.KindRestoring || c.Op.Intent["rollback"] != rbID {
t.Fatalf("the rollback half: %+v", c.Op)
@@ -1724,6 +1717,65 @@ func TestRecoveryPassLeavesADirtyFencedTargetToJ4AndPagesOnce(t *testing.T) {
}
}
+// dirtyFencedRollback puts the fenced world at a rollback whose target
+// released dirty, the claim still landing, and returns the rollback's id.
+func dirtyFencedRollback(t *testing.T, w *movePassWorld) string {
+ t.Helper()
+ rbID, at := newRollbackID(t), time.Now().UTC().Add(-time.Minute)
+ deadline := at.Add(time.Hour)
+ j17SetRecord(t, w.fx, func(r *provision.Record) {
+ r.Region = "ie"
+ r.Move.State = provision.MoveRollingBack
+ r.Move.Rollback = &provision.MoveRollback{ID: rbID, RequestedAt: &at, Reason: provision.RollbackLandingDeadline, TargetPhase: provision.RbTargetReleased, Dirty: true,
+ SourcePhase: provision.RbSourceWaiting, ReleaseDeadline: &deadline}
+ })
+ return rbID
+}
+
+// TestRecoveryPassPagesOnlyAVerifiedDirtyTarget (Task 5 fix round 1, item
+// 1): the dirty page rests only on the grant's verified-dirty outcome. The
+// move's registration withdrawn from the admission between the pass's
+// observation and the fence call makes the grant answer ErrPending before it
+// verifies anything: the pass sends no page, then or on a later pass.
+func TestRecoveryPassPagesOnlyAVerifiedDirtyTarget(t *testing.T) {
+ w, _, calls := fencedTargetWorld(t, "code-mvpass-fence-dirty-admission")
+ dirtyFencedRollback(t, w)
+ w.pass(t) // the claim's rollback half
+ ctx := context.Background()
+ withdraw := func(hostclaim.FenceRef) {
+ body, etag, err := w.fx.rel.Get(ctx, "apps/bab/titogo/admission.json")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ var a hostops.AppAdmission
+ if err := json.Unmarshal(body, &a); err != nil {
+ t.Error(err)
+ return
+ }
+ if _, ok := a.Registrations[w.id]; !ok {
+ return
+ }
+ delete(a.Registrations, w.id)
+ if body, err = json.Marshal(a); err != nil {
+ t.Error(err)
+ return
+ }
+ if _, err := w.fx.rel.Put(ctx, "apps/bab/titogo/admission.json", body, etag); err != nil {
+ t.Error(err)
+ }
+ }
+ w.fx.srv.moveFenceCall = withdraw
+ w.pass(t)
+ w.pass(t)
+ if len(calls()) != 0 {
+ t.Fatal("the seam was replaced; the world's own counter should not run")
+ }
+ if p := w.paged(t); len(p) != 0 {
+ t.Fatalf("paged without a verified fence: %+v", p)
+ }
+}
+
// TestRecoveryPassStopsCallingTheFenceOnceConsumed (Q8): once the claim
// carries the consumed fence and the record its loss (LossRef and Evidence
// the fence's key), the pass never calls the fence again.
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt
index e3db6a4f8..1f68e7949 100644
--- a/internal/console/testdata/claim-authority-sites.txt
+++ b/internal/console/testdata/claim-authority-sites.txt
@@ -12,8 +12,8 @@
../hostops/lifecycle_grant.go|RecoveryPass|w.RunRecovery(ctx, r.Pass)
../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, MoveFenceRegions{Authority: g.authority, HomeRegion: o.Claim.Op.Intent["home_region"]})
+../hostops/move_fence_release.go|releaseFencedTarget|hostclaim.ReleaseFencedTarget(r)
+../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/hostops/move_fence_release.go b/internal/hostops/move_fence_release.go
index 3124c1c96..8f7e4cf58 100644
--- a/internal/hostops/move_fence_release.go
+++ b/internal/hostops/move_fence_release.go
@@ -12,10 +12,25 @@ import (
"github.com/carlosframework/platform/internal/release"
)
+// FencedTargetOutcome is what one ReleaseFencedTarget step found, as values,
+// never authority. VerifiedDirty is set only with ErrPending, and only after
+// the fence verified: the target released dirty, which the grant leaves for
+// J4. Every other ErrPending (a withdrawn registration, a spent grant) leaves
+// it unset.
+type FencedTargetOutcome struct {
+ VerifiedDirty bool
+}
+
// ReleaseFencedTarget advances one record-first recovery step per grant:
// record decision, claim release with parks, then record release. Each pass
// verifies unconsumed authority; completion uses the durable claim evidence.
-func (g *MoveRecoveryGrant) ReleaseFencedTarget(ctx context.Context, ref hostclaim.FenceRef) error {
+func (g *MoveRecoveryGrant) ReleaseFencedTarget(ctx context.Context, ref hostclaim.FenceRef) (FencedTargetOutcome, error) {
+ var out FencedTargetOutcome
+ err := g.releaseFencedTarget(ctx, ref, &out)
+ return out, err
+}
+
+func (g *MoveRecoveryGrant) releaseFencedTarget(ctx context.Context, ref hostclaim.FenceRef, out *FencedTargetOutcome) error {
if g == nil || g.state == nil {
return ErrPending
}
@@ -78,6 +93,7 @@ func (g *MoveRecoveryGrant) ReleaseFencedTarget(ctx context.Context, ref hostcla
}
rb := m.Rollback
if rb.Dirty {
+ out.VerifiedDirty = true
return ErrPending
} // J4: a dead dirty target cannot build a return lane.
released := rb.TargetPhase == provision.RbTargetReleased && !rb.Dirty
diff --git a/internal/hostops/plan12_fence_home_test.go b/internal/hostops/plan12_fence_home_test.go
index 57e51a797..d05cd314c 100644
--- a/internal/hostops/plan12_fence_home_test.go
+++ b/internal/hostops/plan12_fence_home_test.go
@@ -33,7 +33,10 @@ func TestPlan12RecoveryFenceGrantFromHome(t *testing.T) {
act := func(fn func(context.Context, *MoveRecoveryGrant) error) error {
return w.control.RunRecovery(t.Context(), func(ctx context.Context) error { return runMoveRecovery(ctx, w.claim(t), fn, authority) })
}
- if err := act(func(ctx context.Context, g *MoveRecoveryGrant) error { return g.ReleaseFencedTarget(ctx, w.ref) }); err != nil {
+ if err := act(func(ctx context.Context, g *MoveRecoveryGrant) error {
+ _, err := g.ReleaseFencedTarget(ctx, w.ref)
+ return err
+ }); err != nil {
t.Fatalf("the fenced target of a move out of home was not released: %v", err)
}
o := w.claim(t)
@@ -49,7 +52,10 @@ func TestPlan12RecoveryFenceGrantFromHome(t *testing.T) {
}
}
// The replay writes the record's half (the target released).
- if err := act(func(ctx context.Context, g *MoveRecoveryGrant) error { return g.ReleaseFencedTarget(ctx, w.ref) }); err != nil {
+ if err := act(func(ctx context.Context, g *MoveRecoveryGrant) error {
+ _, err := g.ReleaseFencedTarget(ctx, w.ref)
+ return err
+ }); err != nil {
t.Fatalf("the release's replay: %v", err)
}
if rec, err := provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host); err != nil || rec.Move.Rollback.TargetPhase != provision.RbTargetReleased {
diff --git a/internal/hostops/plan12_fence_test.go b/internal/hostops/plan12_fence_test.go
index cf2d2850e..59bbe4aef 100644
--- a/internal/hostops/plan12_fence_test.go
+++ b/internal/hostops/plan12_fence_test.go
@@ -36,9 +36,9 @@ func (a *plan12BumpFence) ValidateFence(ctx context.Context, f termfence.Record)
return a.c7ReplayAuthority.ValidateFence(ctx, f)
}
func TestPlan12RecoveryFenceGrant(t *testing.T) {
- for _, mode := range []string{"ok", "admitted", "installed", "from-landing", "from-restoring", "record-released", "changed-sequence", "dirty", "escaped", "wrong-ref"} {
+ for _, mode := range []string{"ok", "admitted", "installed", "from-landing", "from-restoring", "record-released", "changed-sequence", "dirty", "dirty-unregistered", "escaped", "wrong-ref"} {
t.Run(mode, func(t *testing.T) {
- w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {
+ w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, a *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"}
@@ -47,7 +47,12 @@ func TestPlan12RecoveryFenceGrant(t *testing.T) {
r.Region = "uk"
r.Move.From, r.Move.To, r.Move.State = "uk", "ie", provision.MoveRollingBack
at := r.CreatedAt
- r.Move.Rollback = &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", Reason: provision.RollbackTargetFenced, Evidence: "control/box-fences/" + strings.Repeat("a", 32) + ".json", RequestedAt: &at, TargetPhase: provision.RbTargetLost, LossRef: "control/box-fences/" + strings.Repeat("a", 32) + ".json", LostAt: &at, SourcePhase: provision.RbSourceWaiting, Dirty: mode == "dirty"}
+ r.Move.Rollback = &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", Reason: provision.RollbackTargetFenced, Evidence: "control/box-fences/" + strings.Repeat("a", 32) + ".json", RequestedAt: &at, TargetPhase: provision.RbTargetLost, LossRef: "control/box-fences/" + strings.Repeat("a", 32) + ".json", LostAt: &at, SourcePhase: provision.RbSourceWaiting, Dirty: mode == "dirty" || mode == "dirty-unregistered"}
+ if mode == "dirty-unregistered" {
+ // The registration gone: the grant refuses before it
+ // verifies the fence, so the refusal is not verified-dirty.
+ delete(a.Registrations, c.Op.ID)
+ }
if mode == "admitted" || mode == "installed" {
c.Holder.Session = 7
c.Holder.Stamp = 7
@@ -102,6 +107,7 @@ func TestPlan12RecoveryFenceGrant(t *testing.T) {
ref.SHA256 = strings.Repeat("e", 64)
}
var escaped *MoveRecoveryGrant
+ var out FencedTargetOutcome
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 {
@@ -109,13 +115,20 @@ func TestPlan12RecoveryFenceGrant(t *testing.T) {
escaped = g
return nil
}
- return g.ReleaseFencedTarget(ctx, ref)
+ var err error
+ out, err = g.ReleaseFencedTarget(ctx, ref)
+ return err
}, authority)
})
}
err := run()
if mode == "escaped" {
- err = escaped.ReleaseFencedTarget(t.Context(), ref)
+ out, err = escaped.ReleaseFencedTarget(t.Context(), ref)
+ }
+ // Only a dirty target behind a verified fence is verified-dirty
+ // (Task 5 fix round 1, item 1).
+ if (mode == "dirty") != out.VerifiedDirty || (mode == "dirty" || mode == "dirty-unregistered") && !errors.Is(err, ErrPending) {
+ t.Fatalf("%s: verified-dirty %v, err %v", mode, out.VerifiedDirty, err)
}
if mode != "ok" && mode != "admitted" && mode != "installed" && mode != "from-landing" && mode != "from-restoring" && mode != "record-released" {
if err == nil {
diff --git a/internal/hostops/plan13_fence_test.go b/internal/hostops/plan13_fence_test.go
index f4cdfe8eb..3260ed71b 100644
--- a/internal/hostops/plan13_fence_test.go
+++ b/internal/hostops/plan13_fence_test.go
@@ -44,7 +44,10 @@ func TestPlan13FenceRecoveryHomeAndNamed(t *testing.T) {
}
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)
+ return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error {
+ _, err := g.ReleaseFencedTarget(ctx, w.ref)
+ return err
+ }, w.authority)
})
}
if err := run(); err != nil {