moves: the console logs a fence step from the grant's own evidence (Task 5 fix round 1, item 2)
FencedTargetOutcome carries the step's record transition (moves.TransitionOf over the record before and after the grant's own write) and ClaimRolledBack (the claim CAS rolled a landing claim back, read inside the CAS closure). The console queues exactly those, and no longer re-reads the record (logFenceStep is gone), keeping the rule at move_recovery_grant.go:131. Regression TestRecoveryPassLogsOnlyItsOwnFenceWrites: a box's record write lands just after the grant's claim write, then just after its record write (deletableStore.afterPut); the console logs claim_rollback, then rolling_back, and never the box's transition. Provision B8 record-write census: the record-writer unit is now MoveRecoveryGrant.releaseFencedTarget (item 1's wrapper renamed it; 2be0b79e alone fails TestPlan9B8RecordWriteCensus). Reviewed: it still writes only through provision.WriteRecord; digest updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+129
−45
internal/console/movesrecovery.go+9 −35internal/console/movesrecovery_test.go+70 −0internal/console/view_test.go+8 −1internal/hostops/move_fence_release.go+24 −8internal/hostops/plan12_fence_test.go+17 −0internal/provision/plan9_b8_census_test.go+1 −1
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go| index c2eca793e..3527f3d71 100644 |
| --- a/internal/console/movesrecovery.go |
| +++ b/internal/console/movesrecovery.go |
| @@ -92,7 +92,7 @@ import ( |
| // 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. |
| +// its own move writes, from the grant's outcome (the write's own evidence). |
| // |
| // Pages (§13) go through alerting.Page on the sinks cmd/console arms, best |
| // effort: a rollback waiting past its release deadline (an incident, again |
| @@ -468,7 +468,14 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant |
| if err != nil { |
| return err |
| } |
| - s.logFenceStep(ctx, o, c, rec, now) |
| + // The step's own writes, from the grant's evidence (fix round 1, |
| + // item 2), never from a later read of the record. |
| + if out.Transition != nil { |
| + s.queueMoveLog(o, *out.Transition, now) |
| + } |
| + if out.ClaimRolledBack { |
| + s.queueMoveLog(o, claimTransition(o, rec, "claim_rollback", "claim-rollback"), now) |
| + } |
| return nil |
| case moveActClose: |
| if !s.moveCloseOverdue(o, d.Record, now) { |
| @@ -518,39 +525,6 @@ func (s *Server) bucketFence(ctx context.Context, enrollment string) (*hostclaim |
| return &hostclaim.FenceRef{Enrollment: enrollment, Key: key, SHA256: hex.EncodeToString(sum[:])}, nil |
| } |
| -// logFenceStep queues the log entry of the write one fence step made, as the |
| -// console's own move write (fix round 3, ruling 1). A record write is the one |
| -// whose sequence follows the record the pass acted on, named by |
| -// moves.TransitionOf: the rollback (RollbackTargetFenced), the target marked |
| -// lost, or the loss committed. A step from a landing claim that wrote no |
| -// record was the claim's release, which rolled the claim back |
| -// (claim_rollback). A record that moved on twice, or cannot be read, loses |
| -// the entry with a journal line: the write itself stands. |
| -func (s *Server) logFenceStep(ctx context.Context, o hostops.ConsoleMoveObservation, c *hostclaim.Claim, rec provision.Record, now time.Time) { |
| - r, err := hostops.ReadRecord(ctx, s.cfg.Releases, c.Holder.Key) |
| - if err != nil { |
| - log.Printf("console: moves: %s: the log of move %s's fence step is lost: the record cannot be read: %v", o.Host, o.OpID, err) |
| - return |
| - } |
| - after, err := provision.DecodeRecord(r.Body) |
| - if err != nil { |
| - log.Printf("console: moves: %s: the log of move %s's fence step is lost: %v", o.Host, o.OpID, err) |
| - return |
| - } |
| - switch { |
| - case after.RecordSeq == rec.RecordSeq+1: |
| - if t, ok := moves.TransitionOf(rec, after); ok { |
| - s.queueMoveLog(o, t, now) |
| - } |
| - case after.RecordSeq == rec.RecordSeq: |
| - if c.Op.Kind == hostclaim.KindLanding { |
| - s.queueMoveLog(o, claimTransition(o, rec, "claim_rollback", "claim-rollback"), now) |
| - } |
| - default: |
| - log.Printf("console: moves: %s: the log of move %s's fence step is lost: the record moved on past it", o.Host, o.OpID) |
| - } |
| -} |
| - |
| // moveDueSince is when the pass first saw this close due, remembering now |
| // if it had not. The memory is the process's: a restarted console waits the |
| // grace again, which only delays the console's close or page. Entries a day |
diff --git a/internal/console/movesrecovery_test.go b/internal/console/movesrecovery_test.go| index 03bfd13a2..6e0922a83 100644 |
| --- a/internal/console/movesrecovery_test.go |
| +++ b/internal/console/movesrecovery_test.go |
| @@ -1776,6 +1776,76 @@ func TestRecoveryPassPagesOnlyAVerifiedDirtyTarget(t *testing.T) { |
| } |
| } |
| +// TestRecoveryPassLogsOnlyItsOwnFenceWrites (Task 5 fix round 1, item 2): |
| +// the console logs a fence step from the grant's own evidence, never from a |
| +// record read after it. A box's record write that lands just after the |
| +// grant's write (the claim's release, then the record's rollback) is not |
| +// logged as the console's, and the console's own entries are all there: |
| +// claim_rollback for the claim's step, rolling_back for the record's. |
| +func TestRecoveryPassLogsOnlyItsOwnFenceWrites(t *testing.T) { |
| + recordKey := provision.RecordKey("bab", "titogo", j4Host) |
| + claimKey, err := hostclaim.Key(j4Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // landAfter writes the record once, as the source box would (its |
| + // rollback's source phase), right after the next accepted Put of key. |
| + landAfter := func(t *testing.T, w *movePassWorld, key string) *bool { |
| + t.Helper() |
| + st := w.fx.rel.(*deletableStore) |
| + landed := new(bool) |
| + st.afterPut = func(_ context.Context, k string) { |
| + if k != key { |
| + return |
| + } |
| + st.afterPut = nil |
| + *landed = true |
| + j17SetRecord(t, w.fx, func(r *provision.Record) { |
| + if r.Move != nil && r.Move.Rollback != nil { |
| + r.Move.Rollback.SourcePhase = provision.RbSourceRestoring |
| + } |
| + }) |
| + } |
| + t.Cleanup(func() { st.afterPut = nil }) |
| + return landed |
| + } |
| + notTheConsoles := func(t *testing.T, w *movePassWorld) { |
| + t.Helper() |
| + if ev, ok := moveLogOf(t, w)["rolling_back-source-"+provision.RbSourceRestoring]; ok { |
| + t.Fatalf("the box's write was logged as the console's: %+v", ev) |
| + } |
| + } |
| + t.Run("the claim's step", func(t *testing.T) { |
| + w, _, _ := fencedTargetWorld(t, "code-mvpass-fence-log-claim") |
| + w.pass(t) // the record's rollback |
| + landed := landAfter(t, w, claimKey) |
| + w.pass(t) // the claim's release, which rolls the landing claim back |
| + if !*landed { |
| + t.Fatal("the box's write did not land after the claim's step") |
| + } |
| + if ev := moveLogOf(t, w)["claim-rollback"]; ev.Actor != "console" || ev.Event != "claim_rollback" { |
| + t.Fatalf("the claim's rollback was not logged: %+v in %+v", ev, moveLogOf(t, w)) |
| + } |
| + notTheConsoles(t, w) |
| + }) |
| + t.Run("the record's step", func(t *testing.T) { |
| + w, _, _ := fencedTargetWorld(t, "code-mvpass-fence-log-record") |
| + landed := landAfter(t, w, recordKey) |
| + w.pass(t) // the record's rollback |
| + if !*landed { |
| + t.Fatal("the box's write did not land after the record's step") |
| + } |
| + log := moveLogOf(t, w) |
| + if ev := log["rolling_back"]; ev.Actor != "console" || ev.Fields["reason"] != provision.RollbackTargetFenced { |
| + t.Fatalf("the fenced rollback was not logged: %+v in %+v", ev, log) |
| + } |
| + if _, ok := log["claim-rollback"]; ok { |
| + t.Fatalf("a record step logged a claim rollback: %+v", log) |
| + } |
| + notTheConsoles(t, w) |
| + }) |
| +} |
| + |
| // 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/view_test.go b/internal/console/view_test.go| index faaeffc31..06833ce5d 100644 |
| --- a/internal/console/view_test.go |
| +++ b/internal/console/view_test.go |
| @@ -23,13 +23,20 @@ type deletableStore struct { |
| // putHook, when set, runs before each Put reaches the store: a test's |
| // concurrent writer. |
| putHook func(ctx context.Context, key string) |
| + // afterPut, when set, runs after each Put the store accepted: a |
| + // test's writer that lands just after another's write. |
| + afterPut func(ctx context.Context, key string) |
| } |
| func (d *deletableStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| if h := d.putHook; h != nil { |
| h(ctx, key) |
| } |
| - return d.Store.Put(ctx, key, body, ifMatch) |
| + etag, err := d.Store.Put(ctx, key, body, ifMatch) |
| + if h := d.afterPut; h != nil && err == nil { |
| + h(ctx, key) |
| + } |
| + return etag, err |
| } |
| // DeleteIfMatch passes the conditional delete through to the wrapped store, |
diff --git a/internal/hostops/move_fence_release.go b/internal/hostops/move_fence_release.go| index 8f7e4cf58..d962738fb 100644 |
| --- a/internal/hostops/move_fence_release.go |
| +++ b/internal/hostops/move_fence_release.go |
| @@ -12,13 +12,19 @@ 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. |
| +// FencedTargetOutcome is what one ReleaseFencedTarget step found or wrote, |
| +// 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. Transition is the step's record write, named as the |
| +// move log names it (moves.TransitionOf over the record before and after the |
| +// write), and ClaimRolledBack that the step's claim write rolled a landing |
| +// claim back: the caller logs the write from this evidence, never from a |
| +// later read (Transition's rule). |
| type FencedTargetOutcome struct { |
| - VerifiedDirty bool |
| + VerifiedDirty bool |
| + Transition *moves.Transition |
| + ClaimRolledBack bool |
| } |
| // ReleaseFencedTarget advances one record-first recovery step per grant: |
| @@ -51,8 +57,15 @@ func (g *MoveRecoveryGrant) releaseFencedTarget(ctx context.Context, ref hostcla |
| if err != nil { |
| return err |
| } |
| - _, _, err = provision.WriteRecord(provision.WithRecordSequence(ctx), g.w.raw, record.Holder.Key, record.Body, body, record.ETag) |
| - return err |
| + if _, _, err = provision.WriteRecord(provision.WithRecordSequence(ctx), g.w.raw, record.Holder.Key, record.Body, body, record.ETag); err != nil { |
| + return err |
| + } |
| + if before, err := provision.DecodeRecord(record.Body); err == nil { |
| + if t, ok := moves.TransitionOf(before, rec); ok { |
| + out.Transition = &t |
| + } |
| + } |
| + return nil |
| } |
| if consumed, ok := hostclaim.ConsumedMoveTargetFence(o.Claim); ok { |
| if consumed != ref || m.Rollback == nil || m.State != provision.MoveRollingBack || rec.Region != m.From { |
| @@ -124,6 +137,7 @@ func (g *MoveRecoveryGrant) releaseFencedTarget(ctx context.Context, ref hostcla |
| return err |
| } |
| g.state.active = false |
| + landing := false |
| _, err = hostclaim.Update(ctx, g.w.raw, o, func(c *hostclaim.Claim) error { |
| record, err := ReadRecord(ctx, g.w.raw, h.Key) |
| if err != nil { |
| @@ -141,8 +155,10 @@ func (g *MoveRecoveryGrant) releaseFencedTarget(ctx context.Context, ref hostcla |
| } |
| r.Token = fencerelease.New(c.Host, c.Holder.Incarnation, c.Gen, r.OpID, r.RollbackID, r.Target) |
| defer r.Token.Revoke() |
| + landing = c.Op.Kind == hostclaim.KindLanding |
| return hostclaim.ReleaseFencedTarget(r)(c) |
| }) |
| + out.ClaimRolledBack = err == nil && landing |
| return err |
| } |
diff --git a/internal/hostops/plan12_fence_test.go b/internal/hostops/plan12_fence_test.go| index 59bbe4aef..a505d57a4 100644 |
| --- a/internal/hostops/plan12_fence_test.go |
| +++ b/internal/hostops/plan12_fence_test.go |
| @@ -142,6 +142,20 @@ func TestPlan12RecoveryFenceGrant(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| + // The outcome carries the step's own write (Task 5 fix round 1, |
| + // item 2): the record's transition, or the claim's rollback. |
| + wantEvent := map[string]string{"from-landing": "rolling_back", "from-restoring": "declared_lost"}[mode] |
| + if got := ""; out.Transition != nil || wantEvent != "" { |
| + if out.Transition != nil { |
| + got = out.Transition.Event |
| + } |
| + if got != wantEvent { |
| + t.Fatalf("%s: the record's transition %q, want %q", mode, got, wantEvent) |
| + } |
| + } |
| + if wantRollback := mode == "ok" || mode == "admitted" || mode == "installed"; out.ClaimRolledBack != wantRollback { |
| + t.Fatalf("%s: claim rollback %v, want %v", mode, out.ClaimRolledBack, wantRollback) |
| + } |
| if mode == "from-landing" || mode == "from-restoring" { |
| if mode == "from-landing" && w.claim(t).Claim.Op.Kind != hostclaim.KindLanding { |
| t.Fatal("record-first pass changed claim") |
| @@ -149,6 +163,9 @@ func TestPlan12RecoveryFenceGrant(t *testing.T) { |
| if err := run(); err != nil { |
| t.Fatal("claim pass", err) |
| } |
| + if out.Transition != nil || out.ClaimRolledBack != (mode == "from-landing") { |
| + t.Fatalf("%s: the claim pass's outcome %+v", mode, out) |
| + } |
| } |
| o := w.claim(t) |
| if o.Claim.Op.Phase != hostclaim.PhaseReleased || len(o.Claim.Op.Participants) != 1 || len(o.Claim.Obligations) != func() int { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 1edf558b3..ec0eb1ff1 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -478,7 +478,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/hostops/move_loss_admission.go", "moveLossOwner.ValidateLossRecovery", "key-site", "key:RecordKey", "1ad4af4595c764fc5051e50c2b187472cb20ce73c3b3e8245b8527228f326f5f"}, |
| {"internal/host/claimretirementcensus.go", "claimArchivedLocations", "key-site", "lit:instances", "9ba8ea4ecb5ad63621c1322467b9a80f8f5703896ba1fc3f7cb1addd56418950"}, |
| {"internal/host/claimretirementcensus.go", "claimLegacyIncarnation", "key-site", "lit:instances", "31a14ea105c67e00a24f06b9500a221ee5bfc47402b9c33cecfde807e51db530"}, |
| - {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.ReleaseFencedTarget", "record-writer", "key:WriteRecord,key:writeRecord", "f164e9f612ec87ddf02e6eaafd466549ebc2623b33aa1b8aae5122e1d3120a83"}, |
| + {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.releaseFencedTarget", "record-writer", "key:WriteRecord,key:writeRecord", "d2288f641c97eadf304e967042c96076a37d9d11443706f813a896e32826816b"}, |
| {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.StampLostMoveTargetParked", "record-writer", "key:WriteRecord", "911ab90f0786d646b494a62c2d061852c35273dc538408636ebf2ec613fa2198"}, |
| {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Convert", "record-writer", "key:WriteRecord", "eaf286018241c8d1f02ee37f5e2cac1b97eec657335519febf529ab902c0b511"}, |
| // The recovery pass's move log writer: one create-only entry of a move's |