carlos / platform Public

host: root's held release under the claim, over the root control socket (moves Task 7b, platform-1e reviews the publish path)

POST /claim/held-release on the claim-mode instance-control socket, the
peer authenticated as the edge unit's main process (R10's transport; no
request file, #267). Wired only with CARLOS_MOVES_PHASE1 on.

stage: root re-reads the claim (holder tuple, op, this box the target),
the record, the registry row and the source's report, then
release.StageHeldVerified. No selection, no hostguard, live/<host> never
touched.

publish: a receipt for exactly this request (session, render hash,
identity) answers a replay with no read and no effect. Otherwise root
re-reads the claim, record and row; the version must be the pin (landing
configured or probing) or the return's release (dirty configured, probe)
or the revert's; the render hash journalled under the hold op must be
present (else a typed not-yet at once, holding nothing) and equal the
request's, read through safefile (no symlink anywhere, regular file, one
link, bounded). Root then selects held-release itself, requires the
selection's tuple to be the request's, and runs MaterializeHeldUnder
inside it with the held-stage lock. Moving publishes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ed97cbc73c471f3fbff53fc9c9f5e9186ac12fb2 parent cf0b6de
11 files changed, +1934 −7
  • approved_moves_copy_test.go +5 −0
  • internal/host/claimlaunch_production.go +14 −3
  • internal/host/claimstop_run.go +5 −0
  • internal/host/movesheldrelease.go +362 −0
  • internal/host/movesheldrelease_linux.go +59 −0
  • internal/host/movesheldrelease_linux_test.go +204 −0
  • internal/host/movesheldrelease_other.go +14 −0
  • internal/host/movesheldrelease_production.go +80 −0
  • internal/host/movesheldrelease_root.go +503 −0
  • internal/host/movesheldrelease_test.go +681 −0
  • internal/release/materializeheld.go +7 −4
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index d0413a447..6d98e4f3d 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -1237,6 +1237,11 @@ var movesScanFiles = []string{
"internal/host/movesrevert.go",
"internal/host/movesrollback.go",
"internal/host/movesstage.go",
+ // moves after claim Plan 12, Task 7b: root's held release.
+ "internal/host/movesheldrelease.go",
+ "internal/host/movesheldrelease_linux.go",
+ "internal/host/movesheldrelease_production.go",
+ "internal/host/movesheldrelease_root.go",
"internal/host/movesstore.go",
"internal/host/refusalwatch.go",
"internal/host/systemapply.go",
diff --git a/internal/host/claimlaunch_production.go b/internal/host/claimlaunch_production.go
index f122c3dc6..cc2edf67d 100644
--- a/internal/host/claimlaunch_production.go
+++ b/internal/host/claimlaunch_production.go
@@ -14,13 +14,24 @@ import (
)
// ClaimRootWithLaunch is the claim-mode root handler with the isolated
-// launch route beside the existing claim verbs.
+// launch route beside the existing claim verbs, and the moves held-release
+// route (Task 7b), which is nil, and refuses, unless CARLOS_MOVES_PHASE1 is
+// on (WithProductionMovesHeldRelease).
type ClaimRootWithLaunch struct {
- Root ClaimRootHTTP
- Launch *ClaimLaunchRoot
+ Root ClaimRootHTTP
+ Launch *ClaimLaunchRoot
+ HeldRelease http.Handler
}
func (h ClaimRootWithLaunch) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == heldReleasePath {
+ if h.HeldRelease == nil {
+ http.Error(w, "refused", http.StatusServiceUnavailable)
+ return
+ }
+ h.HeldRelease.ServeHTTP(w, r)
+ return
+ }
if r.URL.Path != "/claim/launch" {
h.Root.ServeHTTP(w, r)
return
diff --git a/internal/host/claimstop_run.go b/internal/host/claimstop_run.go
index 9c8efa9b0..97bfdf93a 100644
--- a/internal/host/claimstop_run.go
+++ b/internal/host/claimstop_run.go
@@ -52,6 +52,11 @@ func RunInstanceControl(args []string) {
if err != nil {
log.Fatal(err)
}
+ // moves Task 7b: root's held release, only with CARLOS_MOVES_PHASE1 on.
+ handler, err = WithProductionMovesHeldRelease(startup, handler, reg, claimEntryEnv(ctx))
+ if err != nil {
+ log.Fatal(err)
+ }
recovery, ok := handler.(interface{ RecoverPending(context.Context) error })
if !ok {
log.Fatal(ErrClaimStopUnavailable)
diff --git a/internal/host/movesheldrelease.go b/internal/host/movesheldrelease.go
new file mode 100644
index 000000000..779101ba6
--- /dev/null
+++ b/internal/host/movesheldrelease.go
@@ -0,0 +1,362 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "regexp"
+ "time"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/regionname"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The held release under the host claim (moves after claim Plan 12, Task
+// 7b; task-7b-design.md with platform-1e's binding review of 2026-10-12).
+//
+// Under the claim the sandboxed agent cannot write /opt/carlos, so it can
+// neither stage T7's release nor publish live/<host> for the held row. It
+// asks root instead, over the root instance-control socket (claim Plan 11's
+// R10 transport: POST /claim/held-release, the peer authenticated as the
+// edge unit's main process), never through a file root would read by name
+// (#267). There are two actions:
+//
+// - stage: T7. Root stages and verifies the pinned release into the
+// shared version store (release.StageHeldVerified) and never touches
+// live/<host>. The agent then verifies the staged bytes itself; root's
+// answer is a hint.
+// - publish: the landing's configured step, a dirty return's configured
+// step and a probe's release. Root publishes live/<host> inside its own
+// held-release selection (release.MaterializeHeldUnder with the
+// held-stage lock), and writes a receipt keyed by the session and the
+// render hash, so a replay after success is a no-op with the same
+// answer.
+//
+// Every request field is an expectation root compares with authoritative
+// state it reads itself (the claim, the record, the registry row, the
+// render journal); none grants anything. The route is new, so an older root
+// answers 404 and never reads a stage as a publication, and the old file
+// relay (release.ServeHeldStageRequests) is never handed a request by this
+// path.
+
+const (
+ heldReleaseSchema = "carlos.moves.held-release/1"
+ heldReleaseStage = "stage"
+ heldReleasePublish = "publish"
+ heldReleaseOutcomeStaged = "staged"
+ heldReleaseOutcomePublished = "published"
+ heldReleasePath = "/claim/held-release"
+ // heldReleaseRequestMax bounds a request body: its fields fit many times
+ // over.
+ heldReleaseRequestMax = 4096
+ // heldReleaseDeadline bounds one call. A stage may download the build,
+ // as T7 does in the agent with the claim off.
+ heldReleaseDeadline = 15 * time.Minute
+)
+
+var (
+ errHeldReleaseShape = errors.New("moves: held release: the request is malformed")
+ errHeldReleaseUnavailable = errors.New("moves: held release: root serves no held-release requests")
+ // errHeldReleaseNotYet is root finding no render hash journalled for
+ // the publication (platform-1e Q2): it refuses at once, holding nothing,
+ // and the agent asks again on a later pass.
+ errHeldReleaseNotYet = errors.New("moves: held release: the held render's hash is not journalled yet")
+ errHeldReleaseUnsafe = errors.New("moves: held release: the render journal is not a regular file with one link and a bounded size")
+)
+
+// heldReleaseRequest is one request. Kind, Phase, Incarnation, Stamp,
+// Session and Generation are the claim tuple the agent observed; MoveID is
+// the claim's op id; HoldOp the op its registry row is held by (the
+// rollback id for a return); RenderHash, on a publication, the held
+// render's journalled hash ("sha256:<hex>").
+type heldReleaseRequest struct {
+ Schema string `json:"schema"`
+ RequestID string `json:"request_id"`
+ Action string `json:"action"`
+ Host string `json:"host"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Version string `json:"version"`
+ MoveID string `json:"move_id"`
+ HoldOp string `json:"hold_op"`
+ Box string `json:"box"`
+ Region string `json:"region"`
+ Incarnation string `json:"incarnation"`
+ Stamp uint64 `json:"stamp"`
+ Session uint64 `json:"session"`
+ Generation string `json:"generation"`
+ Kind string `json:"kind"`
+ Phase string `json:"phase"`
+ RenderHash string `json:"render_hash,omitempty"`
+}
+
+type heldReleaseResponse struct {
+ Schema string `json:"schema"`
+ RequestID string `json:"request_id"`
+ Outcome string `json:"outcome"`
+ Replayed bool `json:"replayed,omitempty"`
+}
+
+var (
+ heldReleaseIDShape = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
+ heldReleaseBoxShape = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)
+ heldReleaseHexShape = regexp.MustCompile(`^[0-9a-f]{64}$`)
+ heldReleaseRenderShape = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
+ heldReleaseTokenShape = regexp.MustCompile(`^[A-Za-z0-9._:@-]{0,128}$`)
+)
+
+// validate is the shape root trusts before any read: every name a path
+// component or a key could be built from, and the action's own fields.
+func (q heldReleaseRequest) validate() error {
+ bad := func(what string) error { return fmt.Errorf("%w: %s", errHeldReleaseShape, what) }
+ switch {
+ case q.Schema != heldReleaseSchema:
+ return bad("schema")
+ case q.Action != heldReleaseStage && q.Action != heldReleasePublish:
+ return bad("action")
+ case !heldReleaseIDShape.MatchString(q.RequestID):
+ return bad("request id")
+ case !controlname.ValidHostShape(q.Host):
+ return bad("host")
+ case !release.ValidAccount(q.Account):
+ return bad("account")
+ case !release.ValidApp(q.App):
+ return bad("app")
+ case !release.ValidVersion(q.Version):
+ return bad("version")
+ case !moves.ValidOpID(q.MoveID) || moves.OpIDKind(q.MoveID) != "move":
+ return bad("move id")
+ case !moves.ValidOpID(q.HoldOp):
+ return bad("hold op")
+ case !heldReleaseBoxShape.MatchString(q.Box):
+ return bad("box")
+ case q.Region != regionname.HomeRegion && (q.Region == "" || regionname.PlacementRegion(q.Region) != q.Region):
+ return bad("region")
+ case !heldReleaseHexShape.MatchString(q.Incarnation):
+ return bad("incarnation")
+ case q.Stamp == 0:
+ return bad("stamp")
+ case !heldReleaseTokenShape.MatchString(q.Generation) || len(q.Phase) > 64 || !heldReleaseTokenShape.MatchString(q.Phase):
+ return bad("generation or phase")
+ }
+ switch q.Kind {
+ case hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindRestoring, hostclaim.KindReverting:
+ default:
+ return bad("kind")
+ }
+ if q.Action == heldReleasePublish && !heldReleaseRenderShape.MatchString(q.RenderHash) {
+ return bad("render hash")
+ }
+ if q.Action == heldReleaseStage && q.RenderHash != "" {
+ return bad("a stage names no render")
+ }
+ return nil
+}
+
+// decodeHeldReleaseRequest reads one request body: bounded, one JSON object,
+// no field the protocol does not carry.
+func decodeHeldReleaseRequest(r io.Reader) (heldReleaseRequest, error) {
+ body, err := io.ReadAll(io.LimitReader(r, heldReleaseRequestMax+1))
+ if err != nil || len(body) > heldReleaseRequestMax {
+ return heldReleaseRequest{}, fmt.Errorf("%w: unreadable or oversized", errHeldReleaseShape)
+ }
+ dec := json.NewDecoder(bytes.NewReader(body))
+ dec.DisallowUnknownFields()
+ var q heldReleaseRequest
+ if err := dec.Decode(&q); err != nil {
+ return heldReleaseRequest{}, fmt.Errorf("%w: %v", errHeldReleaseShape, err)
+ }
+ if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
+ return heldReleaseRequest{}, fmt.Errorf("%w: trailing data", errHeldReleaseShape)
+ }
+ return q, nil
+}
+
+func newHeldReleaseRequestID() string {
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return fmt.Sprintf("t%d", time.Now().UnixNano())
+ }
+ return hex.EncodeToString(b[:])
+}
+
+// heldReleaseRelay is the agent's way to root.
+type heldReleaseRelay interface {
+ HeldRelease(context.Context, heldReleaseRequest) (heldReleaseResponse, error)
+}
+
+// heldReleaseRootClient is the agent's client of POST /claim/held-release
+// on the root instance-control socket.
+type heldReleaseRootClient struct {
+ Socket string
+ // Dial is the dialer; nil dials Socket as a unix socket.
+ Dial func(context.Context, string) (net.Conn, error)
+}
+
+func newHeldReleaseRootClient(socket string) *heldReleaseRootClient {
+ return &heldReleaseRootClient{Socket: socket}
+}
+
+// HeldRelease sends q and returns root's answer for exactly q. Root's
+// refusals come back typed: a stale or forged request wraps
+// release.ErrStaleHeldStage, a release D3 does not admit
+// release.ErrHeldNotProven, no render journal errHeldReleaseNotYet, and a
+// root with no such route (an older build, or one without its providers)
+// errHeldReleaseUnavailable.
+func (c *heldReleaseRootClient) HeldRelease(ctx context.Context, q heldReleaseRequest) (heldReleaseResponse, error) {
+ if c == nil || c.Socket == "" {
+ return heldReleaseResponse{}, errHeldReleaseUnavailable
+ }
+ body, err := json.Marshal(q)
+ if err != nil {
+ return heldReleaseResponse{}, err
+ }
+ ctx, cancel := context.WithTimeout(ctx, heldReleaseDeadline)
+ defer cancel()
+ dial := c.Dial
+ if dial == nil {
+ dial = func(ctx context.Context, path string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "unix", path)
+ }
+ }
+ transport := &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dial(ctx, c.Socket) }}
+ defer transport.CloseIdleConnections()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://control"+heldReleasePath, bytes.NewReader(body))
+ if err != nil {
+ return heldReleaseResponse{}, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := (&http.Client{Transport: transport}).Do(req)
+ if err != nil {
+ return heldReleaseResponse{}, fmt.Errorf("moves: held release: %w", err)
+ }
+ defer resp.Body.Close()
+ defer io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
+ switch resp.StatusCode {
+ case http.StatusOK:
+ case http.StatusBadRequest:
+ return heldReleaseResponse{}, errHeldReleaseShape
+ case http.StatusForbidden:
+ return heldReleaseResponse{}, ErrClaimStopIdentity
+ case http.StatusConflict:
+ return heldReleaseResponse{}, staleHeldStage("root refused the held release")
+ case http.StatusUnprocessableEntity:
+ return heldReleaseResponse{}, fmt.Errorf("%w: root's proof gate refused it", release.ErrHeldNotProven)
+ case http.StatusTooEarly:
+ return heldReleaseResponse{}, errHeldReleaseNotYet
+ case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusServiceUnavailable:
+ return heldReleaseResponse{}, errHeldReleaseUnavailable
+ default:
+ return heldReleaseResponse{}, fmt.Errorf("moves: held release: root answered %d", resp.StatusCode)
+ }
+ dec := json.NewDecoder(io.LimitReader(resp.Body, heldReleaseRequestMax))
+ dec.DisallowUnknownFields()
+ var out heldReleaseResponse
+ if err := dec.Decode(&out); err != nil {
+ return heldReleaseResponse{}, fmt.Errorf("moves: held release: root's answer: %w", err)
+ }
+ want := heldReleaseOutcomeStaged
+ if q.Action == heldReleasePublish {
+ want = heldReleaseOutcomePublished
+ }
+ if out.Schema != heldReleaseSchema || out.RequestID != q.RequestID || out.Outcome != want {
+ return heldReleaseResponse{}, fmt.Errorf("moves: held release: root's answer is not for this request: %+v", out)
+ }
+ return out, nil
+}
+
+// heldReleaseVia is how the agent's held-release hooks reach root: the
+// relay (nil: no root path, Task 7's local behaviour), this box's
+// placement code, sent as its engine region, and the claim reader (nil:
+// hostclaim.Read over the hook's store; a test seam).
+type heldReleaseVia struct {
+ Relay heldReleaseRelay
+ Region string
+ Claims func(context.Context, string) (hostclaim.Observed, error)
+}
+
+func (v heldReleaseVia) claim(ctx context.Context, s release.Store, host string) (hostclaim.Observed, error) {
+ if v.Claims != nil {
+ return v.Claims(ctx, host)
+ }
+ return hostclaim.Read(ctx, s, host)
+}
+
+// request builds the agent's request for action from its own reads: the
+// claim tuple as it observes it now, moveID ("" takes the claim's op id)
+// and holdOp from its record or route, and on a publication the render hash
+// configured journalled under holdOp. None of it is authority: root reads
+// each again and compares.
+func (v heldReleaseVia) request(ctx context.Context, s release.Store, action, host, account, app, version, moveID, holdOp, box, dataDir string) (heldReleaseRequest, error) {
+ obs, err := v.claim(ctx, s, host)
+ if err != nil {
+ return heldReleaseRequest{}, err
+ }
+ if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil {
+ return heldReleaseRequest{}, staleHeldStage("the host has no claim")
+ }
+ c := obs.Claim
+ if moveID == "" {
+ moveID = c.Op.ID
+ }
+ q := heldReleaseRequest{Schema: heldReleaseSchema, RequestID: newHeldReleaseRequestID(), Action: action, Host: host, Account: account, App: app,
+ Version: version, MoveID: moveID, HoldOp: holdOp, Box: box, Region: regionname.PlacementRegion(v.Region),
+ Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp, Session: c.Holder.Session, Generation: c.Holder.Generation,
+ Kind: c.Op.Kind, Phase: c.Op.Phase}
+ if action == heldReleasePublish {
+ j, err := moves.ReadJournal(dataDir, host, holdOp)
+ if err != nil {
+ return heldReleaseRequest{}, err
+ }
+ if q.RenderHash = j.Steps[landStepRendered]; q.RenderHash == "" {
+ return heldReleaseRequest{}, errHeldReleaseNotYet
+ }
+ }
+ return q, nil
+}
+
+// heldReleaseHoldOp is the op rec's registry row is held by: the rollback
+// id while a rollback runs (ruling J3), else the move id.
+func heldReleaseHoldOp(rec provision.Record) string {
+ if m := rec.Move; m != nil && m.State == provision.MoveRollingBack && m.Rollback != nil && m.Rollback.ID != "" {
+ return m.Rollback.ID
+ }
+ if rec.Move == nil {
+ return ""
+ }
+ return rec.Move.ID
+}
+
+// stage asks root to stage T7's release for rec (the move's target, its
+// row held, if at all, by the move id).
+func (v heldReleaseVia) stage(ctx context.Context, s release.Store, rec provision.Record, box, version string) error {
+ q, err := v.request(ctx, s, heldReleaseStage, rec.Host, rec.Account, rec.App, version, rec.Move.ID, rec.Move.ID, box, "")
+ if err != nil {
+ return err
+ }
+ _, err = v.Relay.HeldRelease(ctx, q)
+ return err
+}
+
+// publish asks root to publish version for rt under holdOp's render
+// journal.
+func (v heldReleaseVia) publish(ctx context.Context, s release.Store, rt registry.Route, moveID, holdOp, box, dataDir, version string) error {
+ q, err := v.request(ctx, s, heldReleasePublish, rt.Host, release.RouteAccount(rt), rt.App, version, moveID, holdOp, box, dataDir)
+ if err != nil {
+ return err
+ }
+ _, err = v.Relay.HeldRelease(ctx, q)
+ return err
+}
diff --git a/internal/host/movesheldrelease_linux.go b/internal/host/movesheldrelease_linux.go
new file mode 100644
index 000000000..e4c9b417a
--- /dev/null
+++ b/internal/host/movesheldrelease_linux.go
@@ -0,0 +1,59 @@
+//go:build linux
+
+package host
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/safefile"
+)
+
+// heldRenderJournalMax bounds the render journal root reads: a move's
+// journal holds step values (digests, ids, one install manifest), well
+// under this.
+const heldRenderJournalMax = 1 << 20
+
+// readHeldRenderHash is root's read of the held render's journalled hash
+// for (host, op): moves.JournalFile under the agent's data directory. The
+// journal is agent-written, so root never opens it by name (#267,
+// platform-1e): safefile.OpenFile walks every directory from / with
+// O_NOFOLLOW, pins the leaf with O_PATH|O_NOFOLLOW and fstats it before
+// opening it for reading (with O_NONBLOCK), so a symlink at any component
+// fails, and a FIFO, a device or a file with more than one link is refused
+// without being opened for reading; the bytes are read from that one
+// descriptor, bounded. A journal, or a directory on its path, that does not
+// exist is os.ErrNotExist (not yet); anything else unsafe is
+// errHeldReleaseUnsafe. A journal with no rendered step is "".
+func readHeldRenderHash(dataDir, host, op string) (string, error) {
+ f, err := safefile.OpenFile(moves.JournalFile(dataDir, host, op), os.O_RDONLY, 0)
+ if errors.Is(err, os.ErrNotExist) {
+ return "", os.ErrNotExist
+ }
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", errHeldReleaseUnsafe, err)
+ }
+ defer f.Close()
+ st, err := f.Stat()
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", errHeldReleaseUnsafe, err)
+ }
+ if !st.Mode().IsRegular() || st.Size() > heldRenderJournalMax {
+ return "", errHeldReleaseUnsafe
+ }
+ raw, err := io.ReadAll(io.LimitReader(f, heldRenderJournalMax+1))
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", errHeldReleaseUnsafe, err)
+ }
+ if len(raw) > heldRenderJournalMax {
+ return "", errHeldReleaseUnsafe
+ }
+ j, err := moves.DecodeJournal(raw, host, op)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", errHeldReleaseUnsafe, err)
+ }
+ return j.Steps[landStepRendered], nil
+}
diff --git a/internal/host/movesheldrelease_linux_test.go b/internal/host/movesheldrelease_linux_test.go
new file mode 100644
index 000000000..a6e69b940
--- /dev/null
+++ b/internal/host/movesheldrelease_linux_test.go
@@ -0,0 +1,204 @@
+//go:build linux
+
+package host
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestHeldReleaseRootRefusesUnsafeRenderJournals (#267, platform-1e): the
+// render-hash journal lives under the agent's /data, so root reads it only
+// through the anchored path: no symlink at any component, a regular file
+// with one link, one descriptor, a bounded size. A journal that is a
+// symlink to a root-only file holding the right hash, a directory on its
+// path that is a symlink to such a journal, and a FIFO are each refused at
+// once, and root selects and publishes nothing.
+func TestHeldReleaseRootRefusesUnsafeRenderJournals(t *testing.T) {
+ ctx := context.Background()
+ journalPath := func(f *hrFixture) string {
+ return filepath.Join(moves.JournalDir(f.data, p1Host, p1ID), "journal.json")
+ }
+ // rootOnly is a journal with the right hash in a directory only its
+ // owner can read: what a symlink would make root read on the agent's
+ // behalf.
+ rootOnly := func(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ g := &hrFixture{data: dir}
+ g.journal(t, p1ID, hrRendered)
+ if err := os.Chmod(filepath.Join(moves.JournalDir(dir, p1Host, p1ID), "journal.json"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return moves.JournalDir(dir, p1Host, p1ID)
+ }
+ for _, tc := range []struct {
+ name string
+ prep func(t *testing.T, f *hrFixture)
+ }{
+ {"a symlink to a root-only journal", func(t *testing.T, f *hrFixture) {
+ p := journalPath(f)
+ if err := os.Remove(p); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join(rootOnly(t), "journal.json"), p); err != nil {
+ t.Fatal(err)
+ }
+ }},
+ {"a symlinked directory on the path", func(t *testing.T, f *hrFixture) {
+ dir := moves.JournalDir(f.data, p1Host, p1ID)
+ if err := os.RemoveAll(dir); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(rootOnly(t), dir); err != nil {
+ t.Fatal(err)
+ }
+ }},
+ {"a FIFO", func(t *testing.T, f *hrFixture) {
+ p := journalPath(f)
+ if err := os.Remove(p); err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Mkfifo(p, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }},
+ {"a hard link", func(t *testing.T, f *hrFixture) {
+ if err := os.Link(journalPath(f), filepath.Join(t.TempDir(), "other")); err != nil {
+ t.Skipf("no hard link across temp dirs: %v", err)
+ }
+ }},
+ {"an oversized journal", func(t *testing.T, f *hrFixture) {
+ if err := os.WriteFile(journalPath(f), []byte(strings.Repeat(" ", heldRenderJournalMax+1)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newHRFixture(t)
+ tc.prep(t, f)
+ done := make(chan error, 1)
+ go func() {
+ _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish))
+ done <- err
+ }()
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Fatal("root published on an unsafe journal")
+ }
+ if errors.Is(err, errHeldReleaseNotYet) {
+ // Not yet would make the agent retry an unsafe file forever
+ // without anyone seeing it; it is a refusal.
+ t.Fatalf("an unsafe journal was read as not yet: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("root blocked on the journal")
+ }
+ f.untouched(t)
+ })
+ }
+}
+
+// TestHeldReleaseOverTheRootSocket: the agent's client reaches root's
+// held-release route on the claim-mode instance-control mux, with root's
+// peer authentication; root's refusals come back typed; and a root with no
+// such route (an older build, or one without its providers) and a mux with
+// no claim handler at all both read as unavailable.
+func TestHeldReleaseOverTheRootSocket(t *testing.T) {
+ ctx := context.Background()
+ f := newHRFixture(t)
+ authenticated := 0
+ f.root.Authenticate = func(_ context.Context, conn net.Conn) (StopPeer, error) {
+ if conn == nil {
+ t.Error("no request connection")
+ }
+ authenticated++
+ return hrPeer, nil
+ }
+ sock := serveClaimRoot(t, ClaimRootWithLaunch{HeldRelease: f.root})
+ client := newHeldReleaseRootClient(sock)
+
+ stale := f.request(heldReleasePublish)
+ stale.Session = 99
+ if _, err := client.HeldRelease(ctx, stale); !errors.Is(err, release.ErrStaleHeldStage) {
+ t.Fatalf("a stale request over the socket: %v", err)
+ }
+ resp, err := client.HeldRelease(ctx, f.request(heldReleasePublish))
+ if err != nil || resp.Outcome != heldReleaseOutcomePublished {
+ t.Fatalf("publish over the socket: %+v %v", resp, err)
+ }
+ if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" {
+ t.Fatalf("live = %q", v)
+ }
+ if authenticated != 2 {
+ t.Fatalf("authenticated %d of 2 requests", authenticated)
+ }
+
+ f.root.DataDir = t.TempDir()
+ f.set(func(f *hrFixture) { f.claim.Holder.Session = 4 })
+ if _, err := client.HeldRelease(ctx, f.request(heldReleasePublish)); !errors.Is(err, errHeldReleaseNotYet) {
+ t.Fatalf("not yet over the socket: %v", err)
+ }
+
+ f.root.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, errors.New("not the edge") }
+ if _, err := client.HeldRelease(ctx, f.request(heldReleasePublish)); !errors.Is(err, ErrClaimStopIdentity) {
+ t.Fatalf("an unauthenticated peer: %v", err)
+ }
+
+ // A body the protocol cannot carry is refused before authority checks.
+ f.root.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return hrPeer, nil }
+ httpc := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "unix", sock)
+ }}}
+ r, err := httpc.Post("http://control/claim/held-release", "application/json", strings.NewReader(`{"schema":"x","path":"/etc/shadow"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.Body.Close()
+ if r.StatusCode != http.StatusBadRequest {
+ t.Fatalf("an unknown field: status %d", r.StatusCode)
+ }
+
+ for name, h := range map[string]http.Handler{
+ "a root without the route": ClaimRootWithLaunch{},
+ "a claim root of old": http.NotFoundHandler(),
+ } {
+ t.Run(name, func(t *testing.T) {
+ old := newHeldReleaseRootClient(serveClaimRoot(t, h))
+ if _, err := old.HeldRelease(ctx, f.request(heldReleaseStage)); !errors.Is(err, errHeldReleaseUnavailable) {
+ t.Fatalf("an old root: %v, want errHeldReleaseUnavailable", err)
+ }
+ })
+ }
+ t.Run("a mux with the claim off", func(t *testing.T) {
+ dir, err := os.MkdirTemp("", "hr")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ sock := filepath.Join(dir, "c.sock")
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := &http.Server{Handler: http.NewServeMux()}
+ go srv.Serve(ln)
+ t.Cleanup(func() { srv.Close() })
+ if _, err := newHeldReleaseRootClient(sock).HeldRelease(ctx, f.request(heldReleaseStage)); !errors.Is(err, errHeldReleaseUnavailable) {
+ t.Fatalf("no claim mux: %v", err)
+ }
+ })
+}
diff --git a/internal/host/movesheldrelease_other.go b/internal/host/movesheldrelease_other.go
new file mode 100644
index 000000000..b3cb92155
--- /dev/null
+++ b/internal/host/movesheldrelease_other.go
@@ -0,0 +1,14 @@
+//go:build !linux
+
+package host
+
+// heldRenderJournalMax is the Linux reader's bound; off Linux nothing is
+// read.
+const heldRenderJournalMax = 1 << 20
+
+// readHeldRenderHash refuses off Linux: root's held release runs on a Linux
+// box only, and without safefile's anchored open there is no safe read of
+// an agent-written journal. Fail closed, like openMovesDir.
+func readHeldRenderHash(dataDir, host, op string) (string, error) {
+ return "", errHeldReleaseUnavailable
+}
diff --git a/internal/host/movesheldrelease_production.go b/internal/host/movesheldrelease_production.go
new file mode 100644
index 000000000..c8cb4904e
--- /dev/null
+++ b/internal/host/movesheldrelease_production.go
@@ -0,0 +1,80 @@
+package host
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "path/filepath"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// WithProductionMovesHeldRelease adds the moves held-release route (Task
+// 7b) to the claim-mode root handler WithProductionClaimLaunch returned,
+// only while CARLOS_MOVES_PHASE1 is on; otherwise the handler is returned
+// unchanged and the route refuses, exactly main's behaviour. It reuses the
+// claim root's peer authentication and the assembly's roster identity,
+// guard, policy and local authority, and builds root's held-release
+// selection from them as the assembly builds the agent's (Build's
+// WorkSource, without Prepare: participation is the agent's). A release
+// key that does not parse leaves the route unwired, so every request is
+// refused and T7 says MV-N455: fail closed, without stopping
+// instance-control.
+func WithProductionMovesHeldRelease(ctx context.Context, handler http.Handler, reg *registry.Registry, getenv func(string) string) (http.Handler, error) {
+ if !moves.Enabled(getenv) {
+ return handler, nil
+ }
+ root, ok := handler.(ClaimRootWithLaunch)
+ if !ok {
+ return nil, ErrClaimStopUnavailable
+ }
+ entry, _ := ctx.Value(claimEntryKey{}).(claimEntry)
+ a := entry.Assembly
+ if a == nil || entry.Store == nil || reg == nil || a.Guard.DataDir == "" || !filepath.IsAbs(a.ManagedRoots["opt"]) || root.Root.Stop.Authenticate == nil {
+ return nil, &ClaimDependencyError{Contracts: []string{"moves Task 7b held release: production claim assembly, registry and root peer authentication"}}
+ }
+ pub, err := release.ParsePublicKey(getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+ if err != nil {
+ log.Printf("moves: held releases are refused: %v", err)
+ return root, nil
+ }
+ store := entry.Store
+ guard := a.Guard
+ guard.Claims = hostguard.StoreReader{Store: store}
+ optDir, dataDir := a.ManagedRoots["opt"], a.Guard.DataDir
+ policy, identity, local := a.Policy, a.Identity, a.Local
+ var continues func(kind, from, to string) bool
+ if a.Wake != nil {
+ continues = a.Wake.Continue
+ }
+ root.HeldRelease = &movesHeldReleaseRoot{
+ Authenticate: root.Root.Stop.Authenticate,
+ Actor: identity.Verify,
+ Store: store,
+ Claims: func(ctx context.Context, host string) (hostclaim.Observed, error) {
+ return hostclaim.Read(ctx, store, host)
+ },
+ Records: MovesRecordsFrom(store),
+ Route: reg.Get,
+ Works: func(ctx context.Context, actor hostbox.Actor, host, action string) (hostwrite.Work, error) {
+ return hostbox.WorkSource{FleetHomeRegion: actor.FleetHomeRegion, HomeRegion: actor.HomeRegion, Guard: guard, Box: actor.Box,
+ Region: park.PlacementRegion(actor.Region), Now: time.Now, Policy: policy.Check, Local: identity.Local(local), Continue: continues}.Select(ctx, host, action)
+ },
+ Selected: hostbox.Selected,
+ Pub: pub,
+ Ratchet: func() *release.DigestRatchet { return pointerRatchet(true, optDir, dataDir, getenv) },
+ Proof: func() *release.ProofGate { return release.NewProofGate(release.ProofPolicyPath, dataDir) },
+ OptDir: optDir,
+ DataDir: dataDir,
+ ReceiptDir: heldReleaseReceiptDir,
+ }
+ return root, nil
+}
diff --git a/internal/host/movesheldrelease_root.go b/internal/host/movesheldrelease_root.go
new file mode 100644
index 000000000..65f87529c
--- /dev/null
+++ b/internal/host/movesheldrelease_root.go
@@ -0,0 +1,503 @@
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/regionname"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/safefile"
+)
+
+// heldReleaseReceiptDir is where root keeps its publish receipts: its own
+// claim state directory (carlos-instance-control's StateDirectory in claim
+// mode), which the agent cannot write.
+const heldReleaseReceiptDir = "/var/lib/carlos-claims/held-release"
+
+// movesHeldReleaseRoot is root's side of the held release (Task 7b; see
+// movesheldrelease.go). Every authority it checks comes from root's own
+// providers: the authenticated peer, the claim and record it reads from the
+// deployment store, the registry row, the render journal it reads through
+// the anchored path, and its own held-release selection. A request only
+// names what the agent expects them to say.
+type movesHeldReleaseRoot struct {
+ // Authenticate is root's peer authentication (the edge unit's main
+ // process), the claim root's own.
+ Authenticate func(context.Context, net.Conn) (StopPeer, error)
+ // Actor is this box's verified roster identity.
+ Actor func(context.Context) (hostbox.Actor, error)
+ // Store is the deployment store: releases and the source's report.
+ Store release.Store
+ Claims func(context.Context, string) (hostclaim.Observed, error)
+ Records MovesRecordReader
+ Route func(string) (*registry.Route, error)
+ // Works selects the claim's held-release work for host
+ // (hostbox.WorkSource.Select, built for actor); Selected reads back the
+ // selection it captured (hostbox.Selected).
+ Works func(ctx context.Context, actor hostbox.Actor, host, action string) (hostwrite.Work, error)
+ Selected func(hostwrite.Work) (hostbox.Selection, error)
+ // Pub, Ratchet and Proof are §4.1's and D3's evidence, as for root's
+ // adoption pass: the release key, root's pointer digest ratchet and a
+ // fresh proof gate per request.
+ Pub ed25519.PublicKey
+ Ratchet func() *release.DigestRatchet
+ Proof func() *release.ProofGate
+ OptDir string
+ // DataDir is the box's data directory: the render journals and the
+ // held-stage lock.
+ DataDir string
+ ReceiptDir string
+
+ // Test seams. Nil is production.
+ stage func(context.Context, release.Store, ed25519.PublicKey, *release.DigestRatchet, string, registry.Route, string, *release.ProofGate) (*release.Manifest, error)
+ materialize func(context.Context, release.Store, ed25519.PublicKey, *release.DigestRatchet, string, registry.Route, string, *release.ProofGate, hostwrite.Work, func(func() error) error) (*release.Manifest, error)
+ renderHash func(dataDir, host, op string) (string, error)
+ afterPublish func(host string) error
+}
+
+func (h *movesHeldReleaseRoot) complete() bool {
+ return h != nil && h.Actor != nil && h.Store != nil && h.Claims != nil && h.Records != nil && h.Route != nil && h.Works != nil && h.Selected != nil &&
+ len(h.Pub) == ed25519.PublicKeySize && h.Ratchet != nil && h.Proof != nil && filepath.IsAbs(h.OptDir) && filepath.IsAbs(h.DataDir) && filepath.IsAbs(h.ReceiptDir)
+}
+
+// ServeHTTP answers POST /claim/held-release for an authenticated peer.
+// The peer is authenticated before the body is read.
+func (h *movesHeldReleaseRoot) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if h == nil || h.Authenticate == nil || r.Method != http.MethodPost || r.URL.Path != heldReleasePath {
+ http.Error(w, "refused", http.StatusServiceUnavailable)
+ return
+ }
+ conn := instancectl.RequestConn(r.Context())
+ if conn == nil {
+ http.Error(w, "refused", http.StatusForbidden)
+ return
+ }
+ peer, err := h.Authenticate(r.Context(), conn)
+ if err != nil {
+ http.Error(w, "refused", http.StatusForbidden)
+ return
+ }
+ q, err := decodeHeldReleaseRequest(r.Body)
+ if err != nil {
+ http.Error(w, "refused", http.StatusBadRequest)
+ return
+ }
+ out, err := h.Serve(r.Context(), peer, q)
+ if err != nil {
+ log.Printf("moves: %s: held release %s of %s refused: %v", q.Host, q.Action, q.Version, err)
+ http.Error(w, "refused", heldReleaseStatus(err))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(out)
+}
+
+// heldReleaseStatus is the status each refusal answers with; the agent's
+// client maps it back to the same typed error.
+func heldReleaseStatus(err error) int {
+ var refusal *MovesPolicyRefusal
+ switch {
+ case errors.Is(err, errHeldReleaseShape):
+ return http.StatusBadRequest
+ case errors.Is(err, ErrClaimStopIdentity):
+ return http.StatusForbidden
+ case errors.Is(err, errHeldReleaseNotYet):
+ return http.StatusTooEarly
+ case errors.Is(err, release.ErrHeldNotProven):
+ return http.StatusUnprocessableEntity
+ case errors.Is(err, errHeldReleaseUnavailable), errors.Is(err, hostwrite.ErrUnavailable):
+ return http.StatusServiceUnavailable
+ case errors.Is(err, release.ErrStaleHeldStage), errors.Is(err, hostbox.ErrChanged), errors.Is(err, errHeldReleaseUnsafe),
+ errors.Is(err, release.ErrHeldNotAuthorized), errors.Is(err, release.ErrManifestDigest), errors.As(err, &refusal):
+ return http.StatusConflict
+ }
+ return http.StatusInternalServerError
+}
+
+// Serve answers one request for an authenticated peer. Its order: the peer,
+// the request's shape, this box's identity, then the action's own reads,
+// every refusal before any effect.
+func (h *movesHeldReleaseRoot) Serve(ctx context.Context, peer StopPeer, q heldReleaseRequest) (heldReleaseResponse, error) {
+ if !h.complete() {
+ return heldReleaseResponse{}, errHeldReleaseUnavailable
+ }
+ if peer.UID == 0 || peer.PID <= 0 || peer.ProcessStart == "" || peer.BootID == "" || peer.ControllerID == "" {
+ return heldReleaseResponse{}, ErrClaimStopIdentity
+ }
+ if err := q.validate(); err != nil {
+ return heldReleaseResponse{}, err
+ }
+ actor, err := h.Actor(ctx)
+ if err != nil {
+ return heldReleaseResponse{}, err
+ }
+ if !actor.Eligible || actor.Quarantined || actor.Box == "" {
+ return heldReleaseResponse{}, hostbox.ErrChanged
+ }
+ if q.Box != actor.Box {
+ return heldReleaseResponse{}, staleHeldStage("it names box %q and this is %q", q.Box, actor.Box)
+ }
+ if !regionname.SameRegion(actor.HomeRegion, q.Region, regionname.PlacementRegion(actor.Region)) {
+ return heldReleaseResponse{}, staleHeldStage("it names region %q and this box is in %q", q.Region, actor.Region)
+ }
+ if q.Action == heldReleaseStage {
+ return h.stageRelease(ctx, actor, q)
+ }
+ return h.publishRelease(ctx, actor, q)
+}
+
+// heldReleaseAuthority is root's own read of what a request names.
+type heldReleaseAuthority struct {
+ claim *hostclaim.Claim
+ rec *provision.Record
+ row *registry.Route
+}
+
+// authority re-reads the claim, the record and the registry row, and
+// refuses a request that is not exactly theirs: the holder's incarnation,
+// stamp, session, generation, account and app; the op's id, kind and
+// phase; this box as the move's target (moving, landing) or as the
+// executing source (restoring, reverting), and not declared lost; the hold
+// op the row must be held by (the move id, or the claim's intent.rollback
+// for a restoring op, ruling J3); the record's move and incarnation; and a
+// registry row, when there is one, held by that op for that account and
+// app.
+func (h *movesHeldReleaseRoot) authority(ctx context.Context, actor hostbox.Actor, q heldReleaseRequest) (heldReleaseAuthority, error) {
+ obs, err := h.Claims(ctx, q.Host)
+ if err != nil {
+ return heldReleaseAuthority{}, fmt.Errorf("host claim for %s: %w", q.Host, err)
+ }
+ if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil || obs.Claim.Host != q.Host {
+ return heldReleaseAuthority{}, staleHeldStage("the host has no claim")
+ }
+ c, hd := obs.Claim, obs.Claim.Holder
+ if hd.Incarnation != q.Incarnation || hd.Stamp != q.Stamp || hd.Session != q.Session || hd.Generation != q.Generation {
+ return heldReleaseAuthority{}, staleHeldStage("the claim's holder is not the one the request observed")
+ }
+ if hd.Account != q.Account || hd.App != q.App {
+ return heldReleaseAuthority{}, staleHeldStage("the claim is not held by %s/%s", q.Account, q.App)
+ }
+ if c.Op.ID != q.MoveID || c.Op.Kind != q.Kind || c.Op.Phase != q.Phase {
+ return heldReleaseAuthority{}, staleHeldStage("the claim is %s %s at %q", c.Op.Kind, c.Op.ID, c.Op.Phase)
+ }
+ if slices.Contains(c.Lost, actor.Box) {
+ return heldReleaseAuthority{}, staleHeldStage("this box is declared lost")
+ }
+ hold := q.MoveID
+ switch c.Op.Kind {
+ case hostclaim.KindMoving, hostclaim.KindLanding:
+ if c.Op.Intent["target"] != actor.Box {
+ return heldReleaseAuthority{}, staleHeldStage("the claim's target is %q", c.Op.Intent["target"])
+ }
+ case hostclaim.KindRestoring, hostclaim.KindReverting:
+ if c.Op.Exec != actor.Box || c.Op.Intent["source"] != actor.Box {
+ return heldReleaseAuthority{}, staleHeldStage("this box is not the move's executing source")
+ }
+ if c.Op.Kind == hostclaim.KindRestoring {
+ if hold = c.Op.Intent["rollback"]; !provision.ValidRollbackID(hold) {
+ return heldReleaseAuthority{}, staleHeldStage("the claim names no rollback")
+ }
+ }
+ default:
+ return heldReleaseAuthority{}, staleHeldStage("the claim's op is %s", c.Op.Kind)
+ }
+ if q.HoldOp != hold {
+ return heldReleaseAuthority{}, staleHeldStage("the row is held by %q, not %q", hold, q.HoldOp)
+ }
+ rec, err := h.Records(ctx, q.Account, q.App, q.Host)
+ if err != nil {
+ return heldReleaseAuthority{}, fmt.Errorf("record for %s: %w", q.Host, err)
+ }
+ if rec == nil || rec.Host != q.Host || rec.Account != q.Account || rec.App != q.App || rec.Move == nil || rec.Move.ID != q.MoveID || rec.Move.Incarnation != hd.Incarnation {
+ return heldReleaseAuthority{}, staleHeldStage("the record is not this move")
+ }
+ if c.Op.Kind == hostclaim.KindRestoring {
+ if rb := rec.Move.Rollback; rec.Move.State != provision.MoveRollingBack || rb == nil || rb.ID != hold {
+ return heldReleaseAuthority{}, staleHeldStage("the record is not this rollback")
+ }
+ }
+ row, err := h.Route(q.Host)
+ if err != nil {
+ return heldReleaseAuthority{}, fmt.Errorf("registry row for %s: %w", q.Host, err)
+ }
+ if row != nil && (row.HoldOp != hold || release.RouteAccount(*row) != q.Account || row.App != q.App) {
+ return heldReleaseAuthority{}, staleHeldStage("the registry row is %s/%s held by %q", release.RouteAccount(*row), row.App, row.HoldOp)
+ }
+ return heldReleaseAuthority{claim: c, rec: rec, row: row}, nil
+}
+
+// heldReleaseRoute is the held route a request's effect runs for.
+func heldReleaseRoute(q heldReleaseRequest) registry.Route {
+ return registry.Route{Host: q.Host, Kind: "instance", App: q.App, Account: q.Account, HoldOp: q.HoldOp}
+}
+
+// stageRelease is T7 under the claim, for a move's target (moving or
+// landing) or a dirty return's source (restoring): the version must be the
+// source's reported release and, once pinned, the record's pin (for a
+// return, its recorded recovery release, else the pin). It stages and
+// verifies (release.StageHeldVerified), never touches live/<host>, selects
+// nothing and holds no hostguard: the version store is shared and a stage
+// is no host's write.
+func (h *movesHeldReleaseRoot) stageRelease(ctx context.Context, actor hostbox.Actor, q heldReleaseRequest) (heldReleaseResponse, error) {
+ a, err := h.authority(ctx, actor, q)
+ if err != nil {
+ return heldReleaseResponse{}, err
+ }
+ m := a.rec.Move
+ switch q.Kind {
+ case hostclaim.KindMoving, hostclaim.KindLanding:
+ raw, _, err := h.Store.Get(ctx, moves.SourceReportKey(moves.ReportPrefix(q.Account, q.App, q.Host, q.MoveID)))
+ if errors.Is(err, os.ErrNotExist) {
+ return heldReleaseResponse{}, staleHeldStage("the source has not reported its release")
+ }
+ if err != nil {
+ return heldReleaseResponse{}, fmt.Errorf("source report for %s: %w", q.Host, err)
+ }
+ var sr moves.SourceReport
+ if err := json.Unmarshal(raw, &sr); err != nil {
+ return heldReleaseResponse{}, fmt.Errorf("source report for %s: %w", q.Host, err)
+ }
+ if sr.Release == "" || sr.Release != q.Version {
+ return heldReleaseResponse{}, staleHeldStage("the source runs %q", sr.Release)
+ }
+ if m.Inputs != nil && m.Inputs.Release != q.Version {
+ return heldReleaseResponse{}, staleHeldStage("the pinned release is %q", m.Inputs.Release)
+ }
+ case hostclaim.KindRestoring:
+ if want := heldReturnRelease(m); want == "" || want != q.Version {
+ return heldReleaseResponse{}, staleHeldStage("the return's release is %q", want)
+ }
+ default:
+ return heldReleaseResponse{}, staleHeldStage("a %s op stages nothing", q.Kind)
+ }
+ stage := h.stage
+ if stage == nil {
+ stage = release.StageHeldVerified
+ }
+ if _, err := stage(ctx, h.Store, h.Pub, h.Ratchet(), h.OptDir, heldReleaseRoute(q), q.Version, h.Proof()); err != nil {
+ return heldReleaseResponse{}, err
+ }
+ log.Printf("moves: %s: held release %s staged for %s (no publication)", q.Host, q.Version, q.MoveID)
+ return heldReleaseResponse{Schema: heldReleaseSchema, RequestID: q.RequestID, Outcome: heldReleaseOutcomeStaged}, nil
+}
+
+// heldReturnRelease is a return's release: its recorded recovery release,
+// else the source's pin.
+func heldReturnRelease(m *provision.MoveState) string {
+ if rb := m.Rollback; rb != nil && rb.RecoveryInputs != nil && rb.RecoveryInputs.Release != "" {
+ return rb.RecoveryInputs.Release
+ }
+ if m.Inputs != nil {
+ return m.Inputs.Release
+ }
+ return ""
+}
+
+// publishVersion is the release a publication may name, by the claim's
+// kind and phase, and "" with the reason when the claim grants none: a
+// landing's configured or probing step, on this box as the executor, with
+// the record at that landing phase (the pinned release); a dirty return's
+// configured step (claimed) or a return's probe (its recovery release, the
+// pin at configured when none is recorded yet); a revert's probe (its
+// recovery release). Anything else, a moving op included, publishes
+// nothing: the policy is never widened.
+func publishVersion(c *hostclaim.Claim, m *provision.MoveState, box string) (string, error) {
+ if c.Op.Exec != box {
+ return "", staleHeldStage("this box is not the executor")
+ }
+ switch c.Op.Kind {
+ case hostclaim.KindLanding:
+ if c.Op.Phase != provision.LandConfigured && c.Op.Phase != provision.LandProbing {
+ return "", staleHeldStage("a landing at %q publishes nothing", c.Op.Phase)
+ }
+ if m.State != provision.MoveLanding || m.LandingPhase != c.Op.Phase {
+ return "", staleHeldStage("the record is %s at %q", m.State, m.LandingPhase)
+ }
+ if m.Inputs == nil || m.Inputs.Release == "" {
+ return "", staleHeldStage("no pinned release")
+ }
+ return m.Inputs.Release, nil
+ case hostclaim.KindRestoring:
+ rb := m.Rollback
+ switch {
+ case c.Op.Phase == hostclaim.PhaseClaimed && rb.SourcePhase == provision.RbSourceConfigured && rb.Dirty:
+ case c.Op.Phase == hostclaim.PhaseProbing && rb.SourcePhase == provision.RbSourceProbing:
+ if rb.RecoveryInputs == nil || rb.RecoveryInputs.Release == "" {
+ return "", staleHeldStage("no recorded recovery release")
+ }
+ default:
+ return "", staleHeldStage("a rollback at %q (source %q) publishes nothing", c.Op.Phase, rb.SourcePhase)
+ }
+ if v := heldReturnRelease(m); v != "" {
+ return v, nil
+ }
+ return "", staleHeldStage("no recorded recovery release")
+ case hostclaim.KindReverting:
+ if c.Op.Phase != hostclaim.PhaseProbing || c.Op.Journal[hostclaim.JournalRevPhase] != hostclaim.PhaseProbing || m.State != provision.MoveReverting {
+ return "", staleHeldStage("a revert at %q publishes nothing", c.Op.Phase)
+ }
+ if m.RecoveryInputs == nil || m.RecoveryInputs.Release == "" {
+ return "", staleHeldStage("no recorded recovery release")
+ }
+ return m.RecoveryInputs.Release, nil
+ }
+ return "", staleHeldStage("a %s op publishes nothing", c.Op.Kind)
+}
+
+// publishRelease is a publication under the claim. In order: a receipt for
+// exactly this request (session, render hash and identity) answers it
+// again, with no read and no effect; the claim, record and row are re-read
+// and must be the request's; the version must be the one publishVersion
+// grants; the render hash journalled at configured must be present (else
+// errHeldReleaseNotYet, at once, holding nothing) and equal the request's;
+// then root selects held-release itself, requires the selection's tuple to
+// be the request's, and runs release.MaterializeHeldUnder inside it with
+// the held-stage lock (bind, swap, re-hash and rollback, the lock a leaf
+// inside Work.With, which re-checks the claim under the hostguard right
+// before the swap). The receipt is written last.
+func (h *movesHeldReleaseRoot) publishRelease(ctx context.Context, actor hostbox.Actor, q heldReleaseRequest) (heldReleaseResponse, error) {
+ if h.replayed(q) {
+ return heldReleaseResponse{Schema: heldReleaseSchema, RequestID: q.RequestID, Outcome: heldReleaseOutcomePublished, Replayed: true}, nil
+ }
+ a, err := h.authority(ctx, actor, q)
+ if err != nil {
+ return heldReleaseResponse{}, err
+ }
+ want, err := publishVersion(a.claim, a.rec.Move, actor.Box)
+ if err != nil {
+ return heldReleaseResponse{}, err
+ }
+ if q.Version != want {
+ return heldReleaseResponse{}, staleHeldStage("the release to publish is %q", want)
+ }
+ if a.row == nil {
+ return heldReleaseResponse{}, staleHeldStage("the host has no held row")
+ }
+ read := h.renderHash
+ if read == nil {
+ read = readHeldRenderHash
+ }
+ rendered, err := read(h.DataDir, q.Host, q.HoldOp)
+ switch {
+ case errors.Is(err, os.ErrNotExist) || (err == nil && rendered == ""):
+ return heldReleaseResponse{}, errHeldReleaseNotYet
+ case err != nil:
+ return heldReleaseResponse{}, err
+ case rendered != q.RenderHash:
+ return heldReleaseResponse{}, staleHeldStage("the journalled render is %s", rendered)
+ }
+ work, err := h.Works(ctx, actor, q.Host, movesActionHeldRelease)
+ if err != nil {
+ return heldReleaseResponse{}, err
+ }
+ sel, err := h.Selected(work)
+ if err != nil {
+ return heldReleaseResponse{}, err
+ }
+ if sel.Host != q.Host || sel.Action != movesActionHeldRelease || sel.Box != actor.Box || sel.Captured.Incarnation != q.Incarnation || sel.Captured.Stamp != q.Stamp ||
+ sel.Session != q.Session || sel.Generation != q.Generation || sel.Kind != q.Kind || sel.Op != q.MoveID || sel.Phase != q.Phase {
+ return heldReleaseResponse{}, hostbox.ErrChanged
+ }
+ materialize := h.materialize
+ if materialize == nil {
+ materialize = release.MaterializeHeldUnder
+ }
+ lock := func(publish func() error) error { return withHeldStageLock(h.DataDir, q.Host, publish) }
+ if _, err := materialize(ctx, h.Store, h.Pub, h.Ratchet(), h.OptDir, heldReleaseRoute(q), q.Version, h.Proof(), work, lock); err != nil {
+ return heldReleaseResponse{}, err
+ }
+ if h.afterPublish != nil {
+ if err := h.afterPublish(q.Host); err != nil {
+ return heldReleaseResponse{}, err
+ }
+ }
+ if err := h.writeReceipt(q); err != nil {
+ // The publication stands; a replay without a receipt runs the
+ // whole path again and publishes the same release.
+ log.Printf("moves: %s: held release %s published, receipt not written: %v", q.Host, q.Version, err)
+ }
+ log.Printf("moves: %s: held release %s published for %s at %s %q", q.Host, q.Version, q.MoveID, q.Kind, q.Phase)
+ return heldReleaseResponse{Schema: heldReleaseSchema, RequestID: q.RequestID, Outcome: heldReleaseOutcomePublished}, nil
+}
+
+// heldReleaseReceipt is root's record of a publication it made, one per
+// host (the latest), keyed by the session and the render hash with the
+// identity the publication was for.
+type heldReleaseReceipt struct {
+ Schema string `json:"schema"`
+ Host string `json:"host"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Version string `json:"version"`
+ MoveID string `json:"move_id"`
+ HoldOp string `json:"hold_op"`
+ Incarnation string `json:"incarnation"`
+ Stamp uint64 `json:"stamp"`
+ Session uint64 `json:"session"`
+ Generation string `json:"generation"`
+ RenderHash string `json:"render_hash"`
+ Outcome string `json:"outcome"`
+ At time.Time `json:"at"`
+}
+
+func receiptFor(q heldReleaseRequest) heldReleaseReceipt {
+ return heldReleaseReceipt{Schema: heldReleaseSchema, Host: q.Host, Account: q.Account, App: q.App, Version: q.Version, MoveID: q.MoveID, HoldOp: q.HoldOp,
+ Incarnation: q.Incarnation, Stamp: q.Stamp, Session: q.Session, Generation: q.Generation, RenderHash: q.RenderHash, Outcome: heldReleaseOutcomePublished}
+}
+
+func (h *movesHeldReleaseRoot) receiptPath(host string) string {
+ return filepath.Join(h.ReceiptDir, host+".json")
+}
+
+// replayed reports whether root's receipt for q's host is for exactly q:
+// the same session and render hash, and the same identity. Anything else,
+// an unreadable receipt included, is not a replay and runs the whole path.
+func (h *movesHeldReleaseRoot) replayed(q heldReleaseRequest) bool {
+ f, err := safefile.OpenFile(h.receiptPath(q.Host), os.O_RDONLY, 0)
+ if err != nil {
+ return false
+ }
+ defer f.Close()
+ raw, err := io.ReadAll(io.LimitReader(f, heldReleaseRequestMax+1))
+ if err != nil || len(raw) > heldReleaseRequestMax {
+ return false
+ }
+ var got heldReleaseReceipt
+ if err := json.Unmarshal(raw, &got); err != nil {
+ return false
+ }
+ got.At = time.Time{}
+ return got == receiptFor(q)
+}
+
+func (h *movesHeldReleaseRoot) writeReceipt(q heldReleaseRequest) error {
+ r := receiptFor(q)
+ r.At = time.Now().UTC()
+ raw, err := json.Marshal(r)
+ if err != nil {
+ return err
+ }
+ if err := safefile.MkdirAll(h.ReceiptDir, 0o700); err != nil {
+ return err
+ }
+ return safefile.WriteFile(h.receiptPath(q.Host), raw, 0o600)
+}
diff --git a/internal/host/movesheldrelease_test.go b/internal/host/movesheldrelease_test.go
new file mode 100644
index 000000000..6a0283090
--- /dev/null
+++ b/internal/host/movesheldrelease_test.go
@@ -0,0 +1,681 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Task 7b: the root-side held release under the host claim (moves after
+// claim Plan 12; task-7b-design.md and platform-1e's binding review of
+// 2026-10-12). The sandboxed agent cannot write /opt/carlos, so under the
+// claim it asks root, over the root control socket, to stage T7's release
+// (never publishing it) and to publish the held row's release at configured
+// and before a probe's start. Root re-reads the claim, the record, the
+// registry row and the render-hash journal, refuses anything that does not
+// match before any effect, and publishes only inside its own held-release
+// selection.
+
+const (
+ hrRendered = "sha256:" + "aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11"
+ hrOther = "sha256:" + "bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22"
+ hrRB = "rb_abcdefghijklmnopqrstuvwxyz"
+)
+
+var hrPeer = StopPeer{UID: 1000, PID: 4242, ProcessStart: "77", BootID: "boot", ControllerID: "edge"}
+
+// hrSelector is root's held-release selection as the tests see it: each
+// selection is recorded with the claim tuple it was made at, and each write
+// it is handed runs, recording the identity it names.
+type hrSelector struct {
+ mu sync.Mutex
+ f *hrFixture
+ selected []string
+ ids []hostwrite.Identity
+ refuse error
+}
+
+type hrWork struct {
+ s *hrSelector
+ sel hostbox.Selection
+}
+
+func (w hrWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error {
+ w.s.mu.Lock()
+ w.s.ids = append(w.s.ids, id)
+ w.s.mu.Unlock()
+ return fn()
+}
+
+func (s *hrSelector) count() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return len(s.selected)
+}
+
+// hrFixture is one box (uk-box in uk) with a held row of bab/app at a
+// landing's configured step: the claim, the record, the registry row, the
+// render journal, a store with v7 shipped and authorized, and root.
+type hrFixture struct {
+ s release.Store
+ opt, data, receipts string
+ inc string
+ mu sync.Mutex
+ claim hostclaim.Claim
+ rec provision.Record
+ row *registry.Route
+ sel *hrSelector
+ claimReads int
+ root *movesHeldReleaseRoot
+}
+
+func newHRFixture(t *testing.T) *hrFixture {
+ t.Helper()
+ s := release.NewDirStore(t.TempDir())
+ shipPinnedV7(t, s)
+ inc, err := hostclaim.Incarnation("bab", "app", p1Host, p1Created)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f := &hrFixture{s: s, opt: t.TempDir(), data: t.TempDir(), receipts: t.TempDir(), inc: inc}
+ f.claim = hostclaim.Claim{V: 1, Host: p1Host, Gen: 5,
+ Holder: &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "app", p1Host), Account: "bab", App: "app", Incarnation: inc,
+ CreatedAt: p1Created, Location: "instances/" + p1Host + "/" + inc, Generation: p1ID, Stamp: 7, Session: 3, WriterBox: "uk-box"},
+ Op: hostclaim.Op{Kind: hostclaim.KindLanding, ID: p1ID, Phase: provision.LandConfigured, Exec: "uk-box", Participants: []string{"ie-box", "uk-box"},
+ Intent: map[string]string{"source": "ie-box", "target": "uk-box", "to": "uk"}}}
+ f.rec = provision.Record{Schema: 1, Host: p1Host, Slug: "s", App: "app", Account: "bab", Region: "uk", CreatedAt: p1Created,
+ Move: &provision.MoveState{ID: p1ID, Incarnation: inc, From: "ie", To: "uk", State: provision.MoveLanding, LandingPhase: provision.LandConfigured,
+ Inputs: &provision.MoveInputs{Release: "v7"}}}
+ f.row = &registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Status: "landing", HoldOp: p1ID}
+ f.sel = &hrSelector{f: f}
+ raw, err := json.Marshal(moves.SourceReport{V: 1, Box: "ie-box", MoveID: p1ID, At: time.Now(), Release: "v7"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(context.Background(), moves.SourceReportKey(moves.ReportPrefix("bab", "app", p1Host, p1ID)), raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ f.journal(t, p1ID, hrRendered)
+ f.root = &movesHeldReleaseRoot{
+ Authenticate: nil,
+ Actor: func(context.Context) (hostbox.Actor, error) {
+ return hostbox.Actor{Box: "uk-box", Region: "uk", Eligible: true}, nil
+ },
+ Store: s,
+ Claims: f.claims,
+ Records: f.records,
+ Route: func(host string) (*registry.Route, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.row == nil || host != f.row.Host {
+ return nil, nil
+ }
+ rt := *f.row
+ return &rt, nil
+ },
+ Works: func(_ context.Context, actor hostbox.Actor, host, action string) (hostwrite.Work, error) {
+ f.sel.mu.Lock()
+ defer f.sel.mu.Unlock()
+ f.sel.selected = append(f.sel.selected, host+"/"+action)
+ if f.sel.refuse != nil {
+ return nil, f.sel.refuse
+ }
+ f.mu.Lock()
+ c := f.claim
+ f.mu.Unlock()
+ return hrWork{s: f.sel, sel: hostbox.Selection{Host: host, Action: action, Box: actor.Box, Region: actor.Region,
+ Captured: hostguard.Captured{Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp}, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase,
+ Generation: c.Holder.Generation, Session: c.Holder.Session}}, nil
+ },
+ Selected: func(w hostwrite.Work) (hostbox.Selection, error) {
+ hw, ok := w.(hrWork)
+ if !ok {
+ return hostbox.Selection{}, hostbox.ErrUnsupported
+ }
+ return hw.sel, nil
+ },
+ Pub: hsPub,
+ Ratchet: func() *release.DigestRatchet { return hsRatchet(t) },
+ Proof: func() *release.ProofGate { return offProofGate(t) },
+ OptDir: f.opt,
+ DataDir: f.data,
+ ReceiptDir: f.receipts,
+ }
+ return f
+}
+
+func (f *hrFixture) claims(_ context.Context, host string) (hostclaim.Observed, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.claimReads++
+ if host != f.claim.Host {
+ return hostclaim.Observed{Host: host, Absent: true}, nil
+ }
+ raw, err := json.Marshal(f.claim)
+ if err != nil {
+ return hostclaim.Observed{}, err
+ }
+ var c hostclaim.Claim
+ if err := json.Unmarshal(raw, &c); err != nil {
+ return hostclaim.Observed{}, err
+ }
+ return hostclaim.Observed{Host: host, Claim: &c}, nil
+}
+
+func (f *hrFixture) records(_ context.Context, acct, app, host string) (*provision.Record, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if acct != f.rec.Account || app != f.rec.App || host != f.rec.Host {
+ return nil, nil
+ }
+ raw, err := json.Marshal(f.rec)
+ if err != nil {
+ return nil, err
+ }
+ var rec provision.Record
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ return nil, err
+ }
+ return &rec, nil
+}
+
+// journal journals the held render's hash for op, as configured does.
+func (f *hrFixture) journal(t *testing.T, op, hash string) {
+ t.Helper()
+ if err := moves.UpdateJournal(f.data, p1Host, op, func(j *moves.Journal) error {
+ j.Mark(landStepRendered, hash)
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// request is the agent's request for action, built from the fixture's claim
+// and record as the agent builds it from its own reads.
+func (f *hrFixture) request(action string) heldReleaseRequest {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ c := f.claim
+ q := heldReleaseRequest{Schema: heldReleaseSchema, RequestID: "req-1", Action: action, Host: p1Host, Account: "bab", App: "app", Version: "v7",
+ MoveID: c.Op.ID, HoldOp: c.Op.ID, Box: "uk-box", Region: "uk", Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp,
+ Session: c.Holder.Session, Generation: c.Holder.Generation, Kind: c.Op.Kind, Phase: c.Op.Phase}
+ if c.Op.Kind == hostclaim.KindRestoring {
+ q.HoldOp = c.Op.Intent["rollback"]
+ }
+ if action == heldReleasePublish {
+ q.RenderHash = hrRendered
+ }
+ return q
+}
+
+func (f *hrFixture) set(fn func(f *hrFixture)) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ fn(f)
+}
+
+// untouched fails unless root had no effect: nothing selected, nothing
+// staged, live/<host> absent and no receipt.
+func (f *hrFixture) untouched(t *testing.T) {
+ t.Helper()
+ if n := f.sel.count(); n != 0 {
+ t.Fatalf("root selected %d time(s) for a refused request", n)
+ }
+ if _, err := os.Stat(filepath.Join(f.opt, "versions")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("root staged for a refused request: %v", err)
+ }
+ if _, err := os.Lstat(release.LiveLink(f.opt, p1Host)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("root published for a refused request: %v", err)
+ }
+ if ents, _ := os.ReadDir(f.receipts); len(ents) != 0 {
+ t.Fatalf("root wrote a receipt for a refused request: %v", ents)
+ }
+}
+
+// TestHeldReleaseRootPublishesAtConfigured: a landing's configured step
+// asks root to publish the pinned release; root selects held-release for
+// the host, publishes live/<host> under the identity naming exactly v7,
+// writes the receipt, and the agent's own reading then passes.
+func TestHeldReleaseRootPublishesAtConfigured(t *testing.T) {
+ ctx := context.Background()
+ f := newHRFixture(t)
+ resp, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish))
+ if err != nil {
+ t.Fatalf("publish: %v", err)
+ }
+ if resp.Outcome != heldReleaseOutcomePublished || resp.Replayed || resp.RequestID != "req-1" {
+ t.Fatalf("response = %+v", resp)
+ }
+ if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" {
+ t.Fatalf("live = %q, want v7", v)
+ }
+ if len(f.sel.selected) != 1 || f.sel.selected[0] != p1Host+"/"+movesActionHeldRelease {
+ t.Fatalf("selected %v, want one held-release selection", f.sel.selected)
+ }
+ if len(f.sel.ids) != 1 || f.sel.ids[0] != (hostwrite.Identity{Release: "v7"}) {
+ t.Fatalf("published under %v, want the pinned release alone", f.sel.ids)
+ }
+ if _, err := os.Stat(filepath.Join(f.receipts, p1Host+".json")); err != nil {
+ t.Fatalf("no publish receipt: %v", err)
+ }
+ rt := registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", HoldOp: p1ID}
+ lock := func(read func() error) error { return withHeldStageLock(f.data, p1Host, read) }
+ if err := release.VerifyHeldLive(ctx, f.s, f.opt, rt, "v7", offProofGate(t), lock); err != nil {
+ t.Fatalf("the agent's reading after root's publication: %v", err)
+ }
+}
+
+// TestHeldReleaseRootPublishesForTheDirtyReturnAndTheProbe: the same path
+// serves a landing's probe and a dirty return, at its configured (claimed)
+// and its probe, each under its own hold op and render journal.
+func TestHeldReleaseRootPublishesForTheDirtyReturnAndTheProbe(t *testing.T) {
+ ctx := context.Background()
+ asReturn := func(f *hrFixture, phase, sourcePhase string, recovery *provision.MoveInputs) {
+ f.claim.Op = hostclaim.Op{Kind: hostclaim.KindRestoring, ID: p1ID, Phase: phase, Exec: "uk-box", Participants: []string{"uk-box", "ie-box"},
+ Intent: map[string]string{"source": "uk-box", "target": "ie-box", "to": "ie", "rollback": hrRB}}
+ f.rec.Move.State, f.rec.Move.LandingPhase = provision.MoveRollingBack, ""
+ f.rec.Move.From, f.rec.Move.To = "uk", "ie"
+ f.rec.Move.Rollback = &provision.MoveRollback{ID: hrRB, Dirty: true, SourcePhase: sourcePhase, RecoveryInputs: recovery}
+ f.row.HoldOp = hrRB
+ }
+ cases := []struct {
+ name string
+ set func(f *hrFixture)
+ op string
+ }{
+ {"the landing's probe", func(f *hrFixture) {
+ f.claim.Op.Phase = provision.LandProbing
+ f.rec.Move.LandingPhase = provision.LandProbing
+ }, p1ID},
+ {"a dirty return's configured", func(f *hrFixture) {
+ asReturn(f, hostclaim.PhaseClaimed, provision.RbSourceConfigured, nil)
+ }, hrRB},
+ {"a dirty return's probe", func(f *hrFixture) {
+ asReturn(f, hostclaim.PhaseProbing, provision.RbSourceProbing, &provision.MoveInputs{Release: "v7"})
+ }, hrRB},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newHRFixture(t)
+ f.set(tc.set)
+ f.journal(t, tc.op, hrRendered)
+ q := f.request(heldReleasePublish)
+ if q.HoldOp != tc.op {
+ t.Fatalf("request hold op %q, want %q", q.HoldOp, tc.op)
+ }
+ resp, err := f.root.Serve(ctx, hrPeer, q)
+ if err != nil || resp.Outcome != heldReleaseOutcomePublished {
+ t.Fatalf("publish: %+v %v", resp, err)
+ }
+ if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" {
+ t.Fatalf("live = %q, want v7", v)
+ }
+ if len(f.sel.ids) != 1 || f.sel.ids[0] != (hostwrite.Identity{Release: "v7"}) {
+ t.Fatalf("published under %v", f.sel.ids)
+ }
+ })
+ }
+}
+
+// TestHeldReleaseRootRefusesBeforeAnyEffect: every request whose tuple is
+// not the claim's, whose pins or render are not the record's and the
+// journal's, that names another account, app, box or region, or that
+// arrives while the claim grants no held write, is refused before root
+// selects, stages, publishes or writes a receipt.
+func TestHeldReleaseRootRefusesBeforeAnyEffect(t *testing.T) {
+ ctx := context.Background()
+ otherInc := strings.Repeat("c", 64)
+ cases := []struct {
+ name string
+ action string
+ state func(f *hrFixture)
+ q func(q *heldReleaseRequest)
+ }{
+ {"a stale incarnation", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Incarnation = otherInc }},
+ {"a stale stamp", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Stamp = 6 }},
+ {"a stale session", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Session = 2 }},
+ {"a stale generation", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Generation = "cur" }},
+ {"a stale op", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.MoveID, q.HoldOp = p1ID2, p1ID2 }},
+ {"a stale phase", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Phase = provision.LandProbing }},
+ {"a stale kind", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Kind = hostclaim.KindReverting }},
+ {"changed pins: another version", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Version = "v8" }},
+ {"changed pins: the record's", heldReleasePublish, func(f *hrFixture) { f.rec.Move.Inputs.Release = "v8" }, nil},
+ {"changed render", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.RenderHash = hrOther }},
+ {"a forged account", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Account = "zed" }},
+ {"a forged app", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.App = "other" }},
+ {"the wrong box", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Box = "ie-box" }},
+ {"the wrong region", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Region = "ie" }},
+ {"another hold op", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.HoldOp = hrRB }},
+ {"the row held by another op", heldReleasePublish, func(f *hrFixture) { f.row.HoldOp = p1ID2 }, nil},
+ {"no held row", heldReleasePublish, func(f *hrFixture) { f.row = nil }, nil},
+ {"the record's move is another", heldReleasePublish, func(f *hrFixture) { f.rec.Move.ID = p1ID2 }, nil},
+ {"the record is not at configured", heldReleasePublish, func(f *hrFixture) { f.rec.Move.LandingPhase = provision.LandLocal }, nil},
+ {"this box is not the executor", heldReleasePublish, func(f *hrFixture) { f.claim.Op.Exec = "ie-box" }, nil},
+ {"this box is declared lost", heldReleasePublish, func(f *hrFixture) { f.claim.Lost = []string{"uk-box"} }, nil},
+ {"a moving claim publishes nothing", heldReleasePublish, func(f *hrFixture) {
+ f.claim.Op.Kind, f.claim.Op.Phase = hostclaim.KindMoving, ""
+ }, func(q *heldReleaseRequest) { q.Kind, q.Phase = hostclaim.KindMoving, "" }},
+ {"a landing at local publishes nothing", heldReleasePublish, func(f *hrFixture) {
+ f.claim.Op.Phase, f.rec.Move.LandingPhase = provision.LandLocal, provision.LandLocal
+ }, func(q *heldReleaseRequest) { q.Phase = provision.LandLocal }},
+ {"no claim", heldReleasePublish, func(f *hrFixture) { f.claim.Host = "other.example.com" }, nil},
+ {"stage: the wrong box", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.Box = "ie-box" }},
+ {"stage: a forged app", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.App = "other" }},
+ {"stage: a forged account", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.Account = "zed" }},
+ {"stage: a stale session", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.Session = 9 }},
+ {"stage: a stale incarnation", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.Incarnation = otherInc }},
+ {"stage: changed pins", heldReleaseStage, func(f *hrFixture) { f.rec.Move.Inputs.Release = "v8" }, nil},
+ {"stage: another target", heldReleaseStage, func(f *hrFixture) { f.claim.Op.Intent["target"] = "fr-box" }, nil},
+ {"stage: a render hash", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.RenderHash = hrRendered }},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newHRFixture(t)
+ if tc.state != nil {
+ f.set(tc.state)
+ }
+ q := f.request(tc.action)
+ if tc.q != nil {
+ tc.q(&q)
+ }
+ _, err := f.root.Serve(ctx, hrPeer, q)
+ if err == nil {
+ t.Fatal("root accepted the request")
+ }
+ if errors.Is(err, errHeldReleaseNotYet) {
+ t.Fatalf("refused as not yet: %v", err)
+ }
+ f.untouched(t)
+ })
+ }
+}
+
+// TestHeldReleaseRootStages: a stage request stages and verifies the pinned
+// release into the shared version store and never touches live/<host>, and
+// selects nothing: a moving claim grants no held write.
+func TestHeldReleaseRootStages(t *testing.T) {
+ ctx := context.Background()
+ f := newHRFixture(t)
+ f.set(func(f *hrFixture) {
+ f.claim.Op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: p1ID, Exec: "ie-box", Participants: []string{"ie-box", "uk-box"},
+ Intent: map[string]string{"source": "ie-box", "target": "uk-box", "to": "uk"}}
+ f.rec.Move.State, f.rec.Move.LandingPhase, f.rec.Move.Inputs = provision.MoveReady, "", nil
+ f.row = nil
+ })
+ resp, err := f.root.Serve(ctx, hrPeer, f.request(heldReleaseStage))
+ if err != nil || resp.Outcome != heldReleaseOutcomeStaged {
+ t.Fatalf("stage: %+v %v", resp, err)
+ }
+ if _, err := os.Stat(release.VersionPath(f.opt, "bab", "v7", "app")); err != nil {
+ t.Fatalf("v7 not staged: %v", err)
+ }
+ if _, err := os.Lstat(release.LiveLink(f.opt, p1Host)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a stage published live/<host>: %v", err)
+ }
+ if n := f.sel.count(); n != 0 {
+ t.Fatalf("a stage selected %d time(s)", n)
+ }
+ // A publish request against a moving claim is refused: the policy is
+ // never widened.
+ q := f.request(heldReleasePublish)
+ if _, err := f.root.Serve(ctx, hrPeer, q); err == nil {
+ t.Fatal("root published under a moving claim")
+ }
+ if _, err := os.Lstat(release.LiveLink(f.opt, p1Host)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("live/<host> under a moving claim: %v", err)
+ }
+}
+
+// TestHeldReleaseRootNotYetWithoutTheRenderJournal (platform-1e Q2): with
+// no render-hash journal, or one without the rendered step, root refuses
+// with the typed not-yet error at once. It selects nothing and holds no
+// lock, so the agent's retry finds the held-stage lock free.
+func TestHeldReleaseRootNotYetWithoutTheRenderJournal(t *testing.T) {
+ ctx := context.Background()
+ for _, tc := range []struct {
+ name string
+ prep func(t *testing.T, f *hrFixture)
+ }{
+ {"no journal", func(t *testing.T, f *hrFixture) {
+ f.data = t.TempDir()
+ f.root.DataDir = f.data
+ }},
+ {"no rendered step", func(t *testing.T, f *hrFixture) {
+ f.data = t.TempDir()
+ f.root.DataDir = f.data
+ if err := moves.UpdateJournal(f.data, p1Host, p1ID, func(j *moves.Journal) error {
+ j.Mark(landStepPreRender, `{"before":"absent","intended":["`+hrRendered+`"]}`)
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newHRFixture(t)
+ tc.prep(t, f)
+ start := time.Now()
+ _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish))
+ if !errors.Is(err, errHeldReleaseNotYet) {
+ t.Fatalf("publish without the journal: %v, want errHeldReleaseNotYet", err)
+ }
+ if d := time.Since(start); d > 5*time.Second {
+ t.Fatalf("root waited %v for the journal", d)
+ }
+ f.untouched(t)
+ if err := withHeldStageLock(f.data, p1Host, func() error { return nil }); err != nil {
+ t.Fatalf("the held-stage lock after a not-yet: %v", err)
+ }
+ })
+ }
+}
+
+// TestHeldReleaseRootReplayAndCrashRecovery: a replay after success is a
+// no-op that returns the same result (the receipt, keyed by session and
+// render hash); a crash between the publication and its receipt is
+// recovered by the replay, which publishes the same release again.
+func TestHeldReleaseRootReplayAndCrashRecovery(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("a replay after success", func(t *testing.T) {
+ f := newHRFixture(t)
+ q := f.request(heldReleasePublish)
+ first, err := f.root.Serve(ctx, hrPeer, q)
+ if err != nil {
+ t.Fatal(err)
+ }
+ reads := f.claimReads
+ q.RequestID = "req-2"
+ again, err := f.root.Serve(ctx, hrPeer, q)
+ if err != nil {
+ t.Fatalf("replay: %v", err)
+ }
+ if again.Outcome != first.Outcome || !again.Replayed {
+ t.Fatalf("replay = %+v, first = %+v", again, first)
+ }
+ if f.sel.count() != 1 || f.claimReads != reads {
+ t.Fatalf("a replay selected %d time(s) and read the claim %d more time(s)", f.sel.count(), f.claimReads-reads)
+ }
+ })
+
+ t.Run("another session is not a replay", func(t *testing.T) {
+ f := newHRFixture(t)
+ if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); err != nil {
+ t.Fatal(err)
+ }
+ f.set(func(f *hrFixture) { f.claim.Holder.Session = 4 })
+ resp, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish))
+ if err != nil || resp.Replayed {
+ t.Fatalf("a new session's request: %+v %v", resp, err)
+ }
+ if f.sel.count() != 2 {
+ t.Fatalf("selections = %d, want 2", f.sel.count())
+ }
+ })
+
+ t.Run("a stale request is not answered from an old receipt", func(t *testing.T) {
+ f := newHRFixture(t)
+ if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); err != nil {
+ t.Fatal(err)
+ }
+ q := f.request(heldReleasePublish)
+ q.Version = "v8"
+ if _, err := f.root.Serve(ctx, hrPeer, q); err == nil {
+ t.Fatal("another version was answered from the receipt")
+ }
+ })
+
+ t.Run("a crash between the publication and the receipt", func(t *testing.T) {
+ f := newHRFixture(t)
+ crashed := false
+ f.root.afterPublish = func(string) error {
+ if !crashed {
+ crashed = true
+ return errCrash
+ }
+ return nil
+ }
+ if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); !errors.Is(err, errCrash) {
+ t.Fatalf("first publish: %v, want the crash", err)
+ }
+ if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" {
+ t.Fatalf("live = %q after the crash", v)
+ }
+ if _, err := os.Stat(filepath.Join(f.receipts, p1Host+".json")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a receipt before the crash point: %v", err)
+ }
+ resp, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish))
+ if err != nil || resp.Outcome != heldReleaseOutcomePublished || resp.Replayed {
+ t.Fatalf("recovery: %+v %v", resp, err)
+ }
+ if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" || f.sel.count() != 2 {
+ t.Fatalf("after recovery live = %q, selections %d", v, f.sel.count())
+ }
+ if _, err := os.Stat(filepath.Join(f.receipts, p1Host+".json")); err != nil {
+ t.Fatalf("no receipt after recovery: %v", err)
+ }
+ })
+}
+
+// TestHeldReleaseRequestShape: root refuses a request whose shape it cannot
+// trust before any read: the schema, the action, the names, the ids, the
+// incarnation, the stamp and the render hash.
+func TestHeldReleaseRequestShape(t *testing.T) {
+ f := newHRFixture(t)
+ for name, mutate := range map[string]func(q *heldReleaseRequest){
+ "schema": func(q *heldReleaseRequest) { q.Schema = "carlos.moves.held-stage/1" },
+ "action": func(q *heldReleaseRequest) { q.Action = "install" },
+ "no request id": func(q *heldReleaseRequest) { q.RequestID = "" },
+ "host": func(q *heldReleaseRequest) { q.Host = "../etc" },
+ "account": func(q *heldReleaseRequest) { q.Account = "Bab/x" },
+ "app": func(q *heldReleaseRequest) { q.App = "a/../b" },
+ "version": func(q *heldReleaseRequest) { q.Version = "../v7" },
+ "move id": func(q *heldReleaseRequest) { q.MoveID = "mv_x" },
+ "hold op": func(q *heldReleaseRequest) { q.HoldOp = "../" + p1ID },
+ "box": func(q *heldReleaseRequest) { q.Box = "" },
+ "region": func(q *heldReleaseRequest) { q.Region = "UK!" },
+ "incarnation": func(q *heldReleaseRequest) { q.Incarnation = "x" },
+ "stamp": func(q *heldReleaseRequest) { q.Stamp = 0 },
+ "kind": func(q *heldReleaseRequest) { q.Kind = hostclaim.KindServing },
+ "render hash": func(q *heldReleaseRequest) { q.RenderHash = "sha256:x" },
+ "no render hash": func(q *heldReleaseRequest) { q.RenderHash = "" },
+ "oversized phase": func(q *heldReleaseRequest) { q.Phase = strings.Repeat("p", 200) },
+ "oversized gen id": func(q *heldReleaseRequest) { q.Generation = strings.Repeat("g", 300) },
+ } {
+ t.Run(name, func(t *testing.T) {
+ q := f.request(heldReleasePublish)
+ mutate(&q)
+ reads := f.claimReads
+ if _, err := f.root.Serve(context.Background(), hrPeer, q); !errors.Is(err, errHeldReleaseShape) {
+ t.Fatalf("serve: %v, want errHeldReleaseShape", err)
+ }
+ if f.claimReads != reads {
+ t.Fatal("a malformed request reached the claim")
+ }
+ })
+ }
+ t.Run("root refuses a peer with no identity", func(t *testing.T) {
+ if _, err := f.root.Serve(context.Background(), StopPeer{}, f.request(heldReleasePublish)); !errors.Is(err, ErrClaimStopIdentity) {
+ t.Fatalf("serve: %v", err)
+ }
+ f.untouched(t)
+ })
+ t.Run("the body is bounded and strict", func(t *testing.T) {
+ raw, _ := json.Marshal(f.request(heldReleasePublish))
+ if _, err := decodeHeldReleaseRequest(strings.NewReader(strings.TrimSuffix(string(raw), "}") + `,"extra":1}`)); !errors.Is(err, errHeldReleaseShape) {
+ t.Fatalf("unknown field: %v", err)
+ }
+ if _, err := decodeHeldReleaseRequest(strings.NewReader(string(raw) + `{}`)); !errors.Is(err, errHeldReleaseShape) {
+ t.Fatalf("trailing data: %v", err)
+ }
+ if _, err := decodeHeldReleaseRequest(strings.NewReader(strings.Repeat(" ", heldReleaseRequestMax+1) + string(raw))); !errors.Is(err, errHeldReleaseShape) {
+ t.Fatalf("oversized: %v", err)
+ }
+ if q, err := decodeHeldReleaseRequest(strings.NewReader(string(raw))); err != nil || q.Host != p1Host {
+ t.Fatalf("a good body: %+v %v", q, err)
+ }
+ })
+}
+
+// TestHeldReleaseRootSelectionMustBeTheRequests: the claim moving on between
+// root's read and its own selection (a new session admitted) is refused
+// before anything is published: the selection's tuple must be the
+// request's. A selection the claim refuses publishes nothing either.
+func TestHeldReleaseRootSelectionMustBeTheRequests(t *testing.T) {
+ ctx := context.Background()
+ t.Run("the claim moved on", func(t *testing.T) {
+ f := newHRFixture(t)
+ works := f.root.Works
+ f.root.Works = func(ctx context.Context, actor hostbox.Actor, host, action string) (hostwrite.Work, error) {
+ f.set(func(f *hrFixture) { f.claim.Holder.Session = 4 })
+ return works(ctx, actor, host, action)
+ }
+ if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("publish across a session change: %v, want hostbox.ErrChanged", err)
+ }
+ if _, err := os.Lstat(release.LiveLink(f.opt, p1Host)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("published across a session change: %v", err)
+ }
+ if len(f.sel.ids) != 0 {
+ t.Fatalf("wrote under %v", f.sel.ids)
+ }
+ })
+ t.Run("the selection is refused", func(t *testing.T) {
+ f := newHRFixture(t)
+ f.sel.refuse = hostbox.ErrChanged
+ if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("publish with the selection refused: %v", err)
+ }
+ if _, err := os.Lstat(release.LiveLink(f.opt, p1Host)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("published with the selection refused: %v", err)
+ }
+ })
+}
+
+// TestHeldReleaseRouteIsMovesOnly: with CARLOS_MOVES_PHASE1 off the claim
+// root's handler is returned unchanged, so the route refuses as on main.
+func TestHeldReleaseRouteIsMovesOnly(t *testing.T) {
+ h := ClaimRootWithLaunch{}
+ got, err := WithProductionMovesHeldRelease(context.Background(), h, nil, func(string) string { return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r, ok := got.(ClaimRootWithLaunch); !ok || r.HeldRelease != nil {
+ t.Fatalf("moves off wired %#v", got)
+ }
+}
diff --git a/internal/release/materializeheld.go b/internal/release/materializeheld.go
index c3707c4d5..a8e51995d 100644
--- a/internal/release/materializeheld.go
+++ b/internal/release/materializeheld.go
@@ -256,8 +256,10 @@ func MaterializeHeld(ctx context.Context, s Store, pub ed25519.PublicKey, r *Dig
// around the publication (the caller's held-stage lock). D3's admit and bind
// are MaterializeHeld's. A nil work is refused before anything is staged; it
// does not consult CARLOS_HOST_CLAIM, since the selection is the authority.
-// A stage this process cannot write is ErrStageBlocked: there is no root
-// relay under the claim.
+// A stage this process cannot write is ErrStageBlocked. Under the claim the
+// sandboxed agent never calls this on a hardened box: it asks root, which
+// calls it inside root's own held-release selection (moves Task 7b,
+// internal/host movesheldrelease_root.go).
//
// pub and r are §4.1's evidence, as for MaterializeHeld: the stage
// (stageHeld) runs HeldAuthorized first and binds its digest, so a build no
@@ -338,8 +340,9 @@ func StageHeld(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRat
// A refusal of either D3 check is ErrHeldNotProven, and bind's is recorded
// in the move's held namespace as the publication records it; a build §4.1
// does not authorize is ErrHeldNotAuthorized, before anything is
-// downloaded; a version store this process cannot write is ErrStageBlocked
-// (there is no root relay under the claim). A nil proof is refused.
+// downloaded; a version store this process cannot write is ErrStageBlocked,
+// which the agent answers by asking root to run this same stage (moves Task
+// 7b, internal/host movesheldrelease_root.go). A nil proof is refused.
func StageHeldVerified(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string, proof *ProofGate) (*Manifest, error) {
if err := heldShape(rt, version); err != nil {
return nil, err