carlos / platform Public

moves: held writes under the claim, and G16 lifts with the wake-source gate (Task 7, Q2)

Paul's T7 ruling (2026-10-12) and platform-1e's answer to Q2.2.

- T7 under the claim stages and verifies the pinned release
  (release.StageHeldVerified) and does not publish live/<host>; a
  version store this process cannot write is MV-N455, with nothing
  relayed to the root stage pass. With the claim off T7 is
  heldReleaseReady, unchanged (heldReleaseHooks).
- The configured step publishes the release under the held-release
  selection (MaterializeHeldUnder, then VerifyHeldLive) through the new
  ReleaseLive hook; under the claim it never falls back to T7's hook.
- The held render under the claim is RenderHeldUnderBefore with a
  held-config selection under the merged configuration's identity
  (heldRenderer); the landing's pins decide before anything is selected,
  and a mismatch is the configuration-changed refusal. RenderHeld now
  carries the pin.
- checking keeps refusing with RollbackClaimWired under the claim until
  movesWakeSourceRegistered opens, then lands. The constant and its legal
  reason stay.
- Copy: MV-N455 as a pending row. B8 census: no censused unit changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 11ed3bb5d15e15c3f5ab2254c9385e89c4b5a7e2 parent 5a46611
13 files changed, +779 −48
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +6 −0
  • internal/host/movescutguard_test.go +1 −1
  • internal/host/movesheldclaim_test.go +480 −0
  • internal/host/movesinstall.go +35 −4
  • internal/host/movesinstall_test.go +1 −1
  • internal/host/movesland.go +19 −14
  • internal/host/movesland_test.go +13 −1
  • internal/host/movespass.go +71 −17
  • internal/host/movesrecover_test.go +1 −1
  • internal/host/movesreport.go +5 −1
  • internal/host/movesrollback_finalfix_test.go +8 −2
  • internal/host/movesstage.go +138 −5
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index c8a733d2d..d0413a447 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -86,7 +86,7 @@ var movesCopyIDs = []string{
"MV-N380", "MV-N381",
"MV-N385", "MV-N386", "MV-N387", "MV-N389", "MV-N390",
"MV-N440", "MV-N441", "MV-N442", "MV-N443",
- "MV-N450", "MV-N451",
+ "MV-N450", "MV-N451", "MV-N455",
}
// copyRow is one row of the record. The File cell is the file the sentence is
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 810daa96f..f11cac8f2 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -425,3 +425,9 @@ This branch's rows take MV-N450 to MV-N499 (Ruling F13: each a pending row in th
|---|---|---|---|
| MV-N450 | internal/console/movesrecovery.go | carlos move target terminated before returning its writes: <host> | pending |
| MV-N451 | internal/console/movesrecovery.go | <box> in <to> was terminated after it wrote data it had not returned to <from>. the move <id> of <host> cannot finish on its own. run carlos move resolve --host <host> --declare-lost <box> to move <host> back to <from>. | pending |
+
+Task 7's row is T7's reason under the claim (Paul's T7 ruling of 2026-10-12). With host claims on, T7 stages the source's release on the target and checks it, and leaves the publication to the landing. When this box cannot write its version store, the release is not staged and there is no root stage pass to relay it to, so `carlos move status` shows MV-N455 while the move prepares.
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N455 | internal/host/movesreport.go | the release is not staged on this box yet | pending |
diff --git a/internal/host/movescutguard_test.go b/internal/host/movescutguard_test.go
index e64b88f9e..9e266fb2f 100644
--- a/internal/host/movescutguard_test.go
+++ b/internal/host/movescutguard_test.go
@@ -61,7 +61,7 @@ func armStandIns(c *moveCfg, s release.Store, role string) {
c.OwnLease = func(context.Context, string, string) (park.HeldLease, error) { return nil, standIn("OwnLease") }
}
if c.RenderHeld == nil {
- c.RenderHeld = func(context.Context, release.Store, registry.Route, string, func(string) error) (appconfig.HeldRender, error) {
+ c.RenderHeld = func(context.Context, release.Store, registry.Route, string, *provision.MoveInputs, func(string) error) (appconfig.HeldRender, error) {
return appconfig.HeldRender{}, standIn("RenderHeld")
}
}
diff --git a/internal/host/movesheldclaim_test.go b/internal/host/movesheldclaim_test.go
new file mode 100644
index 000000000..28e96a13a
--- /dev/null
+++ b/internal/host/movesheldclaim_test.go
@@ -0,0 +1,480 @@
+package host
+
+import (
+ "context"
+ "crypto/ecdh"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "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"
+)
+
+// Held writes under the claim (moves after claim Plan 12, Task 7; Paul's T7
+// ruling of 2026-10-12 and platform-1e's answer to Q2.2).
+
+// heldSelections is the claim's selector as the held hooks see it: it
+// records each selection and runs every write it is handed, recording the
+// identity the write names (and, with log, the order of the writes).
+type heldSelections struct {
+ selected []string
+ ids []hostwrite.Identity
+ log *[]string
+ refuse error
+}
+
+func (s *heldSelections) Select(_ context.Context, host, action string) (hostwrite.Work, error) {
+ s.selected = append(s.selected, host+"/"+action)
+ if s.refuse != nil {
+ return nil, s.refuse
+ }
+ return heldSelectionWork{s}, nil
+}
+
+type heldSelectionWork struct{ s *heldSelections }
+
+func (w heldSelectionWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error {
+ w.s.ids = append(w.s.ids, id)
+ if w.s.log != nil {
+ *w.s.log = append(*w.s.log, "write")
+ }
+ return fn()
+}
+
+func claimWrites(sel *heldSelections) hostwrite.Config {
+ return hostwrite.Config{Enabled: true, Selector: sel}
+}
+
+// TestT7UnderTheClaimStagesWithoutPublishing: under the claim T7 stages the
+// pinned release into the shared version store and verifies it, admitted
+// and authorized, and passes without publishing live/<host> and without any
+// selection (a moving claim grants no held write). A release D3 does not
+// admit is T7's not-proven sentence, a build nothing authorizes fails, and
+// a version store this process cannot write is MV-N455, with nothing relayed
+// to the root stage pass, which has no publication to make under the claim.
+func TestT7UnderTheClaimStagesWithoutPublishing(t *testing.T) {
+ ctx := context.Background()
+ setup := func(t *testing.T) (*phase1Harness, provision.Record) {
+ t.Helper()
+ h := newPhase1Harness(t)
+ rec := h.start(t, "manual")
+ shipPinnedV7(t, h.s)
+ h.pass(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ return h, rec
+ }
+
+ t.Run("staged and verified, not published", func(t *testing.T) {
+ h, rec := setup(t)
+ sel := &heldSelections{}
+ data, opt := t.TempDir(), t.TempDir()
+ relayed := false
+ ready, _ := heldReleaseHooks(claimWrites(sel), testRegistry(t), opt, data, "uk-box", hsPub, hsRatchet(t), offProofGate(t),
+ func(string, string, error) { relayed = true })
+ for i := 0; i < 2; i++ {
+ if c := ready(ctx, h.s, rec, "v7"); !c.OK {
+ t.Fatalf("T7 under the claim, pass %d = %+v", i+1, c)
+ }
+ }
+ if _, err := os.Stat(release.VersionPath(opt, "bab", "v7", "app")); err != nil {
+ t.Fatalf("v7 was not staged: %v", err)
+ }
+ if _, err := os.Lstat(release.LiveLink(opt, p1Host)); !os.IsNotExist(err) {
+ t.Fatalf("T7 published live/<host> under the claim: %v", err)
+ }
+ if len(sel.selected) != 0 || relayed {
+ t.Fatalf("T7 selected %v (relayed %v); a moving claim grants no held write", sel.selected, relayed)
+ }
+ // Through the target's report: T7 passes.
+ tc := h.targetCfg()
+ tc.DataDir = data
+ tc.ReleaseReady = ready
+ h.targetPass(t, tc)
+ if f := failing(h.targetReport(t)); f[moves.CheckRelease] != "" {
+ t.Fatalf("T7 in the report = %q", f[moves.CheckRelease])
+ }
+ })
+
+ t.Run("not proven", func(t *testing.T) {
+ h, rec := setup(t)
+ box := newHeldProofBox(t)
+ box.require(t, "app")
+ opt := t.TempDir()
+ ready, _ := heldReleaseHooks(claimWrites(&heldSelections{}), testRegistry(t), opt, t.TempDir(), "uk-box", hsPub, hsRatchet(t), box.gate(), nil)
+ if c := ready(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7NotProven {
+ t.Fatalf("T7 = %+v, want %q", c, t7NotProven)
+ }
+ if _, err := os.Stat(filepath.Join(opt, "versions")); !os.IsNotExist(err) {
+ t.Fatalf("an unproven release was staged: %v", err)
+ }
+ })
+
+ t.Run("not authorized", func(t *testing.T) {
+ h, rec := setup(t)
+ opt := t.TempDir()
+ ready, _ := heldReleaseHooks(claimWrites(&heldSelections{}), testRegistry(t), opt, t.TempDir(), "uk-box", nil, hsRatchet(t), offProofGate(t), nil)
+ if c := ready(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Failed {
+ t.Fatalf("T7 with no release key = %+v, want %q", c, t7Failed)
+ }
+ if _, err := os.Stat(filepath.Join(opt, "versions")); !os.IsNotExist(err) {
+ t.Fatalf("an unauthorized build was staged: %v", err)
+ }
+ })
+
+ t.Run("blocked", func(t *testing.T) {
+ h, rec := setup(t)
+ prev := heldStageVerified
+ t.Cleanup(func() { heldStageVerified = prev })
+ heldStageVerified = func(context.Context, release.Store, ed25519.PublicKey, *release.DigestRatchet, string, registry.Route, string, *release.ProofGate) (*release.Manifest, error) {
+ return nil, fmt.Errorf("%w: read-only", release.ErrStageBlocked)
+ }
+ data := t.TempDir()
+ relayed := false
+ ready, _ := heldReleaseHooks(claimWrites(&heldSelections{}), testRegistry(t), t.TempDir(), data, "uk-box", hsPub, hsRatchet(t), offProofGate(t),
+ func(string, string, error) { relayed = true })
+ if c := ready(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7NotStaged {
+ t.Fatalf("T7 on a read-only version store = %+v, want %q", c, t7NotStaged)
+ }
+ if _, err := os.Stat(filepath.Join(release.HeldStageDir(data), p1Host+".json")); !os.IsNotExist(err) || relayed {
+ t.Fatalf("a held stage was relayed to the root pass under the claim (%v, %v)", err, relayed)
+ }
+ })
+}
+
+// TestClaimOffT7IsUnchanged: with the claim off, T7 stages and publishes
+// live/<host> exactly as before (heldReleaseReady), relays a blocked stage
+// to the root stage pass, and the configured step's release is that same
+// check.
+func TestClaimOffT7IsUnchanged(t *testing.T) {
+ ctx := context.Background()
+ h := newPhase1Harness(t)
+ rec := h.start(t, "manual")
+ shipPinnedV7(t, h.s)
+ h.pass(t)
+
+ data, opt := t.TempDir(), t.TempDir()
+ ready, live := heldReleaseHooks(hostwrite.Config{}, testRegistry(t), opt, data, "uk-box", hsPub, hsRatchet(t), offProofGate(t), nil)
+ if c := ready(ctx, h.s, rec, "v7"); !c.OK {
+ t.Fatalf("T7 = %+v", c)
+ }
+ if v, _ := release.LiveVersion(opt, p1Host); v != "v7" {
+ t.Fatalf("live = %q, want v7: T7 publishes with the claim off", v)
+ }
+ if c := live(ctx, h.s, rec, "v7"); !c.OK {
+ t.Fatalf("the configured step's release = %+v", c)
+ }
+
+ prev := heldMaterialize
+ t.Cleanup(func() { heldMaterialize = prev })
+ heldMaterialize = func(context.Context, release.Store, ed25519.PublicKey, *release.DigestRatchet, string, registry.Route, string, *release.ProofGate, func(func() error) error) (*release.Manifest, error) {
+ return nil, fmt.Errorf("%w: read-only", release.ErrStageBlocked)
+ }
+ data2 := t.TempDir()
+ relayed := false
+ ready2, _ := heldReleaseHooks(hostwrite.Config{}, testRegistry(t), t.TempDir(), data2, "uk-box", hsPub, hsRatchet(t), offProofGate(t),
+ func(string, string, error) { relayed = true })
+ if c := ready2(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Waiting {
+ t.Fatalf("T7 on a read-only version store = %+v, want %q", c, t7Waiting)
+ }
+ if _, err := os.Stat(filepath.Join(release.HeldStageDir(data2), p1Host+".json")); err != nil || !relayed {
+ t.Fatalf("the blocked stage was not relayed to the root pass (%v, %v)", err, relayed)
+ }
+}
+
+// heldConfigKit is a real held render's inputs: a store with bab/app's
+// default bundle, the secrets key, a data directory and the held row.
+type heldConfigKit struct {
+ s release.Store
+ priv *ecdh.PrivateKey
+ dd string
+ rt registry.Route
+ pin *provision.MoveInputs
+}
+
+func newHeldConfigKit(t *testing.T) heldConfigKit {
+ t.Helper()
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ privB64, _, err := appconfig.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := appconfig.ParsePrivateKey(privB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := appconfig.SetEnv(ctx, s, "bab", "app", map[string]string{"GREETING": "hello"}); err != nil {
+ t.Fatal(err)
+ }
+ _, def, err := s.Get(ctx, appconfig.Key("bab", "app"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return heldConfigKit{s: s, priv: priv, dd: t.TempDir(),
+ rt: registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Status: "landing", HoldOp: p1ID, Socket: provision.DefaultSocket(p1Host)},
+ pin: &provision.MoveInputs{BundleETags: map[string]string{"default": def, "environment": ""}, Release: "v7"}}
+}
+
+// TestConfiguredRendersUnderTheHeldConfigSelection: under the claim the
+// landing's held render is selected as held-config for the host, under the
+// identity of the pinned configuration, and keeps carry (c): the journal
+// learns the sha256 of the bytes before the selection writes them. A render
+// whose merged inputs are not the pins selects and writes nothing and is the
+// landing's "configuration changed" refusal, never the policy's. With the
+// claim off the render is RenderHeldBefore's, unchanged.
+func TestConfiguredRendersUnderTheHeldConfigSelection(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("the render", func(t *testing.T) {
+ k := newHeldConfigKit(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ var log []string
+ sel := &heldSelections{log: &log}
+ render := heldRenderer(claimWrites(sel), k.priv, k.dd, "uk-box")
+ path := appconfig.ConfigEnvPath(k.dd, k.rt)
+ var told string
+ hr, err := render(ctx, k.s, k.rt, p1ID, k.pin, func(sha string) error {
+ log = append(log, "journal")
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Errorf("the file was there before the journal: %v", err)
+ }
+ told = sha
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sel.selected) != 1 || sel.selected[0] != p1Host+"/"+movesActionHeldConfig {
+ t.Fatalf("selected %v, want one held-config selection for the host", sel.selected)
+ }
+ want := HeldConfigIdentity(k.pin.BundleETags, k.pin.OverlayETag)
+ if len(sel.ids) == 0 {
+ t.Fatal("nothing was written through the selection")
+ }
+ for _, id := range sel.ids {
+ if id != want {
+ t.Fatalf("a write named %+v, want the pinned configuration %+v", id, want)
+ }
+ }
+ if len(log) < 2 || log[0] != "journal" || log[1] != "write" {
+ t.Fatalf("order %v, want the journal before the writes", log)
+ }
+ body, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(body)
+ if got := hex.EncodeToString(sum[:]); told != got || hr.SHA256 != got {
+ t.Fatalf("journal %q, render %q, file %q", told, hr.SHA256, got)
+ }
+ if !sameBundle(hr.BundleETags, k.pin.BundleETags) {
+ t.Fatalf("rendered %v, pinned %v", hr.BundleETags, k.pin.BundleETags)
+ }
+ })
+
+ t.Run("a changed configuration selects nothing", func(t *testing.T) {
+ k := newHeldConfigKit(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ if err := appconfig.SetEnv(ctx, k.s, "bab", "app", map[string]string{"GREETING": "changed"}); err != nil {
+ t.Fatal(err)
+ }
+ sel := &heldSelections{}
+ journalled := false
+ _, err := heldRenderer(claimWrites(sel), k.priv, k.dd, "uk-box")(ctx, k.s, k.rt, p1ID, k.pin, func(string) error { journalled = true; return nil })
+ if !errors.Is(err, errHeldRenderUnpinned) {
+ t.Fatalf("a changed configuration: %v, want errHeldRenderUnpinned", err)
+ }
+ if len(sel.selected) != 0 || journalled {
+ t.Fatalf("selected %v, journalled %v", sel.selected, journalled)
+ }
+ if _, err := os.Stat(appconfig.ConfigEnvPath(k.dd, k.rt)); !os.IsNotExist(err) {
+ t.Fatalf("a file was written: %v", err)
+ }
+ })
+
+ t.Run("a refused selection writes nothing", func(t *testing.T) {
+ k := newHeldConfigKit(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ sel := &heldSelections{refuse: hostwrite.ErrUnavailable}
+ if _, err := heldRenderer(claimWrites(sel), k.priv, k.dd, "uk-box")(ctx, k.s, k.rt, p1ID, k.pin, func(string) error { return nil }); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatalf("a refused selection: %v", err)
+ }
+ if _, err := os.Stat(appconfig.ConfigEnvPath(k.dd, k.rt)); !os.IsNotExist(err) {
+ t.Fatalf("a file was written: %v", err)
+ }
+ })
+
+ t.Run("claim off", func(t *testing.T) {
+ k := newHeldConfigKit(t)
+ hr, err := heldRenderer(hostwrite.Config{}, k.priv, k.dd, "uk-box")(ctx, k.s, k.rt, p1ID, k.pin, func(string) error { return nil })
+ if err != nil || hr.SHA256 == "" {
+ t.Fatalf("the claim-off render: %+v, %v", hr, err)
+ }
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ if _, err := heldRenderer(hostwrite.Config{}, k.priv, k.dd, "uk-box")(ctx, k.s, k.rt, p1ID, k.pin, func(string) error { return nil }); !errors.Is(err, appconfig.ErrHeldClaimWired) {
+ t.Fatalf("the legacy render under the flag: %v, want ErrHeldClaimWired as before", err)
+ }
+ })
+
+ t.Run("configured passes the pins and refuses a changed configuration", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ withMovesWakeSource(t, true)
+ var pinned []*provision.MoveInputs
+ h.renderPin = func(pin *provision.MoveInputs) { pinned = append(pinned, pin) }
+ h.renderHook = func(func(string) error) (appconfig.HeldRender, error) {
+ return appconfig.HeldRender{}, errHeldRenderUnpinned
+ }
+ h.land(t)
+ m := h.read(t, p1Host).Move
+ if m.Reason != provision.RollbackConfigChanged || m.LandingPhase != provision.LandConfigured {
+ t.Fatalf("after an unpinned render under the claim: %+v", m)
+ }
+ if len(pinned) == 0 || pinned[0] == nil || !sameBundle(pinned[0].BundleETags, m.Inputs.BundleETags) || pinned[0].OverlayETag != m.Inputs.OverlayETag {
+ t.Fatalf("configured rendered with pins %+v, want the record's %+v", pinned, m.Inputs)
+ }
+ })
+}
+
+// TestConfiguredPublishesTheReleaseUnderHeldRelease: under the claim T7
+// leaves live/<host> alone and the configured step publishes it, selected as
+// held-release for the host under the identity naming exactly the pinned
+// version, then reads it back as T7 used to (VerifyHeldLive). A replay finds
+// it live and selects nothing; a refused selection publishes nothing. The
+// landing calls this hook at configured, never T7's.
+func TestConfiguredPublishesTheReleaseUnderHeldRelease(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("the publication", func(t *testing.T) {
+ h := newPhase1Harness(t)
+ rec := h.start(t, "manual")
+ shipPinnedV7(t, h.s)
+ h.pass(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ sel := &heldSelections{}
+ data, opt := t.TempDir(), t.TempDir()
+ ready, live := heldReleaseHooks(claimWrites(sel), testRegistry(t), opt, data, "uk-box", hsPub, hsRatchet(t), offProofGate(t), nil)
+ if c := ready(ctx, h.s, rec, "v7"); !c.OK {
+ t.Fatalf("T7 = %+v", c)
+ }
+ if v, _ := release.LiveVersion(opt, p1Host); v != "" {
+ t.Fatalf("live = %q after T7 under the claim", v)
+ }
+ if c := live(ctx, h.s, rec, "v7"); !c.OK {
+ t.Fatalf("configured's release = %+v", c)
+ }
+ if v, _ := release.LiveVersion(opt, p1Host); v != "v7" {
+ t.Fatalf("live = %q, want v7", v)
+ }
+ if len(sel.selected) != 1 || sel.selected[0] != p1Host+"/"+movesActionHeldRelease {
+ t.Fatalf("selected %v, want one held-release selection for the host", sel.selected)
+ }
+ if len(sel.ids) != 1 || sel.ids[0] != (hostwrite.Identity{Release: "v7"}) {
+ t.Fatalf("published under %v, want the pinned release alone", sel.ids)
+ }
+ if c := live(ctx, h.s, rec, "v7"); !c.OK || len(sel.selected) != 1 {
+ t.Fatalf("a replay: %+v, selections %v", c, sel.selected)
+ }
+ })
+
+ t.Run("a refused selection publishes nothing", func(t *testing.T) {
+ h := newPhase1Harness(t)
+ rec := h.start(t, "manual")
+ shipPinnedV7(t, h.s)
+ h.pass(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ sel := &heldSelections{refuse: hostwrite.ErrUnavailable}
+ opt := t.TempDir()
+ _, live := heldReleaseHooks(claimWrites(sel), testRegistry(t), opt, t.TempDir(), "uk-box", hsPub, hsRatchet(t), offProofGate(t), nil)
+ if c := live(ctx, h.s, rec, "v7"); c.OK {
+ t.Fatalf("a refused selection published: %+v", c)
+ }
+ if v, _ := release.LiveVersion(opt, p1Host); v != "" {
+ t.Fatalf("live = %q", v)
+ }
+ })
+
+ t.Run("configured calls the publication", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ withMovesWakeSource(t, true)
+ var published []string
+ h.releaseLive = func(_ context.Context, _ release.Store, _ provision.Record, v string) moves.Check {
+ published = append(published, v)
+ return moves.Check{OK: true}
+ }
+ h.release = moves.Check{Reason: "T7's hook was asked at configured"}
+ h.land(t)
+ h.installed(t)
+ if pin := h.read(t, p1Host).Move.Inputs; len(published) == 0 || published[0] != pin.Release {
+ t.Fatalf("configured published %v, want the pinned %q", published, pin.Release)
+ }
+ })
+
+ t.Run("under the claim configured never falls back to T7", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ withMovesWakeSource(t, true)
+ h.land(t)
+ if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandConfigured || m.Reason != "" {
+ t.Fatalf("with no publication hook under the claim: %+v", m)
+ }
+ if _, err := os.Stat(provision.DefaultDBPath(h.tdir, p1Host)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the database was installed with nothing published: %v", err)
+ }
+ })
+}
+
+// TestCheckingRefusesUntilTheWakeSourceRegisters (Q2.3): under the claim
+// the landing keeps refusing at checking with RollbackClaimWired while the
+// wake-source gate is shut, and lands once it opens. With the claim off the
+// gate does not matter.
+func TestCheckingRefusesUntilTheWakeSourceRegisters(t *testing.T) {
+ t.Run("shut", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ withMovesWakeSource(t, false)
+ h.land(t)
+ if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackClaimWired || m.LandingPhase != provision.LandChecking {
+ t.Fatalf("under the claim with the gate shut: %+v", m)
+ }
+ })
+ t.Run("open", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ withMovesWakeSource(t, true)
+ h.releaseLive = func(context.Context, release.Store, provision.Record, string) moves.Check {
+ return moves.Check{OK: true}
+ }
+ h.land(t)
+ if m := h.read(t, p1Host).Move; m.Reason != "" {
+ t.Fatalf("under the claim with the gate open: %+v", m)
+ }
+ h.installed(t)
+ })
+ t.Run("claim off", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ withMovesWakeSource(t, false)
+ h.land(t)
+ if m := h.read(t, p1Host).Move; m.Reason != "" {
+ t.Fatalf("with the claim off: %+v", m)
+ }
+ h.installed(t)
+ })
+}
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go
index dc0238360..962d4ed2b 100644
--- a/internal/host/movesinstall.go
+++ b/internal/host/movesinstall.go
@@ -15,6 +15,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/moves/archive"
@@ -976,12 +977,29 @@ func supersededOnBox(c *hostclaim.Claim, box string, stamp uint64) bool {
return false
}
+// releaseLive is the configured step's release (§9.2 as amended
+// 2026-10-12): ReleaseLive, else, with CARLOS_HOST_CLAIM off, ReleaseReady,
+// which publishes then. Under the claim T7's hook only stages, so it is
+// never the fallback: nil, and configured waits.
+func (c moveCfg) releaseLive() func(context.Context, release.Store, provision.Record, string) moves.Check {
+ switch {
+ case c.ReleaseLive != nil:
+ return c.ReleaseLive
+ case hostwrite.Enabled(os.Getenv):
+ return nil
+ }
+ return c.ReleaseReady
+}
+
// configured (§7.6, §9.1, §9.2): the held row rendered (RenderHeld, the
// move-bound ack). A landing requires the render's bundle and overlay etags
// to be the ones pinned at readiness (else the refusal, "configuration
// changed during the move") and the pinned release live and proven for the
-// held row through Plan 1's T7 hook (ruling G4: "waiting for the root stage
-// pass" waits, anything else waits until the landing's deadline refuses). A
+// held row through releaseLive (ruling G4: "waiting for the root stage
+// pass" waits, anything else waits until the landing's deadline refuses).
+// Under the claim (Paul's T7 ruling, 2026-10-12) the render is selected as
+// held-config and the release is published here, under held-release: T7
+// only staged it. A
// return renders the current inputs instead, never compared with
// move.inputs, gates on the release live on this box, and records what it
// rendered as Rollback.RecoveryInputs in the CAS that ends the phase
@@ -1019,12 +1037,20 @@ func (ir *installRun) configured(ctx context.Context) (bool, error) {
// again as soon as the render answers, even when its config ack failed.
// A rollback's cleaning parks <host>.config.env only when its content is
// one this op rendered (rollbackOwnership).
- hr, err := cfg.RenderHeld(ctx, ir.s, rt, ir.src.HoldOp, ir.notePreRender)
+ //
+ // Under the claim the render is selected as held-config, and the pins
+ // decide first (Task 7, Q2.2): a merge that is not them is the
+ // configuration-changed refusal, with nothing selected or written.
+ hr, err := cfg.RenderHeld(ctx, ir.s, rt, ir.src.HoldOp, pin, ir.notePreRender)
if hr.SHA256 != "" {
if jerr := ir.noteRendered(hr.SHA256); jerr != nil {
return false, errors.Join(err, jerr)
}
}
+ if pin != nil && errors.Is(err, errHeldRenderUnpinned) {
+ log.Printf("moves: %s: the held render's inputs are not the pinned ones (%v, overlay %q): %v", host, pin.BundleETags, pin.OverlayETag, err)
+ return ir.refuse(ctx, refuseConfigChanged)
+ }
if err != nil {
return false, fmt.Errorf("render the held row: %w", err)
}
@@ -1047,7 +1073,12 @@ func (ir *installRun) configured(ctx context.Context) (bool, error) {
recovery = &provision.MoveInputs{BundleETags: bundle, OverlayETag: hr.OverlayETag, Release: live}
want = live
}
- if c := cfg.ReleaseReady(ctx, ir.s, ir.rec, want); !c.OK {
+ live := cfg.releaseLive()
+ if live == nil {
+ ir.w.notice(host+"#release", "moves: %s: release %s cannot be published for the held row: no held-release publication is wired under CARLOS_HOST_CLAIM; retrying", host, want)
+ return false, nil
+ }
+ if c := live(ctx, ir.s, ir.rec, want); !c.OK {
ir.w.notice(host+"#release", "moves: %s: release %s is not live and proven for the held row yet (%s); retrying", host, want, c.Reason)
return false, nil
}
diff --git a/internal/host/movesinstall_test.go b/internal/host/movesinstall_test.go
index 5f29a618d..95231b070 100644
--- a/internal/host/movesinstall_test.go
+++ b/internal/host/movesinstall_test.go
@@ -210,7 +210,7 @@ func (h *returnHarness) sourceCfg(t *testing.T) moveCfg {
c.Guard = hostguard.Guard{DataDir: h.sdir, LockDir: t.TempDir()}
c.Local = park.Local{DataDir: h.sdir, Box: "ie-box"}
c.ParkView = recordView(h.s, "ie", roleSource)
- c.RenderHeld = func(_ context.Context, _ release.Store, rt registry.Route, op string, before func(string) error) (appconfig.HeldRender, error) {
+ c.RenderHeld = func(_ context.Context, _ release.Store, rt registry.Route, op string, _ *provision.MoveInputs, before func(string) error) (appconfig.HeldRender, error) {
h.renders++
if h.renderHook != nil {
return h.renderHook(before)
diff --git a/internal/host/movesland.go b/internal/host/movesland.go
index ac60c15f2..1cb1e4f4b 100644
--- a/internal/host/movesland.go
+++ b/internal/host/movesland.go
@@ -81,14 +81,13 @@ import (
// logs a standing notice instead of landing; the tests arm everything with
// fakes (movesparkfakes_test.go).
// Under
-// CARLOS_HOST_CLAIM the landing refuses at checking with a recorded reason
-// (ruling G16). The policy its held writes need, hostbox.Policy.Moves, is
-// MovesClaimPolicy (movespolicy.go) and the timers import already selects
-// its Work as held-timers; the refusal stands because the production claim
-// registration (RegisterProductionClaimOwners) requires every owner and the
-// Plan 3 (Inputs, PublicationEvidence, AdmissionEvidence) and Plan 5 (View,
-// Authorize, Evidence, LeaseSet) owners do not exist yet. It lifts when
-// those owners are registered.
+// CARLOS_HOST_CLAIM the landing's held writes select their Work from the
+// claim (Task 7, Q2): the timers as held-timers, the render as held-config
+// and the release as held-release, which the configured step now publishes
+// (T7 only stages under the claim; Paul, 2026-10-12). hostbox.Policy.Moves,
+// MovesClaimPolicy (movespolicy.go), decides them. The landing still refuses
+// at checking with a recorded reason (ruling G16, RollbackClaimWired) until
+// the wake-source gate opens (movesWakeSourceRegistered), and lands after.
// landingStepsPerPass bounds the phases one pass runs.
const landingStepsPerPass = 8
@@ -483,13 +482,19 @@ func (l *landingRun) ensureLocalDirs() error {
func (l *landingRun) checking(ctx context.Context) (bool, error) {
cfg := l.cfg()
host, id := l.rec.Host, l.g.ID
- // Ruling G16: with host claims on, this box's held writes would need
- // the claim's selection, and the production registration does not
- // carry the Plan 3 and Plan 5 owners yet, so no selector exists here.
- // MovesClaimPolicy (movespolicy.go) is the Policy.Moves half, ready;
- // this refusal lifts when RegisterProductionClaimOwners has every owner.
+ // Ruling G16, as Task 7 settles it (Q2.3): with host claims on, the
+ // landing's held writes run under the claim's selections (heldRenderer,
+ // heldReleaseHooks, heldTimersReplacer) and MovesClaimPolicy decides
+ // them, but they are granted only once this box's moves source is
+ // registered with the claim. Until the wake-source gate opens
+ // (movesWakeSourceRegistered, Q1's one gate) the landing keeps refusing
+ // here, as before; once it opens it lands. RollbackClaimWired stays
+ // (and stays a legal reason in provision), because old records carry it.
if hostwrite.Enabled(os.Getenv) {
- return l.markRollback(ctx, provision.RollbackClaimWired)
+ if err := movesWakeSourceRegistered(); err != nil {
+ log.Printf("moves: %s: landing %s under CARLOS_HOST_CLAIM before the wake source is registered: %v", host, id, err)
+ return l.markRollback(ctx, provision.RollbackClaimWired)
+ }
}
rt, err := l.route()
if err != nil {
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go
index 35d7e21d8..00294e56b 100644
--- a/internal/host/movesland_test.go
+++ b/internal/host/movesland_test.go
@@ -78,6 +78,14 @@ type landingHarness struct {
parkView func(context.Context, hostclaim.Observed) (park.View, error)
landFault func(point string) error
store lease.VersionedStore // when set, the target's versioned store
+
+ // renderPin, when set, sees the pins each render is asked to hold to
+ // (moves Task 7).
+ renderPin func(pin *provision.MoveInputs)
+ // releaseLive, when set, is the configured step's release
+ // (ReleaseLive, moves Task 7).
+ releaseLive func(ctx context.Context, s release.Store, rec provision.Record, version string) moves.Check
+
// probe, when set, is the target's probe wake (Plan 3 Task 6).
probe func(context.Context, activator.ProbeConfig) (activator.ProbeResult, error)
// probeLive, when set, is the activator's live probe entry.
@@ -378,8 +386,11 @@ func (h *landingHarness) landingCfg(t *testing.T) moveCfg {
if h.store != nil {
c.Versioned = func(context.Context, string, string) (lease.VersionedStore, error) { return h.store, nil }
}
- c.RenderHeld = func(_ context.Context, _ release.Store, _ registry.Route, _ string, before func(string) error) (appconfig.HeldRender, error) {
+ c.RenderHeld = func(_ context.Context, _ release.Store, _ registry.Route, _ string, pin *provision.MoveInputs, before func(string) error) (appconfig.HeldRender, error) {
h.renders++
+ if h.renderPin != nil {
+ h.renderPin(pin)
+ }
if h.renderHook != nil {
return h.renderHook(before)
}
@@ -392,6 +403,7 @@ func (h *landingHarness) landingCfg(t *testing.T) moveCfg {
return hr, err
}
c.ReleaseReady = func(context.Context, release.Store, provision.Record, string) moves.Check { return h.release }
+ c.ReleaseLive = h.releaseLive
c.ReplaceTimers = func(_ context.Context, host string, rows []registry.Timer) error {
h.replaced = append(h.replaced, append([]registry.Timer(nil), rows...))
return nil
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index 4c98a4493..149c0bf26 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -365,10 +365,48 @@ func stagingLeaseKey(targetRegion, host string) (string, error) {
return moves.StagingLeasePrefix(r, host), nil
}
+// errHeldRenderUnpinned is the held render under the claim refusing, before
+// anything is selected or written, a merge whose inputs are not the
+// landing's pins: the configured step turns it into the landing's
+// "configuration changed" refusal (refuseConfigChanged), never the policy's
+// refusal of the write (the probe's rule, fix round 1 finding 4).
+var errHeldRenderUnpinned = errors.New("moves: the held render's inputs are not the pinned ones")
+
+// heldRenderer is the held row's render (§9.1; Task 7, Q2.2). With the claim
+// off (writes.Enabled false) it is appconfig.RenderHeldBefore, unchanged.
+// Under the claim it is appconfig.RenderHeldUnderBefore (platform-1e's
+// answer to Q2.2): once the merged versions are known, a pin that is not
+// them is errHeldRenderUnpinned, and otherwise the write is selected as
+// held-config for the host (writes.Select) under the identity of exactly
+// the merged configuration (HeldConfigIdentity), which MovesClaimPolicy
+// grants at configured only when it is the pinned one (or, for a return,
+// the recovery's). before still journals the rendered bytes' sha256 before
+// the selection writes them (carry (c)).
+func heldRenderer(writes hostwrite.Config, priv *ecdh.PrivateKey, dataDir, box string) func(ctx context.Context, s release.Store, rt registry.Route, op string, pin *provision.MoveInputs, before func(string) error) (appconfig.HeldRender, error) {
+ if !writes.Enabled {
+ return func(ctx context.Context, s release.Store, rt registry.Route, op string, _ *provision.MoveInputs, before func(string) error) (appconfig.HeldRender, error) {
+ return appconfig.RenderHeldBefore(ctx, s, priv, dataDir, rt, op, box, before)
+ }
+ }
+ return func(ctx context.Context, s release.Store, rt registry.Route, op string, pin *provision.MoveInputs, before func(string) error) (appconfig.HeldRender, error) {
+ return appconfig.RenderHeldUnderBefore(ctx, s, priv, dataDir, rt, op, box, func(pre appconfig.HeldRender) (hostwrite.Work, error) {
+ if pin != nil && (!sameBundle(pre.BundleETags, pin.BundleETags) || pre.OverlayETag != pin.OverlayETag) {
+ return nil, errHeldRenderUnpinned
+ }
+ w, err := writes.Select(ctx, rt.Host, movesActionHeldConfig)
+ if err != nil {
+ return nil, err
+ }
+ return heldIdentityWork{Work: w, ID: HeldConfigIdentity(pre.BundleETags, pre.OverlayETag)}, nil
+ }, before)
+ }
+}
+
// armPhase1 wires the Phase 1 hooks (moves design §7.3). Only with the
// switch on: off, none is set and eb.Strict stays nil. writes is the
// agent's host-write configuration (the claim wiring's): the landing's
-// held timers import selects its Work through it.
+// held timers import, held render and held release select their Work
+// through it.
func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service, eb *activator.ExecBackend, writes hostwrite.Config, getenv func(string) string) {
cfg.Box = metricsBoxLabel(getenv, readMachineID())
cfg.OptDir = release.OptDir()
@@ -424,20 +462,18 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin
// park.Engine.RunHeld and the MoveParks local authorizer, and are set
// only once the wake-source gate opens (movesWakeSourceRegistered, Plan 5
// Task 9b), so landingArmed is false in production and the landing pass
- // only logs. The held timers
- // write goes through schedule.SelectedTimers with a held-timers Work
- // selected from writes (ruling G16, heldTimersReplacer): with
- // CARLOS_HOST_CLAIM off that is the registry's own ReplaceTimers, as
- // before; with it on, the claim's selection and hostbox.Policy.Moves
- // (MovesClaimPolicy) decide, once the production registration carries
- // the Plan 3 and Plan 5 owners. Until then the landing refuses at
- // checking under the flag (movesland.go).
+ // only logs. The held writes select their Work from writes (ruling
+ // G16; Task 7, Q2): the timers through schedule.SelectedTimers as
+ // held-timers (heldTimersReplacer), the render as held-config
+ // (heldRenderer) and the release as held-release (heldReleaseHooks).
+ // With CARLOS_HOST_CLAIM off each is exactly what it was; with it on,
+ // the claim's selection and hostbox.Policy.Moves (MovesClaimPolicy)
+ // decide. The landing still refuses at checking under the flag until
+ // the wake-source gate opens (movesland.go).
cfg.Guard = hostguard.Guard{DataDir: dataDir}
cfg.Local = park.Local{DataDir: dataDir, Box: cfg.Box}
cfg.OwnLease = hostbox.RenewingLeases{Region: hib.Region}.Acquire
- cfg.RenderHeld = func(ctx context.Context, s release.Store, rt registry.Route, op string, before func(string) error) (appconfig.HeldRender, error) {
- return appconfig.RenderHeldBefore(ctx, s, cfg.SecretsPriv, dataDir, rt, op, cfg.Box, before)
- }
+ cfg.RenderHeld = heldRenderer(writes, cfg.SecretsPriv, dataDir, cfg.Box)
cfg.RemoveVerifyEntry = dbverify.RemoveHost
cfg.WriteGen = hostbox.WriteGenerationMarker
if reg != nil {
@@ -499,7 +535,12 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin
log.Printf("moves: held stages are refused: %v", perr)
pub = nil
}
- cfg.ReleaseReady = heldReleaseReady(reg, cfg.OptDir, dataDir, cfg.Box, pub, pointerRatchet(false, cfg.OptDir, dataDir, getenv),
+ //
+ // Paul's T7 ruling (2026-10-12): under the claim T7 stages and
+ // verifies without publishing, and the configured step publishes
+ // under the held-release selection (heldReleaseHooks). With the
+ // claim off both are heldReleaseReady, as before.
+ cfg.ReleaseReady, cfg.ReleaseLive = heldReleaseHooks(writes, reg, cfg.OptDir, dataDir, cfg.Box, pub, pointerRatchet(false, cfg.OptDir, dataDir, getenv),
release.NewProofGate(release.ProofPolicyPath, dataDir), stageMarkerRequester(dataDir))
if svc != nil {
// S1 asks the instance itself, woken through the activator.
@@ -648,6 +689,15 @@ type moveCfg struct {
MeasureS3 func(ctx context.Context, key string) (moves.MeasuredRate, error)
MeasureDisk func(ctx context.Context) (moves.MeasuredRate, moves.FilesRate, error)
+ // ReleaseLive is the configured step's release (§9.2 as amended
+ // 2026-10-12): the release published for the held row and proven. With
+ // the claim off it is ReleaseReady itself, which publishes; under the
+ // claim T7 only stages, and this publishes under the landing's
+ // held-release selection (heldReleaseHooks). Nil is ReleaseReady with
+ // the claim off; under the claim a nil ReleaseLive never falls back to
+ // T7's hook, and configured waits (moveCfg.releaseLive).
+ ReleaseLive func(ctx context.Context, s release.Store, rec provision.Record, version string) moves.Check
+
// The cut's hooks (§5.3, §7.4, movescut.go). HoldMoving holds the row
// "moving" under the move id (holdMoving). TimersDrained waits until
// no timer delivery or convergence that could have missed the hold is
@@ -685,9 +735,13 @@ type moveCfg struct {
// (movesWakeSourceRegistered, Plan 5 Task 9b), which leaves every arm
// unarmed there. OwnLease
// takes the location's lease in the own bucket for the build
- // (hostbox.RenewingLeases over lease.Acquire). RenderHeld is
- // appconfig.RenderHeldBefore for the held row (before journals the
- // render's ownership ahead of its write, carry (c)); ReplaceTimers is the
+ // (hostbox.RenewingLeases over lease.Acquire). RenderHeld is the held
+ // row's render (heldRenderer): appconfig.RenderHeldBefore with the claim
+ // off, appconfig.RenderHeldUnderBefore under a held-config selection with
+ // it on; before journals the render's ownership ahead of its write,
+ // carry (c), and pin, when not nil, is the landing's pinned inputs, which
+ // a render under the claim must merge before anything is selected or
+ // written (errHeldRenderUnpinned); ReplaceTimers is the
// registry's through schedule.SelectedTimers with a held-timers Work
// (heldTimersReplacer); SetReplication and SetNextDue are the
// registry's; RemoveVerifyEntry is
@@ -709,7 +763,7 @@ type moveCfg struct {
ParkRunHeld func(ctx context.Context, e park.Engine, w park.Work, held park.HeldLease) error
ParkLocalAuthorize func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error
OwnLease func(ctx context.Context, bucket, location string) (park.HeldLease, error)
- RenderHeld func(ctx context.Context, s release.Store, rt registry.Route, op string, before func(sha256hex string) error) (appconfig.HeldRender, error)
+ RenderHeld func(ctx context.Context, s release.Store, rt registry.Route, op string, pin *provision.MoveInputs, before func(sha256hex string) error) (appconfig.HeldRender, error)
ReplaceTimers func(ctx context.Context, host string, rows []registry.Timer) error
SetReplication func(host, class string) error
SetNextDue func(host string, due int64) error
diff --git a/internal/host/movesrecover_test.go b/internal/host/movesrecover_test.go
index 416c0ad40..847b0d27b 100644
--- a/internal/host/movesrecover_test.go
+++ b/internal/host/movesrecover_test.go
@@ -119,7 +119,7 @@ func (h *recoverHarness) sourceCfg(t *testing.T) moveCfg {
c.Guard = hostguard.Guard{DataDir: h.sdir, LockDir: h.lockDir}
c.Local = park.Local{DataDir: h.sdir, Box: "ie-box"}
c.ParkView = recordView(h.s, "ie", roleSource)
- c.RenderHeld = func(_ context.Context, _ release.Store, rt registry.Route, op string, before func(string) error) (appconfig.HeldRender, error) {
+ c.RenderHeld = func(_ context.Context, _ release.Store, rt registry.Route, op string, _ *provision.MoveInputs, before func(string) error) (appconfig.HeldRender, error) {
h.srcRends++
if err := before(h.srcRender.SHA256); err != nil {
return appconfig.HeldRender{}, err
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go
index e3fc1dcfb..9f8b294e0 100644
--- a/internal/host/movesreport.go
+++ b/internal/host/movesreport.go
@@ -51,6 +51,7 @@ const (
t7Waiting = "waiting for the root stage pass" // MV-N75
t7Failed = "this box could not stage the source's release"
t7NotProven = "this box requires a proof for the source's release and has not accepted one"
+ t7NotStaged = "the release is not staged on this box yet" // MV-N455: T7 under the claim, the version store not writable (heldReleaseStaged)
t9NotThisMove = "the host claim is not held by this move"
t9Incarnation = "the host claim names another incarnation of this instance"
t9NoMoveJSON = "the staging lane has no move.json yet"
@@ -697,7 +698,10 @@ func (w *movesWorker) checkDisk(replicaBytes int64, replicaErr error, art verifi
}
// checkRelease is T7: the source's pinned release materializes for the held
-// row on this box.
+// row on this box. With the claim off that is staged and published
+// (heldReleaseReady); under the claim it is staged and verified, and the
+// landing's configured step publishes it (heldReleaseStaged; Paul's T7
+// ruling, 2026-10-12).
func (w *movesWorker) checkRelease(ctx context.Context, s release.Store, rec provision.Record, source *moves.SourceReport) moves.Check {
if source == nil || source.Release == "" {
return moves.Check{Reason: t7NoRelease}
diff --git a/internal/host/movesrollback_finalfix_test.go b/internal/host/movesrollback_finalfix_test.go
index f7b3457c5..2def16185 100644
--- a/internal/host/movesrollback_finalfix_test.go
+++ b/internal/host/movesrollback_finalfix_test.go
@@ -354,8 +354,8 @@ func (p *policyProbe) wrap(c *moveCfg) {
p.check(movesActionHeldTimers, hostwrite.Identity{})
return timers(ctx, host, rows)
}
- c.RenderHeld = func(ctx context.Context, s release.Store, rt registry.Route, op string, before func(string) error) (appconfig.HeldRender, error) {
- hr, err := render(ctx, s, rt, op, before)
+ c.RenderHeld = func(ctx context.Context, s release.Store, rt registry.Route, op string, pin *provision.MoveInputs, before func(string) error) (appconfig.HeldRender, error) {
+ hr, err := render(ctx, s, rt, op, pin, before)
if err == nil {
p.check(movesActionHeldConfig, HeldConfigIdentity(hr.BundleETags, hr.OverlayETag))
}
@@ -365,6 +365,12 @@ func (p *policyProbe) wrap(c *moveCfg) {
p.check(movesActionHeldRelease, hostwrite.Identity{Release: v})
return ready(ctx, s, rec, v)
}
+ if live := c.ReleaseLive; live != nil {
+ c.ReleaseLive = func(ctx context.Context, s release.Store, rec provision.Record, v string) moves.Check {
+ p.check(movesActionHeldRelease, hostwrite.Identity{Release: v})
+ return live(ctx, s, rec, v)
+ }
+ }
c.WriteGen = func(path, generation string) error {
p.check(movesActionRestore, hostwrite.Identity{})
p.check(movesActionInstall, hostwrite.Identity{})
diff --git a/internal/host/movesstage.go b/internal/host/movesstage.go
index 38c994733..d2d9b17f7 100644
--- a/internal/host/movesstage.go
+++ b/internal/host/movesstage.go
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
+ "github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
@@ -20,11 +21,143 @@ import (
// box (the sandboxed agent cannot write /opt/carlos).
var heldMaterialize = release.MaterializeHeld
-// heldReleaseReady is T7 in production (§7.3, §9.2, round-4 finding 6): the
-// pinned release (the source report's Release) is staged for the held row
-// and live/<host> points at it, under the same authority the root relay
-// uses (heldStageAuthority: the claim is this move's and targets this box,
-// the row is absent or held by the op, the source reports this release).
+// heldStageVerified is release.StageHeldVerified as a variable, so a test can
+// make T7's stage under the claim fail the way a read-only version store
+// does.
+var heldStageVerified = release.StageHeldVerified
+
+// heldReleaseHooks builds the two release checks a move's held row needs
+// (moves design §7.3 and §9.2, amended 2026-10-12 by Paul's T7 ruling):
+// ready is T7, asked while the move prepares; live is the configured step's,
+// asked by the landing (and a return's install) before it installs the
+// database.
+//
+// With the claim off (writes.Enabled false) both are heldReleaseReady,
+// unchanged: T7 stages the pinned release and publishes live/<host>, and
+// configured asks the same question.
+//
+// Under the claim T7 cannot publish: a moving claim grants no held write
+// (MovesClaimPolicy), and live/<host> is this host's file. So ready is
+// heldReleaseStaged, which stages and verifies without publishing, and live
+// is heldReleasePublished, which publishes under the landing's held-release
+// selection, where MovesClaimPolicy grants it, and then reads it back.
+func heldReleaseHooks(writes hostwrite.Config, reg *registry.Registry, optDir, dataDir, box string, pub ed25519.PublicKey, r *release.DigestRatchet, proof *release.ProofGate, requestStage func(host, ver string, err error)) (ready, live func(context.Context, release.Store, provision.Record, string) moves.Check) {
+ if !writes.Enabled {
+ ready = heldReleaseReady(reg, optDir, dataDir, box, pub, r, proof, requestStage)
+ return ready, ready
+ }
+ return heldReleaseStaged(reg, optDir, dataDir, box, pub, r, proof), heldReleasePublished(writes, optDir, dataDir, pub, r, proof)
+}
+
+// heldReleaseStaged is T7 under the claim (Paul, 2026-10-12): the pinned
+// release is staged into the shared version store and verified
+// (release.StageHeldVerified: D3's admit, §4.1's authorization, the
+// digest-bound stage and D3's bind on the staged bytes), under the same
+// authority as before (heldStageAuthority: the claim is this move's and
+// targets this box, the row is absent or held by the op, the source reports
+// this release). live/<host> is never touched and nothing is selected: the
+// publication is the configured step's (heldReleasePublished).
+//
+// A version store this process cannot write is t7NotStaged. Nothing is
+// relayed to the root stage pass: its relay publishes, and it refuses held
+// rows while CARLOS_HOST_CLAIM is on, so a request would wait forever.
+func heldReleaseStaged(reg *registry.Registry, optDir, dataDir, box string, pub ed25519.PublicKey, r *release.DigestRatchet, proof *release.ProofGate) func(context.Context, release.Store, provision.Record, string) moves.Check {
+ return func(ctx context.Context, s release.Store, rec provision.Record, version string) moves.Check {
+ req := release.HeldStageRequest{Account: rec.Account, App: rec.App, Host: rec.Host, Version: version, Op: rec.Move.ID, Box: box}
+ if err := heldStageAuthority(reg, s, box, dataDir)(req, nil); err != nil {
+ logMoveStage(rec.Host, version, err)
+ return moves.Check{Reason: t7Failed}
+ }
+ rt := registry.Route{Host: rec.Host, Kind: "instance", App: rec.App, Account: rec.Account, HoldOp: rec.Move.ID}
+ _, err := heldStageVerified(ctx, s, pub, r, optDir, rt, version, proof)
+ switch {
+ case err == nil:
+ return moves.Check{OK: true}
+ case errors.Is(err, release.ErrHeldNotProven):
+ logMoveStage(rec.Host, version, err)
+ return moves.Check{Reason: t7NotProven}
+ case errors.Is(err, release.ErrStageBlocked):
+ logMoveStage(rec.Host, version, err)
+ return moves.Check{Reason: t7NotStaged}
+ default:
+ logMoveStage(rec.Host, version, err)
+ return moves.Check{Reason: t7Failed}
+ }
+ }
+}
+
+// heldReleasePublished is the configured step's release under the claim
+// (§9.2 as amended 2026-10-12): the release is published for the held row
+// under a held-release selection (writes.Select), which MovesClaimPolicy
+// grants at the landing's configured, and the dirty return's, for exactly
+// the pinned (or recorded) release. release.MaterializeHeldUnder stages it
+// outside the selection (T7 has normally staged it already) and swaps
+// live/<host> inside it, under the identity naming exactly version, with
+// the host's held-stage lock (withHeldStageLock) inside the selection. It
+// then passes only on release.VerifyHeldLive under that lock, the reading T7
+// makes with the claim off. A link already live and proven is not published
+// again, so a replay selects nothing.
+func heldReleasePublished(writes hostwrite.Config, optDir, dataDir string, pub ed25519.PublicKey, r *release.DigestRatchet, proof *release.ProofGate) func(context.Context, release.Store, provision.Record, string) moves.Check {
+ return func(ctx context.Context, s release.Store, rec provision.Record, version string) moves.Check {
+ if rec.Move == nil {
+ return moves.Check{Reason: t7Failed}
+ }
+ rt := registry.Route{Host: rec.Host, Kind: "instance", App: rec.App, Account: rec.Account, HoldOp: rec.Move.ID}
+ lock := func(read func() error) error { return withHeldStageLock(dataDir, rec.Host, read) }
+ verified := func() (moves.Check, error) {
+ err := release.VerifyHeldLive(ctx, s, optDir, rt, version, proof, lock)
+ switch {
+ case err == nil:
+ return moves.Check{OK: true}, nil
+ case errors.Is(err, release.ErrHeldNotProven):
+ return moves.Check{Reason: t7NotProven}, err
+ default:
+ return moves.Check{Reason: t7Failed}, err
+ }
+ }
+ c, err := verified()
+ if c.OK {
+ return c
+ }
+ if !errors.Is(err, release.ErrHeldNotLive) {
+ logMoveStage(rec.Host, version, err)
+ return c
+ }
+ work, err := writes.Select(ctx, rec.Host, movesActionHeldRelease)
+ if err != nil {
+ logMoveStage(rec.Host, version, err)
+ return moves.Check{Reason: t7Failed}
+ }
+ _, err = heldMaterializeUnder(ctx, s, pub, r, optDir, rt, version, proof, work, lock)
+ switch {
+ case errors.Is(err, release.ErrHeldNotProven):
+ logMoveStage(rec.Host, version, err)
+ return moves.Check{Reason: t7NotProven}
+ case errors.Is(err, release.ErrStageBlocked):
+ logMoveStage(rec.Host, version, err)
+ return moves.Check{Reason: t7NotStaged}
+ case err != nil:
+ logMoveStage(rec.Host, version, err)
+ return moves.Check{Reason: t7Failed}
+ }
+ if c, err = verified(); !c.OK {
+ logMoveStage(rec.Host, version, err)
+ }
+ return c
+ }
+}
+
+// heldMaterializeUnder is release.MaterializeHeldUnder as a variable, a test
+// seam like heldMaterialize.
+var heldMaterializeUnder = release.MaterializeHeldUnder
+
+// heldReleaseReady is T7 in production with the claim off (§7.3, §9.2,
+// round-4 finding 6; heldReleaseHooks), and the configured step's release
+// then too: the pinned release (the source report's Release) is staged for
+// the held row and live/<host> points at it, under the same authority the
+// root relay uses (heldStageAuthority: the claim is this move's and targets
+// this box, the row is absent or held by the op, the source reports this
+// release).
// On a hardened box the stage is blocked, so a held-stage request is left
// for the root stage pass (release.ServeHeldStageRequests) and its marker
// touched.