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>
7 files changed,
+118
−25
internal/console/movesrecovery.go+9 −6internal/console/movesrecovery_test.go+60 −8internal/console/testdata/claim-authority-sites.txt+2 −2internal/hostops/move_fence_release.go+17 −1internal/hostops/plan12_fence_home_test.go+8 −2internal/hostops/plan12_fence_test.go+18 −5internal/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 { |