carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 6f1f0d36b4f40bcb6879ad00a2e3ea8d9f21cdc4 parent 2be0b79
6 files changed, +129 −45
  • internal/console/movesrecovery.go +9 −35
  • internal/console/movesrecovery_test.go +70 −0
  • internal/console/view_test.go +8 −1
  • internal/hostops/move_fence_release.go +24 −8
  • internal/hostops/plan12_fence_test.go +17 −0
  • internal/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