package host import ( "context" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/provision" ) // Paul's ruling M4 (2026-10-10; spec §7.5, §19 K8): every move close is // record first, then the claim's release. Each box close checks that nothing // is owed under the op, clears the record's move into LastMove, and only then // releases the claim. A crash between the two halves leaves the record closed // and the claim still under the move; the box pass is driven by records with // a live move, so it never comes back, and the console's recovery pass // releases from LastMove (internal/console, TestRecordFirstCloseCrashIsFinishedByThePass). // // Each case crashes at "after-close-record", the seam between the halves, // and asserts the crash state: the record at rest with the outcome, the // claim still in the move's closing op with nothing owed, and a further // pass of every box writes nothing. // closedButHeld asserts the state between the halves of a record-first // close: the record closed with outcome, the claim still at kind/phase under // the move with nothing owed under it. func closedButHeld(t *testing.T, rec provision.Record, c *hostclaim.Claim, outcome, kind, phase string) { t.Helper() if rec.Move != nil || rec.LastMove == nil || rec.LastMove.ID != p1ID || rec.LastMove.Outcome != outcome { t.Fatalf("the record between the halves: move %+v last %+v, want closed %s", rec.Move, rec.LastMove, outcome) } if c.Op.ID != p1ID || c.Op.Kind != kind || c.Op.Phase != phase { t.Fatalf("the claim between the halves: %+v, want %s/%q under %s (the release is second)", c.Op, kind, phase, p1ID) } for _, o := range c.Obligations { if o.Op == p1ID { t.Fatalf("the record closed while %s %s%s was still owed under the op", o.Kind, o.Region, o.Box) } } } func TestBoxClosesAreRecordFirst(t *testing.T) { t.Parallel() t.Run("committed close (movesparks.go)", func(t *testing.T) { h := newCloseHarness(t) h.land(t) h.sourcePass(t) h.clock.Advance(24 * time.Hour) if err := h.regionalAck(t, h.chore(t, "ie", false)); err != nil { t.Fatal(err) } h.landAt = "after-close-record" h.crashLand(t) closedButHeld(t, h.read(t, p1Host), h.claim(t), "done", hostclaim.KindLanding, hostclaim.PhaseCommitted) gen := h.claim(t).Gen h.freshTarget() h.land(t) h.sourcePass(t) if c := h.claim(t); c.Gen != gen { t.Fatalf("a box pass after the record's close wrote the claim (%d to %d): %+v", gen, c.Gen, c.Op) } }) t.Run("abort (movesabort.go)", func(t *testing.T) { h := newAbortHarness(t) h.abort(t) h.abAt = "after-abort-record" for i := 0; i < 6 && !h.abDone; i++ { movesPass(context.Background(), h.s, h.w()) h.tPass(t) if o := h.laneChore(t); o != nil { h.pastHorizon(t, o) h.tPass(t) } } if !h.abDone { t.Fatalf("never reached after-abort-record (%v)", h.abHits) } closedButHeld(t, h.read(t, p1Host), h.claim(t), "aborted", hostclaim.KindMoving, hostclaim.PhaseAborting) h.ownStateGone(t) gen := h.claim(t).Gen h.worker1 = nil h.pass(t) h.tPass(t) if c := h.claim(t); c.Gen != gen { t.Fatalf("a box pass after the record's close wrote the claim (%d to %d): %+v", gen, c.Gen, c.Op) } }) t.Run("recovery close (movesrecover.go)", func(t *testing.T) { h := newRecoverHarness(t, false) h.released(t) h.srcPass(t) h.opened(t) h.rcAt = "after-close-record" h.closeOut(t) if !h.rcDone { t.Fatalf("never reached after-close-record (%v)", h.rcHits) } closedButHeld(t, h.read(t, p1Host), h.claim(t), "rolled_back", hostclaim.KindRestoring, hostclaim.PhaseOpened) if n := strings.Count(strings.Join(h.sEvents, " "), "rolled_back"); n != 1 { t.Fatalf("the terminal event %d times: %v", n, h.sEvents) } gen := h.claim(t).Gen h.sw = nil h.srcPass(t) h.rollbackPass(t) if c := h.claim(t); c.Gen != gen { t.Fatalf("a box pass after the record's close wrote the claim (%d to %d): %+v", gen, c.Gen, c.Op) } }) t.Run("revert close (movesrevert.go)", func(t *testing.T) { h := newRevertHarness(t) h.fromStopped(t) h.arm(t) h.pass(t) h.reopened(t) h.rvAt = "after-close-record" o := h.laneChore(t) h.tPass(t) h.pastHorizon(t, o) h.tPass(t) h.pass(t) if !h.rvDone { t.Fatalf("never reached after-close-record (%v)", h.rvHits) } closedButHeld(t, h.read(t, p1Host), h.claim(t), "rolled_back", hostclaim.KindReverting, hostclaim.PhaseOpened) gen := h.claim(t).Gen h.freshWorker() h.pass(t) h.tPass(t) if c := h.claim(t); c.Gen != gen { t.Fatalf("a box pass after the record's close wrote the claim (%d to %d): %+v", gen, c.Gen, c.Op) } }) }