carlos / platform Public

host: the cut stops on the activator's own lease; a holder anywhere else is refused at admission with a recorded reason (Task 5 fix round 1, rulings 1 and 2)

The final checkpoint and the stop evidence took a lease at the claim
holder's location. For a scoped holder that is a different object from the
one the activator's writer holds, so the cut could checkpoint under a
writer that had not stopped. Now both use activator.HibernatePrefix(prefix,
host), the claim's generation is bound on that lease (BindGeneration, or
BindRoot for the root layout, so every replica request checks its
authority), and a holder whose location is not that prefix goes back to
ready at requested and at admitted with RefusedReplicaLayout recorded
(MV-N167, pending). Later phases fail closed on such a holder.

Tests: the scoped-holder refusal at both phases, standing across a repin;
the real lease package against a fake S3, the writer's lease held: the
cut stays stopped with execution on the source, no report, nothing but
the lock written, no checkpoint; the production hook refuses a foreign
holder with no request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d3fc2196f36770713f3a0d82260e48966d938621 parent 37a5d35
7 files changed, +321 −35
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +1 −0
  • internal/host/movescut.go +86 −15
  • internal/host/movescut_test.go +158 −8
  • internal/host/movespass.go +21 −11
  • internal/provision/move_test.go +24 −0
  • internal/provision/provision.go +30 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index c9f84f8c5..ba3a55ebb 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -66,7 +66,7 @@ var movesCopyIDs = []string{
"MV-N145", "MV-N146", "MV-N147", "MV-N148", "MV-N149", "MV-N150",
"MV-N151", "MV-N152", "MV-N153", "MV-N154", "MV-N155", "MV-N156",
"MV-N157", "MV-N158", "MV-N159", "MV-N160", "MV-N161", "MV-N162",
- "MV-N163", "MV-N164", "MV-N165", "MV-N166",
+ "MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167",
}
// copyRow is one row of the record. The File cell is the file the sentence is
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index a0c658830..efbeeaec5 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -206,6 +206,7 @@ Columns: id, file, text, status.
| MV-N164 | internal/provision/provision.go | the final copy kept finding changes at the source | pending |
| MV-N165 | internal/provision/provision.go | the stopped files could not be archived | pending |
| MV-N166 | internal/host/movesphase1.go | <region> has more than one box. Moves between regions with several boxes are not supported yet. | pending |
+| MV-N167 | internal/provision/provision.go | the instance keeps its data under a per-incarnation path, which moves cannot cut yet | pending |
## Plan 2
diff --git a/internal/host/movescut.go b/internal/host/movescut.go
index 243ae4922..2d7bde6cb 100644
--- a/internal/host/movescut.go
+++ b/internal/host/movescut.go
@@ -16,6 +16,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
@@ -226,14 +227,19 @@ func (c *cutRun) requested(ctx context.Context) (bool, error) {
}
return c.backToReady(ctx, "the inputs pinned at readiness have changed")
}
+ if refused, err := c.refuseHolder(ctx); refused || err != nil {
+ return refused, err
+ }
now := cfg.Now()
budget := target.CutBudgetS
return c.cutTransition(ctx, "the admission", provision.MoveAdmit(c.g, now, budget, moves.CutDeadline(now, float64(budget))))
}
// admitted (§7.4): the pinned inputs still match the current bundle and
-// overlay versions, the release and the launch report, or back to ready.
-// A replay finds admitted and compares again; nothing has stopped.
+// overlay versions, the release and the launch report, and the claim
+// holder is one this box can stop under the writer's lease (ruling
+// T5-lease), or back to ready. A replay finds admitted and compares again;
+// nothing has stopped.
func (c *cutRun) admitted(ctx context.Context) (bool, error) {
same, err := c.inputsHold(ctx)
if err != nil {
@@ -242,9 +248,33 @@ func (c *cutRun) admitted(ctx context.Context) (bool, error) {
if !same {
return c.backToReady(ctx, "the inputs pinned at readiness have changed")
}
+ if refused, err := c.refuseHolder(ctx); refused || err != nil {
+ return refused, err
+ }
return c.advance(ctx, provision.CutAdmitted, provision.CutStopping, nil)
}
+// refuseHolder reads the claim fresh and, when its holder is not at the
+// activator's lease location (holderSupported), sends the cut back to
+// ready with RefusedReplicaLayout recorded (ruling T5-lease). The
+// refusal stands as long as the claim does: a passing round pins again,
+// the next cut is refused the same way, and the operator reads why.
+func (c *cutRun) refuseHolder(ctx context.Context) (bool, error) {
+ obs, err := c.claim(ctx)
+ if err != nil {
+ return false, err
+ }
+ if err := c.holderSupported(*obs.Claim.Holder); err != nil {
+ log.Printf("moves: %s: cut %s refused: %v", c.rec.Host, c.g.ID, err)
+ ok, err := c.cutTransition(ctx, "the refusal", provision.MoveBackToReadyRefused(c.g, provision.RefusedReplicaLayout))
+ if ok {
+ nudgeSerial(ctx, c.s)
+ }
+ return ok, err
+ }
+ return false, nil
+}
+
// inputsHold compares the record's pin with the current inputs.
func (c *cutRun) inputsHold(ctx context.Context) (bool, error) {
cur, err := c.w.currentInputs(ctx, c.s, c.rec)
@@ -357,6 +387,11 @@ func (c *cutRun) stopping(ctx context.Context) (bool, error) {
if cfg.Box == "" || obs.Claim.Op.Exec != cfg.Box {
return false, fmt.Errorf("move %s: the claim's executor is %q, not this box", c.g.ID, obs.Claim.Op.Exec)
}
+ // Admission refused any other holder; a claim that differs now is an
+ // error, never a stop (fail closed).
+ if err := c.holderSupported(*obs.Claim.Holder); err != nil {
+ return false, fmt.Errorf("move %s: %w", c.g.ID, err)
+ }
if cfg.HoldMoving == nil || cfg.Fence == nil || cfg.TimersDrained == nil || cfg.Evidence == nil {
return false, errors.New("this box cannot stop an instance for a move: a hook is not wired")
}
@@ -442,6 +477,9 @@ func (c *cutRun) stopped(ctx context.Context) (bool, error) {
return false, fmt.Errorf("move %s: the claim is %s/%q", c.g.ID, op.Kind, op.Phase)
}
holder := *obs.Claim.Holder
+ if err := c.holderSupported(holder); err != nil {
+ return false, fmt.Errorf("move %s: %w", c.g.ID, err)
+ }
stoppedKey := moves.StoppedKey(moves.ReportPrefix(c.rec.Account, c.rec.App, c.rec.Host, c.g.ID), "source")
var report moves.StoppedReport
switch {
@@ -499,7 +537,7 @@ func (c *cutRun) giveUpExecution(ctx context.Context, obs hostclaim.Observed, st
return moves.StoppedReport{}, errors.New("no final checkpoint on this box")
}
check := func(ctx context.Context) error { return c.stillExecuting(ctx) }
- if err := cfg.FinalCheckpoint(ctx, dbPath, holder, check); err != nil {
+ if err := cfg.FinalCheckpoint(ctx, host, dbPath, holder, check); err != nil {
return moves.StoppedReport{}, fmt.Errorf("final checkpoint: %w", err)
}
}
@@ -1475,13 +1513,45 @@ func localLTXPosition(dbPath string) (uint64, error) {
return uint64(newest), nil
}
+// errHolderLocation: the claim holder's location is not the activator's
+// own lease prefix, so the cut cannot stop its writer (ruling T5-lease).
+var errHolderLocation = errors.New("the claim holder is not at the activator's lease location")
+
+// writerLease is the one lease object the host's writer holds: the
+// activator's, at activator.HibernatePrefix(prefix, host), as
+// ExecBackend.Acquire takes it. Phase 1 cuts a holder only when its
+// location is exactly that (ruling T5-lease): the final checkpoint and the
+// stop evidence then exclude the real writer, and a scoped location
+// (instances/<host>/<incarnation>), whose lease would be a different
+// object, is refused at admission. The claim's generation is still bound
+// on that lease, so the replica lineage stays the holder's.
+func writerLease(prefix, host string, h hostclaim.Holder) (string, error) {
+ want := activator.HibernatePrefix(prefix, host)
+ if strings.TrimSuffix(h.Location, "/") != want {
+ return "", fmt.Errorf("%w: %s is at %q, not %q", errHolderLocation, host, h.Location, want)
+ }
+ return want, nil
+}
+
+// holderSupported is writerLease for this cut's box.
+func (c *cutRun) holderSupported(h hostclaim.Holder) error {
+ _, err := writerLease(c.cfg().OwnPrefix, c.rec.Host, h)
+ return err
+}
+
// finalCheckpoint is moveCfg.FinalCheckpoint in production (§5.3 step 5,
-// G3): the lease at the claim holder's location, bound to its generation's
-// prefix with check as the authority read before every replica request,
-// one checkpoint, and the lease released.
-func finalCheckpoint(bucket, region string) func(ctx context.Context, dbPath string, h hostclaim.Holder, check func(context.Context) error) error {
- return func(ctx context.Context, dbPath string, h hostclaim.Holder, check func(context.Context) error) error {
- held, err := lease.Acquire(ctx, bucket, strings.TrimSuffix(h.Location, "/"), region)
+// G3, ruling T5-lease): the activator's own lease for host, refused for a
+// holder anywhere else before any request; the replica client bound to the
+// claim's lineage with check as the authority read before every replica
+// request, a bound generation (BindGeneration) or the root layout
+// (BindRoot) alike; one checkpoint; the lease released.
+func finalCheckpoint(bucket, prefix, region string) func(ctx context.Context, host, dbPath string, h hostclaim.Holder, check func(context.Context) error) error {
+ return func(ctx context.Context, host, dbPath string, h hostclaim.Holder, check func(context.Context) error) error {
+ at, err := writerLease(prefix, host, h)
+ if err != nil {
+ return err
+ }
+ held, err := lease.Acquire(ctx, bucket, at, region)
if err != nil {
return err
}
@@ -1489,15 +1559,16 @@ func finalCheckpoint(bucket, region string) func(ctx context.Context, dbPath str
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := held.Release(rctx); err != nil {
- log.Printf("moves: release the checkpoint lease at %s: %v", h.Location, err)
+ log.Printf("moves: release the checkpoint lease at %s: %v", at, err)
}
}()
if g := h.Generation; g != "" && g != hostclaim.RootGeneration {
- prefix, _ := moves.ReplicaSource(h)
- if err := held.BindGeneration(prefix, check); err != nil {
- return err
- }
- } else if err := check(ctx); err != nil {
+ lineage, _ := moves.ReplicaSource(h)
+ err = held.BindGeneration(lineage, check)
+ } else {
+ err = held.BindRoot(check)
+ }
+ if err != nil {
return err
}
_, err = held.Checkpoint(ctx, dbPath)
diff --git a/internal/host/movescut_test.go b/internal/host/movescut_test.go
index d6acdb7a7..934d806c2 100644
--- a/internal/host/movescut_test.go
+++ b/internal/host/movescut_test.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
+ "net/http/httptest"
"os"
"path/filepath"
"sort"
@@ -11,8 +12,10 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
@@ -59,8 +62,21 @@ type cutHarness struct {
crashAt string
crashed bool
hits []string
+ // scoped keeps the Phase 1 harness's scoped holder
+ // (instances/<host>/<incarnation>), which the cut refuses (ruling
+ // T5-lease). By default ready moves the claim to the root location the
+ // activator's lease lives at, instances/<host>, generation cur.
+ scoped bool
}
+// cutRoot is the activator's lease prefix for the harness's host, the one
+// location Phase 1 cuts (ruling T5-lease); cutLineage is the claim
+// generation's replica under it, the prefix the lane mirrors.
+var (
+ cutRoot = "instances/" + p1Host
+ cutLineage = cutRoot + "/g-cur/"
+)
+
// PutFileIfAbsent makes the fake a lease.FilePutIfAbsent.
func (v *fakeVersioned) PutFileIfAbsent(ctx context.Context, bucket, key, path string) (string, error) {
body, err := os.ReadFile(path)
@@ -79,7 +95,6 @@ func newCutHarness(t *testing.T) *cutHarness {
h := &cutHarness{phase1Harness: newPhase1Harness(t), ev: &fakeEvidence{stopped: true}, localPos: 3,
bundle: map[string]string{"default": "e1", "environment": ""}}
h.timers = []registry.Timer{{Host: p1Host, Name: "nightly", Kind: "cron", Spec: "0 3 * * *", NextAt: 1000}}
- loc := "instances/" + p1Host + "/" + h.inc + "/"
last := ltxForTest(t, 4096, 4, 3, 3)
if err := os.WriteFile(h.route.DBPath, []byte("the stopped database"), 0o600); err != nil {
t.Fatal(err)
@@ -95,14 +110,14 @@ func newCutHarness(t *testing.T) *cutHarness {
}
return h.ev.report(), h.ev.err
}
- h.cfg.FinalCheckpoint = func(ctx context.Context, _ string, holder hostclaim.Holder, check func(context.Context) error) error {
+ h.cfg.FinalCheckpoint = func(ctx context.Context, host, _ string, holder hostclaim.Holder, check func(context.Context) error) error {
if err := check(ctx); err != nil {
return err
}
h.checkpoints++
prefix, _ := moves.ReplicaSource(holder)
- if prefix != loc+"g-cur/" {
- t.Errorf("checkpoint at %s, want the claim's lineage %sg-cur/", prefix, loc)
+ if host != p1Host || prefix != cutLineage {
+ t.Errorf("checkpoint of %s at %s, want %s at the claim's lineage %s", host, prefix, p1Host, cutLineage)
}
h.f.store("data-ie", prefix+lastTXKey, fakeMoveObject{lease.MirrorObject{ETag: "cur-c", Size: int64(len(last))}, string(last)})
return nil
@@ -145,10 +160,24 @@ func (h *cutHarness) target() moveCfg {
return c
}
-// ready drives the move to a persisted ready with its inputs pinned.
+// ready drives the move to a persisted ready with its inputs pinned. The
+// claim holder is at the root location unless the harness is scoped.
func (h *cutHarness) ready(t *testing.T) {
t.Helper()
h.start(t, "manual")
+ if !h.scoped {
+ // The Phase 1 harness keeps the replica under the scoped location;
+ // the cut's fixture is the root one, so the same objects move
+ // there and the claim follows.
+ scoped := "instances/" + p1Host + "/" + h.inc + "/"
+ for k, o := range h.f.buckets["data-ie"] {
+ if rel, ok := strings.CutPrefix(k, scoped); ok {
+ delete(h.f.buckets["data-ie"], k)
+ h.f.store("data-ie", cutRoot+"/"+rel, o)
+ }
+ }
+ rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) { c.Holder.Location = cutRoot })
+ }
h.pass(t)
h.targetPass(t, h.target())
h.pass(t)
@@ -330,10 +359,131 @@ func TestCutResumesStoppingFromEvidenceAlone(t *testing.T) {
t.Fatalf("evidence %d, fences %d, drains %d, holds %v", h.ev.calls, h.fences, h.drains, h.holds)
}
for _, loc := range h.locations {
- if loc != "instances/"+p1Host+"/"+h.inc {
- t.Fatalf("the lease step read %s, not the claim holder's location", loc)
+ if loc != cutRoot {
+ t.Fatalf("the lease step read %s, not the activator's lease location %s", loc, cutRoot)
+ }
+ }
+}
+
+// Ruling T5-lease: Phase 1 cuts only a holder whose location is the
+// activator's own lease prefix, activator.HibernatePrefix(prefix, host),
+// because the final checkpoint and the stop evidence take that very lease
+// object. A scoped holder (instances/<host>/<incarnation>) goes back to
+// ready at requested and at admitted, with the reason recorded, and
+// nothing stops. The refusal stands: a passing round pins again and the
+// next cut is refused the same way.
+func TestCutRefusesAScopedHolder(t *testing.T) {
+ ctx := context.Background()
+ check := func(t *testing.T, h *cutHarness, when string) {
+ t.Helper()
+ m := h.move(t)
+ if m.State != provision.MoveReady || m.CutPhase != "" || m.Inputs != nil || m.Reason != provision.RefusedReplicaLayout {
+ t.Fatalf("%s: not refused back to ready with the reason: %+v", when, m)
+ }
+ if h.ev.calls != 0 || len(h.holds) != 0 || h.fences != 0 || h.checkpoints != 0 {
+ t.Fatalf("%s: something stopped: evidence %d, holds %v, fences %d, checkpoints %d", when, h.ev.calls, h.holds, h.fences, h.checkpoints)
+ }
+ if c := h.claim(t); c.Op.Exec != "ie-box" || c.Op.Kind != hostclaim.KindMoving {
+ t.Fatalf("%s: the claim moved: %+v", when, c.Op)
+ }
+ }
+ h := newCutHarness(t)
+ h.scoped = true
+ h.ready(t)
+ if loc := h.claim(t).Holder.Location; loc != "instances/"+p1Host+"/"+h.inc {
+ t.Fatalf("fixture: holder at %s", loc)
+ }
+ h.cut(t)
+ h.pass(t)
+ check(t, h, "at requested")
+ h.pass(t)
+ if m := h.move(t); m.State != provision.MoveReady || m.Inputs == nil || m.Reason != provision.RefusedReplicaLayout {
+ t.Fatalf("the passing round did not pin again under the standing reason: %+v", m)
+ }
+ h.cut(t)
+ h.pass(t)
+ check(t, h, "the second cut")
+
+ h2 := newCutHarness(t)
+ h2.scoped = true
+ h2.ready(t)
+ h2.cut(t)
+ now := h2.clock.Now()
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h2.inc}
+ if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120))); err != nil {
+ t.Fatal(err)
+ }
+ h2.pass(t)
+ check(t, h2, "at admitted")
+}
+
+// Ruling T5-lease: the cut's final checkpoint takes the activator's own
+// lease object, with the real lease package against a fake S3. While the
+// activator's writer holds it the cut cannot checkpoint: execution stays
+// with the source, no report is published and nothing but the lock is
+// written. The production hook refuses a holder at any other location
+// before it asks for a lease at all.
+func TestCutCheckpointWaitsForTheActivatorsLease(t *testing.T) {
+ ctx := context.Background()
+ fake := leasetest.NewVersionedBucket()
+ fake.AddBucket("data-ie")
+ handler, requests := leasetest.NewS3Server(fake, "data-ie")
+ srv := httptest.NewServer(handler) // 127.0.0.1 only
+ defer srv.Close()
+ t.Setenv("CARLOS_S3_ENDPOINT", srv.URL)
+ t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
+ t.Setenv("AWS_SESSION_TOKEN", "")
+ t.Setenv("AWS_PROFILE", "")
+ t.Setenv("AWS_REGION", "")
+
+ h := newCutHarness(t)
+ h.cfg.FinalCheckpoint = finalCheckpoint("data-ie", "instances", "eu-west-1")
+ h.ready(t)
+ // The activator's writer, as ExecBackend.Acquire takes it.
+ writer, err := lease.Acquire(ctx, "data-ie", activator.HibernatePrefix("instances", p1Host), "eu-west-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer writer.Release(ctx)
+ h.cut(t)
+ h.pass(t)
+ m := h.move(t)
+ if m.State != provision.MoveCutting || m.CutPhase != provision.CutStopped {
+ t.Fatalf("with the writer's lease held: %+v", m)
+ }
+ if exec := h.claim(t).Op.Exec; exec != "ie-box" {
+ t.Fatalf("execution left the source: %q", exec)
+ }
+ if _, _, err := h.s.Get(ctx, h.stoppedKey()); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a stopped report was published: %v", err)
+ }
+ vers, err := fake.ListVersions(ctx, "data-ie", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, v := range vers {
+ if v.Key != activator.HibernatePrefix("instances", p1Host)+"/lock.json" {
+ t.Fatalf("the cut wrote %s while the writer held the lease", v.Key)
}
}
+ meta := filepath.Join(filepath.Dir(h.route.DBPath), ".activator-"+filepath.Base(h.route.DBPath)+"-litestream")
+ if _, err := os.Stat(meta); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a checkpoint ran: %s exists (%v)", meta, err)
+ }
+ holder := *h.claim(t).Holder
+ var he *lease.HeldError
+ if err := h.cfg.FinalCheckpoint(ctx, p1Host, h.route.DBPath, holder, func(context.Context) error { return nil }); !errors.As(err, &he) {
+ t.Fatalf("the hook against the held lease: %v", err)
+ }
+ // A holder away from the activator's prefix: refused with no request.
+ before := requests()
+ foreign := holder
+ foreign.Location = "instances/" + p1Host + "/" + h.inc
+ err = h.cfg.FinalCheckpoint(ctx, p1Host, h.route.DBPath, foreign, func(context.Context) error { return nil })
+ if !errors.Is(err, errHolderLocation) || requests() != before {
+ t.Fatalf("a scoped holder: %v (%d requests)", err, requests()-before)
+ }
}
// K3 and K13: the stopped report exists, complete with the replica step
@@ -498,7 +648,7 @@ func TestSealNamesEveryCurrentVersionAndN(t *testing.T) {
// The witness.
stale := seal.Replica[0]
if _, _, err := h.f.CopyVersionedAt(context.Background(), h.clock.Now(), "data-ie",
- "instances/"+p1Host+"/"+h.inc+"/g-cur/0000/0000000000000001-0000000000000002.ltx", "", "data-uk", stale.Key); err != nil {
+ cutLineage+"0000/0000000000000001-0000000000000002.ltx", "", "data-uk", stale.Key); err != nil {
t.Fatal(err)
}
if cur := h.f.versionOf("data-uk", stale.Key); cur == stale.VersionID {
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index 16b45018b..7a6340391 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -268,7 +268,7 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin
return filepath.Join(dataDir, host), filepath.Join(dataDir, host+".env")
}
// The cut (movescut.go).
- cfg.FinalCheckpoint = finalCheckpoint(hib.Bucket, hib.Region)
+ cfg.FinalCheckpoint = finalCheckpoint(hib.Bucket, hib.Prefix, hib.Region)
cfg.LocalPosition = localLTXPosition
cfg.InputVersions = func(ctx context.Context, s release.Store, rec provision.Record) (map[string]string, string, error) {
_, v, err := appconfig.LoadMergedHostVersions(ctx, s, rec.Account, rec.App, rec.Environment, rec.Host)
@@ -284,11 +284,19 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin
if eb != nil {
// A box with no delegated cgroup has no manager, and Reconstruct
// refuses it: such a box cannot produce stop evidence, so it never
- // cuts (Task 3).
+ // cuts (Task 3). The lease step reads the activator's own lock, the
+ // one its writer holds (ExecBackend.Acquire); the cut has refused
+ // any holder whose location is not that prefix before it gets
+ // here (ruling T5-lease), and a location that still differs is an
+ // error, never evidence.
cfg.Evidence = func(ctx context.Context, host, location string, dbPaths []string) (moves.StoppedReport, error) {
+ writerPrefix := activator.HibernatePrefix(hib.Prefix, host)
+ if strings.TrimSuffix(location, "/") != writerPrefix {
+ return moves.StoppedReport{}, fmt.Errorf("%w: %s is at %q, not the writer's lease %q", errHolderLocation, host, location, writerPrefix)
+ }
return stopevidence.Reconstruct(ctx, stopevidence.ReconstructInputs{Host: host, Cgroups: eb.Cgroups, DBPaths: dbPaths,
LeaseFree: func(ctx context.Context) (bool, error) {
- held, _, _, err := lease.LockState(ctx, hib.Bucket, location, hib.Region)
+ held, _, _, err := lease.LockState(ctx, hib.Bucket, writerPrefix, hib.Region)
return !held, err
}})
}
@@ -449,17 +457,19 @@ type moveCfg struct {
// "moving" under the move id (holdMoving). TimersDrained waits until
// no timer delivery or convergence that could have missed the hold is
// still running (the hold view's drainTimers, Task 6). Evidence is
- // §5.3 steps 1 to 4 (stopevidence.Reconstruct, its lease step read at
- // the claim holder's location). FinalCheckpoint is step 5's checkpoint
- // under the claim's lineage, check being the authority read before
- // each replica request (G3). LocalPosition is the stopped database's
- // TXID (the brief's OwnReplicaPosition, G22). Timers reads the host's
- // timer rows. InputVersions reads the current bundle etags (default,
- // environment) and overlay etag, as T3 pinned them.
+ // §5.3 steps 1 to 4 (stopevidence.Reconstruct, its lease step read on
+ // the activator's own lease, which ruling T5-lease requires to be the
+ // claim holder's location). FinalCheckpoint is step 5's checkpoint on
+ // that same lease, bound to the claim's lineage, check being the
+ // authority read before each replica request (G3). LocalPosition is
+ // the stopped database's TXID (the brief's OwnReplicaPosition, G22).
+ // Timers reads the host's timer rows. InputVersions reads the current
+ // bundle etags (default, environment) and overlay etag, as T3 pinned
+ // them.
HoldMoving func(host, op string) error
TimersDrained func(ctx context.Context, host string) error
Evidence func(ctx context.Context, host, location string, dbPaths []string) (moves.StoppedReport, error)
- FinalCheckpoint func(ctx context.Context, dbPath string, holder hostclaim.Holder, check func(context.Context) error) error
+ FinalCheckpoint func(ctx context.Context, host, dbPath string, holder hostclaim.Holder, check func(context.Context) error) error
LocalPosition func(dbPath string) (uint64, error)
Timers func(host string) ([]registry.Timer, error)
InputVersions func(ctx context.Context, s release.Store, rec provision.Record) (bundle map[string]string, overlay string, err error)
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go
index 22afd4eda..4591248dc 100644
--- a/internal/provision/move_test.go
+++ b/internal/provision/move_test.go
@@ -540,3 +540,27 @@ func TestFlipIsOneRecordWrite(t *testing.T) {
t.Fatalf("persisted: %+v", read.Move)
}
}
+
+// A back-to-ready may record why (Task 5 fix round 1, ruling 1): the
+// refusal of a holder whose replica layout Phase 1 cannot cut. The reason
+// is a recorded sentence; anything else is refused and nothing is written.
+// The next admission clears it, so a reason never outlives the refusal.
+func TestBackToReadyRecordsARefusal(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Now()
+ rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutAdmitted, CutAt: &now, CutBudgetS: 120, CutDeadline: &now, Inputs: &MoveInputs{Release: "v1"}}}
+ if err := MoveBackToReadyRefused(g, "x")(rec); err == nil || rec.Move.State != MoveCutting || rec.Move.Reason != "" {
+ t.Fatalf("an unrecorded reason: %v %+v", err, rec.Move)
+ }
+ if err := MoveBackToReadyRefused(g, RefusedReplicaLayout)(rec); err != nil || rec.Move.State != MoveReady || rec.Move.CutPhase != "" || rec.Move.Inputs != nil || rec.Move.Reason != RefusedReplicaLayout {
+ t.Fatalf("refused: %v %+v", err, rec.Move)
+ }
+ if err := MoveBackToReadyRefused(g, RefusedReplicaLayout)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatal("refused from ready")
+ }
+ rec.Move.Inputs = &MoveInputs{Release: "v1"}
+ rec.Move.State, rec.Move.CutPhase = MoveCutting, CutRequested
+ if err := MoveAdmit(g, now, 120, now)(rec); err != nil || rec.Move.Reason != "" {
+ t.Fatalf("the admission did not clear the reason: %v %+v", err, rec.Move)
+ }
+}
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index f7f72e0c7..705633768 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -1369,10 +1369,40 @@ func MoveAdmit(g MoveGuard, now time.Time, budgetS int, deadline time.Time) func
return MoveCutPhase(g, CutRequested, CutAdmitted, func(m *MoveState) {
t, d := now.UTC(), deadline.UTC()
m.CutAt, m.CutBudgetS, m.CutDeadline = &t, budgetS, &d
+ // A refusal an earlier cut recorded (MoveBackToReadyRefused) is
+ // over once a cut admits.
+ m.Reason = ""
})(rec)
}
}
+// RefusedReplicaLayout is the reason MoveBackToReadyRefused records when
+// the claim holder's location is not the activator's own lease prefix
+// (moves Task 5 fix round 1, ruling T5-lease): a scoped location,
+// instances/<host>/<incarnation>, whose replica the cut cannot stop under
+// the writer's lease. Moving such a host waits for a later plan. A
+// recorded sentence (copy record MV-N167), like the Revert reasons.
+const RefusedReplicaLayout = "the instance keeps its data under a per-incarnation path, which moves cannot cut yet"
+
+// MoveBackToReadyRefused is MoveBackToReady with the reason recorded on the
+// move, for a refusal that stands until something outside the move
+// changes (the operator reads it in `carlos move status`). reason must be
+// a recorded constant; any other text is refused and nothing is written.
+func MoveBackToReadyRefused(g MoveGuard, reason string) func(*Record) error {
+ return func(rec *Record) error {
+ switch reason {
+ case RefusedReplicaLayout:
+ default:
+ return fmt.Errorf("provision: refusal reason %q has no recorded sentence", reason)
+ }
+ if err := MoveBackToReady(g)(rec); err != nil {
+ return err
+ }
+ rec.Move.Reason = RefusedReplicaLayout
+ return nil
+ }
+}
+
// MoveBackToReady undoes an admission whose checks are no longer fresh or
// whose inputs moved (§7.4 admitted). Legal from requested or admitted
// only: nothing has stopped yet. It clears the cut fields and the pinned