carlos / platform Public

settings-4: hooks for exec and park paths main added

The audit of origin/main since 30866251 found no new exec path and no new
lister at a holder location missing its build-state hook. It found one
registry interaction: main's RebindClaimRows (hostclaim recovery handover)
compares whole Route rows, now including build_state, the agent's copy of
the build-state object. That column moves at every reconcile refresh
(read_at) even while the instance is stopped, so a refresh between the
handover's read and its transaction refused the rebind, and a replay after
a lost reply or a crash was refused for good. RestoreClaimRows' replay
check had the same exposure since task 3.

Both now compare rows with build_state cleared (withoutBuildStateCopy).
Every other column still decides; neither path writes the column from the
caller's copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 087e3380d00ef3e54ddd0a45bd7b17da8d45187f parent bb0486c
3 files changed, +156 −5
  • internal/registry/buildstate_test.go +129 −0
  • internal/registry/coldhandover.go +22 −1
  • internal/registry/recoveryhandover.go +5 −4
diff --git a/internal/registry/buildstate_test.go b/internal/registry/buildstate_test.go
index 819644036..fd38e5849 100644
--- a/internal/registry/buildstate_test.go
+++ b/internal/registry/buildstate_test.go
@@ -2,6 +2,7 @@ package registry
import (
"context"
+ "errors"
"path/filepath"
"testing"
)
@@ -126,6 +127,134 @@ func TestBuildStateSurvivesColdHandover(t *testing.T) {
}
}
+// buildStateColLater is buildStateCol after one more reconcile refresh: only
+// read_at moved, as it does every reconcile even for a stopped host.
+const buildStateColLater = `{"known":true,"replica":true,"state":{"schema":1,"host":"a.example.com","last_executed":"v1","at":"2026-10-07T12:00:00Z"},"read_at":"2026-10-07T12:00:30Z"}`
+
+// A cold handover replayed after its lost reply must not be refused because
+// the agent refreshed the row's build-state copy in between: the column is
+// the agent's cache of the bucket object (snapshotChurnCols), not part of
+// the installation the replay checks.
+func TestColdHandoverReplayIgnoresABuildStateRefresh(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ ctx := context.Background()
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ oldOwner := ClaimOwnership{"inc", 7, "installation-7", "route-7"}
+ newOwner := ClaimOwnership{"inc", 8, "installation-8", "route-8"}
+ route := Route{Host: "shop.example", App: "app", ClaimOwnership: oldOwner}
+ if err = reg.Upsert(route); err != nil {
+ t.Fatal(err)
+ }
+ if err = reg.SetBuildState(route.Host, buildStateCol); err != nil {
+ t.Fatal(err)
+ }
+ current, err := reg.Get(route.Host)
+ if err != nil || current == nil {
+ t.Fatalf("Get: %v", err)
+ }
+ old := ColdRows{Route: *current}
+ next := ColdRows{Route: *current}
+ next.Route.ClaimOwnership = newOwner
+ targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: oldOwner}}
+ if err = reg.RetireClaimRows(ctx, "ret", targets, func([]ClaimRowArchive) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil {
+ t.Fatalf("handover refused: %v", err)
+ }
+ if err = reg.SetBuildState(route.Host, buildStateColLater); err != nil {
+ t.Fatal(err)
+ }
+ if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil {
+ t.Fatalf("replay after a build-state refresh refused: %v", err)
+ }
+ if got, _ := reg.Get(route.Host); got == nil || got.BuildState != buildStateColLater {
+ t.Fatalf("the replay rewrote the refreshed copy: %+v", got)
+ }
+ // Any other column still decides the replay.
+ if _, err = reg.sql.Exec(`UPDATE routes SET version='other' WHERE host=?`, route.Host); err != nil {
+ t.Fatal(err)
+ }
+ if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); !errors.Is(err, ErrClaimOwnership) {
+ t.Fatalf("replay over a changed row: %v", err)
+ }
+}
+
+// A recovery handover's rebind (main's RebindClaimRows) reads the row, then
+// rebinds it in a transaction, and replays from a fresh read after a lost
+// reply or a crash. A build-state refresh between any two of those steps is
+// not a concurrent replacement of the installation: it must neither refuse
+// the rebind nor its replay, and the rebind must not write the column.
+func TestRecoveryRebindIgnoresABuildStateRefresh(t *testing.T) {
+ for _, mode := range []string{"between-read-and-rebind", "replay-from-a-fresh-read", "replay-with-the-first-read"} {
+ t.Run(mode, func(t *testing.T) {
+ r, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ const host = "app.example"
+ oldOwner := ClaimOwnership{"inc", 1, "old", "old-row"}
+ newOwner := ClaimOwnership{"inc", 2, "new", "new-row"}
+ if err = r.Upsert(Route{Host: host, Kind: "instance", ClaimOwnership: oldOwner}); err != nil {
+ t.Fatal(err)
+ }
+ if err = r.SetBuildState(host, buildStateCol); err != nil {
+ t.Fatal(err)
+ }
+ saved, err := r.Get(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ old := ColdRows{Route: *saved}
+ next := old
+ next.Route.ClaimOwnership = newOwner
+ if mode == "between-read-and-rebind" {
+ if err = r.SetBuildState(host, buildStateColLater); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err = r.RebindClaimRows(t.Context(), old, next); err != nil {
+ t.Fatalf("rebind: %v", err)
+ }
+ if mode != "between-read-and-rebind" {
+ if err = r.SetBuildState(host, buildStateColLater); err != nil {
+ t.Fatal(err)
+ }
+ replayOld, replayNext := old, next
+ if mode == "replay-from-a-fresh-read" {
+ // As claimCrossBoxHandover.rebindRecoveryRows replays: a
+ // fresh Get, the old ownership from the inventory.
+ fresh, err := r.Get(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ replayOld, replayNext = ColdRows{Route: *fresh}, ColdRows{Route: *fresh}
+ replayOld.Route.ClaimOwnership = oldOwner
+ }
+ if err = r.RebindClaimRows(t.Context(), replayOld, replayNext); err != nil {
+ t.Fatalf("replay after a build-state refresh refused: %v", err)
+ }
+ }
+ got, err := r.Get(host)
+ if err != nil || got.ClaimOwnership != newOwner || got.BuildState != buildStateColLater {
+ t.Fatalf("route = %+v, %v", got, err)
+ }
+ // Any other column still decides the replay.
+ if _, err = r.sql.Exec(`UPDATE routes SET version='other' WHERE host=?`, host); err != nil {
+ t.Fatal(err)
+ }
+ if err = r.RebindClaimRows(t.Context(), old, next); !errors.Is(err, ErrClaimOwnership) {
+ t.Fatalf("replay over a changed row: %v", err)
+ }
+ })
+ }
+}
+
// The host's registry child carries the writer across the transport.
func TestRemoteSetBuildState(t *testing.T) {
r, _ := remoteTestRegistry(t)
diff --git a/internal/registry/coldhandover.go b/internal/registry/coldhandover.go
index d3514a109..c5d052688 100644
--- a/internal/registry/coldhandover.go
+++ b/internal/registry/coldhandover.go
@@ -30,6 +30,27 @@ func canonicalJSON[T any](stored []byte) ([]byte, error) {
return json.Marshal(v)
}
+// withoutBuildStateCopy is rt with its build_state column cleared, for the
+// claim handovers' row-identity checks (RestoreClaimRows' replay,
+// RebindClaimRows). The column is the agent's copy of the bucket's
+// build-state object, rewritten at every reconcile refresh even while the
+// instance is stopped (its read_at moves; snapshotChurnCols). A refresh
+// between a handover's read and its transaction, or before its replay, is
+// not a change to the installation, and refusing on it would leave a
+// handover that lost its reply unable ever to replay. Neither check writes
+// the column from the caller's copy on those paths.
+func withoutBuildStateCopy(rt Route) Route {
+ rt.BuildState = ""
+ return rt
+}
+
+// coldRowsWithoutBuildStateCopy is c with its route's build_state cleared
+// (withoutBuildStateCopy).
+func coldRowsWithoutBuildStateCopy(c ColdRows) ColdRows {
+ c.Route = withoutBuildStateCopy(c.Route)
+ return c
+}
+
func (r *Registry) RestoreClaimRows(ctx context.Context, id, retirement string, old ColdRows, next ColdRows) error {
if r.remote != nil {
_, err := r.remote.call(ctx, remoteRequest{Method: "RestoreClaimRows", Id: id, Retirement: retirement, Old: old, NextValue: next}, nil)
@@ -76,7 +97,7 @@ func (r *Registry) RestoreClaimRows(ctx context.Context, id, retirement string,
return ErrClaimOwnership
}
current, e := scanRoute(tx.QueryRowContext(ctx, `SELECT `+routeCols+` FROM routes WHERE host=?`, next.Route.Host).Scan)
- if e != nil || current != next.Route {
+ if e != nil || withoutBuildStateCopy(current) != withoutBuildStateCopy(next.Route) {
return ErrClaimOwnership
}
for _, want := range next.Timers {
diff --git a/internal/registry/recoveryhandover.go b/internal/registry/recoveryhandover.go
index cacc7cd42..2acfb3738 100644
--- a/internal/registry/recoveryhandover.go
+++ b/internal/registry/recoveryhandover.go
@@ -31,7 +31,7 @@ func (r *Registry) RebindClaimRows(ctx context.Context, old, next ColdRows) erro
recorded := err == nil
if recorded {
var rows struct{ Old, Next ColdRows }
- if json.Unmarshal(saved, &rows) != nil || rows.Old.Route != old.Route || !reflect.DeepEqual(rows.Next, next) {
+ if json.Unmarshal(saved, &rows) != nil || withoutBuildStateCopy(rows.Old.Route) != withoutBuildStateCopy(old.Route) || !reflect.DeepEqual(coldRowsWithoutBuildStateCopy(rows.Next), coldRowsWithoutBuildStateCopy(next)) {
return ErrClaimOwnership
}
old = rows.Old
@@ -41,7 +41,7 @@ func (r *Registry) RebindClaimRows(ctx context.Context, old, next ColdRows) erro
if !old.Route.ClaimOwnership.Valid() || !next.Route.ClaimOwnership.Valid() || old.Route.Host != next.Route.Host || old.Route.ClaimIncarnation != next.Route.ClaimIncarnation || next.Route.ClaimStamp <= old.Route.ClaimStamp || len(old.Timers) != len(next.Timers) {
return ErrClaimOwnership
}
- a, b := old.Route, next.Route
+ a, b := withoutBuildStateCopy(old.Route), withoutBuildStateCopy(next.Route)
a.ClaimOwnership, b.ClaimOwnership = ClaimOwnership{}, ClaimOwnership{}
if a != b {
return ErrClaimOwnership
@@ -61,8 +61,9 @@ func (r *Registry) RebindClaimRows(ctx context.Context, old, next ColdRows) erro
if err != nil {
return err
}
- replay := row == next.Route
- if !replay && row != old.Route {
+ row = withoutBuildStateCopy(row)
+ replay := row == withoutBuildStateCopy(next.Route)
+ if !replay && row != withoutBuildStateCopy(old.Route) {
return ErrClaimOwnership
}
rows, err := tx.conn.QueryContext(ctx, `SELECT `+timerCols+` FROM timers WHERE host=? ORDER BY name`, old.Route.Host)