carlos / platform Public

host: handHold, the hold hand-over that requests a root stage pass (Task 1 fix round 1, ruling J3)

registry.HandHold cannot reach the stage-requested marker, so the box
hands a move's hold to its rollback through host's handHold: under
WithHostHoldLock, and the marker is touched only when the hand-over
lands. A refusal, an absent row and a replay touch nothing. Later tasks
call the wrapper, never registry.HandHold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev fe7dc8af0c52bf77d342b36eef389ec516a7dd4b parent 35834df
3 files changed, +88 −2
  • internal/host/moveshold.go +35 −0
  • internal/host/moveshold_test.go +50 −0
  • internal/registry/registry.go +3 −2
diff --git a/internal/host/moveshold.go b/internal/host/moveshold.go
index 9128e77ea..3e791cf20 100644
--- a/internal/host/moveshold.go
+++ b/internal/host/moveshold.go
@@ -8,6 +8,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/schedule"
)
@@ -283,3 +284,37 @@ func holdStageRequester(dd string) func(host string) {
})
return func(host string) { req(host, "", nil) }
}
+
+// handHold is the box's hand-over of a move's hold to its rollback (ruling
+// J3): registry.HandHold under WithHostHoldLock, then written (in production
+// holdStageRequester) only when the hand-over landed, so root re-judges what
+// it staged under the new hold. A refusal, an absent row or a replay touches
+// nothing. Every caller hands a hold over through this wrapper, never through
+// registry.HandHold directly, which cannot reach the marker.
+//
+// true means the row is held status under toOp: the hand-over landed, or a
+// replay found it already done. false with no error means nothing matched:
+// the row is held by some other op, unheld, or absent.
+func handHold(reg *registry.Registry, written func(host string)) func(host, status, toOp, fromOp string) (bool, error) {
+ return func(host, status, toOp, fromOp string) (bool, error) {
+ var handed bool
+ var err error
+ provision.WithHostHoldLock(host, func() {
+ var rt *registry.Route
+ if rt, err = reg.Get(host); err != nil || rt == nil {
+ return
+ }
+ if rt.HoldOp == toOp && rt.Status == status && toOp != "" {
+ handed = true
+ return
+ }
+ if handed, err = reg.HandHold(host, status, toOp, fromOp); err != nil || !handed {
+ return
+ }
+ if written != nil {
+ written(host)
+ }
+ })
+ return handed, err
+ }
+}
diff --git a/internal/host/moveshold_test.go b/internal/host/moveshold_test.go
index 373c366d3..3ac4b21eb 100644
--- a/internal/host/moveshold_test.go
+++ b/internal/host/moveshold_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
+ "os"
"path/filepath"
"testing"
"time"
@@ -318,3 +319,52 @@ func TestMovesHoldsAreMainWithTheSwitchOff(t *testing.T) {
t.Fatal("no reconcile hooks with the switch on")
}
}
+
+// Ruling J3 (fix round 1): the hand-over of a move's hold to its rollback
+// touches the stage-requested marker when it lands, and only then.
+func TestHandHoldTouchesTheStageMarkerOnlyWhenItLands(t *testing.T) {
+ const rb = "rb_abcdefghijklmnopqrstuvwxyz"
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ if err := reg.Upsert(registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Status: "ready", Provisioned: true}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetHold(p1Host, "landing", p1ID); err != nil {
+ t.Fatal(err)
+ }
+ marker := func(dd string) bool {
+ _, err := os.Stat(filepath.Join(dd, stageRequestMarker))
+ return err == nil
+ }
+
+ // A refusal: the row is held by p1ID, not p1ID2. Nothing moves, no marker.
+ refused := t.TempDir()
+ if ok, err := handHold(reg, holdStageRequester(refused))(p1Host, "releasing", rb, p1ID2); err != nil || ok || marker(refused) {
+ t.Fatalf("refused hand-over: %v %v, marker %v", ok, err, marker(refused))
+ }
+ if rt, _ := reg.Get(p1Host); rt.Status != "landing" || rt.HoldOp != p1ID {
+ t.Fatalf("row changed by a refusal: %+v", rt)
+ }
+ // An absent row: no marker.
+ if ok, err := handHold(reg, holdStageRequester(refused))("absent.example.com", "releasing", rb, p1ID); err != nil || ok || marker(refused) {
+ t.Fatalf("absent row: %v %v, marker %v", ok, err, marker(refused))
+ }
+
+ // The hand-over: the row is the rollback's, and the marker is written.
+ landed := t.TempDir()
+ if ok, err := handHold(reg, holdStageRequester(landed))(p1Host, "releasing", rb, p1ID); err != nil || !ok || !marker(landed) {
+ t.Fatalf("hand-over: %v %v, marker %v", ok, err, marker(landed))
+ }
+ if rt, _ := reg.Get(p1Host); rt.Status != "releasing" || rt.HoldOp != rb {
+ t.Fatalf("after the hand-over: %+v", rt)
+ }
+
+ // A replay finds the row already the rollback's: done, nothing written.
+ var touched []string
+ if ok, err := handHold(reg, func(h string) { touched = append(touched, h) })(p1Host, "releasing", rb, p1ID); err != nil || !ok || len(touched) != 0 {
+ t.Fatalf("replay: %v %v, marker touched %d times", ok, err, len(touched))
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index ad3a2d270..73b5481f4 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -1127,8 +1127,9 @@ func (r *Registry) SetHoldIf(host, status, op, expectOp string) (bool, error) {
// with expectOp "" is that), and a row held by any other op is left alone.
// false with no error means nothing matched: the row's hold changed since
// the caller read it (a replay finds it already handed over), or there is
-// no row. Callers hold WithHostHoldLock over a fresh record read and, as
-// with SetHold, touch the stage-requested marker after a write that landed.
+// no row. The box never calls this directly: internal/host's handHold
+// wraps it under WithHostHoldLock and touches the stage-requested marker
+// after a hand-over that landed, which this package cannot reach.
func (r *Registry) HandHold(host, status, toOp, fromOp string) (bool, error) {
if !IsHold(status) || toOp == "" || fromOp == "" || toOp == fromOp {
return false, fmt.Errorf("registry: hand %s from %q to %q as %q is not a hand-over between two ops' holds", host, fromOp, toOp, status)