carlos / platform Public

claim Plan 14 Part 1: claims-off boxes with no root census update and recover exactly as on main

Task D3 (claim participation) applies only with CARLOS_HOST_CLAIM on or a
root census present; it was wrongly folded into the approved D3 health
tightening. Tests and a mutation witness pin the claims-off/no-census
path.
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 8b434a6dd5084998abc1e40564ade69024cbb2b8 parent a73aa2e
10 files changed, +482 −47
  • internal/host/guardtransaction.go +24 −1
  • internal/host/participation_flagoff_test.go +165 −0
  • internal/host/rootexecution_recovery.go +10 −3
  • internal/host/systemparticipation.go +41 −7
  • internal/hostfloor/disk.go +55 −13
  • internal/hostfloor/participation.go +13 −3
  • internal/hostfloor/participation_flagoff_test.go +111 −0
  • internal/hostfloor/participation_test.go +9 −4
  • internal/provision/testdata/plan14-root-reads-merge.tsv +18 −16
  • internal/rootparticipation/production.go +36 −0
diff --git a/internal/host/guardtransaction.go b/internal/host/guardtransaction.go
index 57430230d..30ef1013d 100644
--- a/internal/host/guardtransaction.go
+++ b/internal/host/guardtransaction.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"github.com/carlosframework/platform/internal/hostactors"
+ execlease "github.com/carlosframework/platform/internal/hostexec"
"github.com/carlosframework/platform/internal/hostfloor"
"github.com/carlosframework/platform/internal/release"
"os"
@@ -316,12 +317,34 @@ func (a *systemApplier) controllersRecovered() error {
if a.floor != nil {
guardDir = a.floor.Dir
}
- if recoveryErr := recoverPendingControllers(context.Background(), a.dataDir, guardDir); recoveryErr != nil {
+ required, checkErr := a.participationDisk().ParticipationRequired(context.Background())
+ if checkErr != nil {
+ return errors.Join(err, checkErr)
+ }
+ recover := recoverPendingControllers
+ if !required {
+ recover = execlease.RecoverPending
+ }
+ if recoveryErr := recover(context.Background(), a.dataDir, guardDir); recoveryErr != nil {
return errors.Join(err, recoveryErr)
}
return a.controllersIdle()
}
func (a *systemApplier) closeGuardJournal() error {
+ required, err := a.participationDisk().ParticipationRequired(context.Background())
+ if err != nil {
+ return err
+ }
+ if !required {
+ if !a.guarded() {
+ a.removeJournal()
+ return nil
+ }
+ if err := hostfloor.RemoveDurable(a.file("journal.json")); err != nil {
+ return err
+ }
+ return a.endGuard()
+ }
active, err := a.activeFloor()
if err != nil {
return err
diff --git a/internal/host/participation_flagoff_test.go b/internal/host/participation_flagoff_test.go
new file mode 100644
index 000000000..b223e42dc
--- /dev/null
+++ b/internal/host/participation_flagoff_test.go
@@ -0,0 +1,165 @@
+package host
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPlan14ParticipationFlagOffUpdate(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", "")
+ for _, region := range []bool{false, true} {
+ for _, mirror := range []string{"absent", "empty", "pending", "symlink"} {
+ t.Run(map[bool]string{false: "region-off", true: "region-on"}[region]+"/"+mirror, func(t *testing.T) {
+ f, _ := applyHappy(t)
+ f.a.regionHA = region
+ if err := f.a.participation.State.Remove("census.json"); err != nil {
+ t.Fatal(err)
+ }
+ f.a.participation.VerifyLaunches = nil
+ switch mirror {
+ case "empty":
+ hostexec(t, f)
+ case "pending":
+ if err := os.WriteFile(filepath.Join(hostexec(t, f), "pending.json"), []byte(`{}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ case "symlink":
+ if err := os.Symlink(t.TempDir(), filepath.Join(f.a.dataDir, hostexecDir)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := f.a.Run(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ want := systemOutcomeApplied
+ if region && (mirror == "pending" || mirror == "symlink") {
+ want = systemOutcomeDeferred
+ }
+ if got := f.result(); got == nil || got.Outcome != want {
+ t.Fatalf("result=%+v want=%s", got, want)
+ }
+ if _, err := f.a.participation.UpdateID(); !os.IsNotExist(err) {
+ t.Fatalf("legacy update created root reservation: %v", err)
+ }
+ })
+ }
+ }
+}
+
+func TestPlan14ParticipationFlagOffRecovery(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", "")
+ for _, journal := range []bool{false, true} {
+ t.Run(map[bool]string{false: "marker", true: "journal"}[journal], func(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1")
+ if err := f.a.participation.State.Remove("census.json"); err != nil {
+ t.Fatal(err)
+ }
+ f.a.participation.VerifyLaunches = nil
+ if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(updateBegunPath(f.a.optDir), []byte(`{"target":"v2"}`), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if journal {
+ if err := f.a.journal(systemJournal{Phase: systemPhaseBackingUp, Target: "v2", PrevBuild: "v1", StartedAt: f.now}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ old := recoverPendingControllers
+ t.Cleanup(func() { recoverPendingControllers = old })
+ recoverPendingControllers = func(context.Context, string, string) error {
+ t.Fatal("legacy recovery called root participation recovery")
+ return nil
+ }
+ if err := f.a.Run(t.Context(), true); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(updateBegunPath(f.a.optDir)); !os.IsNotExist(err) {
+ t.Fatalf("legacy marker retained: %v", err)
+ }
+ if j, err := readSystemJournal(f.a.optDir); err != nil || j != nil {
+ t.Fatalf("journal=%+v err=%v", j, err)
+ }
+ })
+ }
+}
+
+func TestPlan14ParticipationFlagAndCensusExclusion(t *testing.T) {
+ for _, flag := range []string{"", "1"} {
+ for _, census := range []bool{false, true} {
+ t.Run(flag+map[bool]string{false: "/missing", true: "/present"}[census], func(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", flag)
+ f := newApplyFixture(t, "stable", "old")
+ if !census {
+ if err := f.a.participation.State.Remove("census.json"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ f.a.participation.VerifyLaunches = nil
+ err := f.a.beginExecutableChange("new", f.now)
+ if (err != nil) != (flag != "" || census) {
+ t.Fatalf("admission error=%v flag=%q census=%t", err, flag, census)
+ }
+ })
+ }
+ }
+}
+
+func TestPlan14ParticipationFlagOffControllersRecovered(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", "")
+ f := newApplyFixture(t, "stable", "v1")
+ if err := f.a.participation.State.Remove("census.json"); err != nil {
+ t.Fatal(err)
+ }
+ old := recoverPendingControllers
+ t.Cleanup(func() { recoverPendingControllers = old })
+ recoverPendingControllers = func(context.Context, string, string) error {
+ t.Fatal("legacy controller invoked root recovery")
+ return nil
+ }
+ dir := hostexec(t, f)
+ if err := f.a.controllersRecovered(); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "pending.json"), []byte(`{}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.a.controllersRecovered(); err == nil {
+ t.Fatal("malformed legacy controller admitted")
+ }
+}
+
+func TestPlan14ParticipationFlagOffGuardRecovery(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", "")
+ f, id := sharedGuardFixture(t)
+ a := f.a
+ if err := a.participation.State.Remove("census.json"); err != nil {
+ t.Fatal(err)
+ }
+ a.participation.VerifyLaunches = nil
+ if err := a.floor.Begin(id, a.dataDir); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.beginExecutableChange("new-build", f.now); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.journal(systemJournal{Phase: systemPhaseBackingUp, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Run(t.Context(), true); err != nil {
+ t.Fatal(err)
+ }
+ completed, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json"))
+ if err != nil || string(completed) != id {
+ t.Fatalf("completion=%q err=%v", completed, err)
+ }
+ if _, err := os.Stat(updateBegunPath(a.optDir)); !os.IsNotExist(err) {
+ t.Fatalf("legacy floor marker retained: %v", err)
+ }
+ if _, err := a.participation.UpdateID(); !os.IsNotExist(err) {
+ t.Fatalf("legacy recovery created root reservation: %v", err)
+ }
+}
diff --git a/internal/host/rootexecution_recovery.go b/internal/host/rootexecution_recovery.go
index 3c943733e..4edc29977 100644
--- a/internal/host/rootexecution_recovery.go
+++ b/internal/host/rootexecution_recovery.go
@@ -6,18 +6,25 @@ import (
"path/filepath"
"time"
+ execlease "github.com/carlosframework/platform/internal/hostexec"
"github.com/carlosframework/platform/internal/rootparticipation"
)
// Recovery constructs only root's local readers and settlement machinery.
// It never starts the monitor, contacts instance-control, or calls Execute.
func recoverRootExecution(ctx context.Context, dataDir, guardDir string) (result error) {
- ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
- defer cancel()
- ledger, err := rootparticipation.Open()
+ ledger, required, err := rootparticipation.OpenForUpdate()
if err != nil {
return err
}
+ if !required {
+ if ledger.State != nil {
+ ledger.State.Close()
+ }
+ return execlease.RecoverPending(ctx, dataDir, guardDir)
+ }
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
census, err := ledger.RecoveryCensus()
ledger.State.Close()
if err != nil {
diff --git a/internal/host/systemparticipation.go b/internal/host/systemparticipation.go
index ca1f9bca6..5041b7add 100644
--- a/internal/host/systemparticipation.go
+++ b/internal/host/systemparticipation.go
@@ -35,9 +35,9 @@ type updateBegun struct {
At time.Time `json:"at"`
}
-// hostexecParticipation requires root participation and launch completeness.
+// hostexecParticipation preserves the legacy region-HA check until root owns a census.
func hostexecParticipation(dataDir string) error {
- return hostfloor.CheckControllersIdle(dataDir)
+ return hostfloor.CheckParticipation(dataDir, false)
}
// beginExecutableChange is §5.10's root side, run under the apply lock
@@ -49,6 +49,13 @@ func hostexecParticipation(dataDir string) error {
// update is refused and retried later). The agent's leg re-requests a
// deferred apply on its backoff ladder, at most every five minutes.
func (a *systemApplier) beginExecutableChange(target string, promotedAt time.Time) error {
+ required, err := a.participationDisk().ParticipationRequired(context.Background())
+ if err != nil {
+ return err
+ }
+ if !required && !a.regionHA {
+ return nil
+ }
active, err := a.activeFloor()
if err != nil {
return err
@@ -78,13 +85,25 @@ func (a *systemApplier) beginExecutableChange(target string, promotedAt time.Tim
if !a.regionHA {
return a.participationDisk().WithParticipation(context.Background(), func(l rootparticipation.Ledger) error {
return l.BeginUpdate(context.Background(), "system-"+target)
- })
+ }, func() error { return nil })
}
- err = a.participationDisk().BeginParticipation(context.Background(), "system-"+target, a.dataDir, func() error {
+ writeMarker := func() error {
if err := os.MkdirAll(systemGuardDir(a.optDir), 0o755); err != nil {
return err
}
return writeJSONDurable(updateBegunPath(a.optDir), updateBegun{Target: target, PromotedAt: promotedAt, At: a.ops.now().UTC()}, 0o644)
+ }
+ err = a.participationDisk().BeginParticipation(context.Background(), "system-"+target, a.dataDir, writeMarker, func() error {
+ if err := writeMarker(); err != nil {
+ return err
+ }
+ if reason := a.participationDisk().CheckParticipation(context.Background(), a.dataDir, false); reason != nil {
+ if err := a.clearLegacyUpdateBegun(); err != nil {
+ a.ops.logf("system apply: %v", err)
+ }
+ return reason
+ }
+ return nil
})
if err != nil {
return fmt.Errorf("%w: %v", errSystemDeferred, err)
@@ -112,7 +131,18 @@ func (a *systemApplier) clearUpdateBegun() error {
}
return hostfloor.RemoveDurable(updateBegunPath(a.optDir))
})
- })
+ }, a.clearLegacyUpdateBegun)
+}
+
+func (a *systemApplier) clearLegacyUpdateBegun() error {
+ err := os.Remove(updateBegunPath(a.optDir))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("remove %s: %w", updateBegunPath(a.optDir), err)
+ }
+ return syncDir(systemGuardDir(a.optDir))
}
// settleUpdateBegun clears the marker once no update is in progress: no
@@ -165,6 +195,10 @@ func (a *systemApplier) participationDisk() hostfloor.Disk {
}
func (a *systemApplier) checkUnarmedParticipationRecovery() error {
+ required, err := a.participationDisk().ParticipationRequired(context.Background())
+ if err != nil || !required {
+ return err
+ }
j, err := readSystemJournal(a.optDir)
if err != nil {
return err
@@ -182,7 +216,7 @@ func (a *systemApplier) checkUnarmedParticipationRecovery() error {
}
recover = true
return nil
- })
+ }, func() error { return nil })
if err != nil {
return err
}
@@ -192,7 +226,7 @@ func (a *systemApplier) checkUnarmedParticipationRecovery() error {
}
}
if reserve {
- return a.participationDisk().WithParticipation(context.Background(), func(l rootparticipation.Ledger) error { return l.BeginUpdate(context.Background(), "recover-system") })
+ return a.participationDisk().WithParticipation(context.Background(), func(l rootparticipation.Ledger) error { return l.BeginUpdate(context.Background(), "recover-system") }, func() error { return nil })
}
return nil
}
diff --git a/internal/hostfloor/disk.go b/internal/hostfloor/disk.go
index 989306f84..a4a9862a8 100644
--- a/internal/hostfloor/disk.go
+++ b/internal/hostfloor/disk.go
@@ -149,7 +149,20 @@ func (d Disk) Begin(id, dataDir string) error {
raw, _ := json.Marshal(struct {
ID string `json:"id"`
}{id})
- return d.BeginParticipation(context.Background(), id, dataDir, func() error { return Durable(marker, raw, 0644) })
+ return d.WithParticipation(context.Background(), func(l rootparticipation.Ledger) error {
+ if err := checkLegacyParticipation(dataDir, false); err != nil {
+ return err
+ }
+ return l.BeginUpdateWith(context.Background(), id, func() error { return Durable(marker, raw, 0644) })
+ }, func() error {
+ if err := Durable(marker, raw, 0644); err != nil {
+ return err
+ }
+ if err := checkLegacyParticipation(dataDir, true); err != nil {
+ return errors.Join(err, RemoveDurable(marker))
+ }
+ return nil
+ })
}
// CheckControllersIdle refuses while any controller state is present. The
@@ -181,16 +194,33 @@ func UpdateIdle(guardDir, journal string) error {
return nil
}
-func (d Disk) WithParticipation(ctx context.Context, fn func(rootparticipation.Ledger) error) error {
- if d.Participation != nil {
- return fn(*d.Participation)
+func (d Disk) WithParticipation(ctx context.Context, fn func(rootparticipation.Ledger) error, legacy func() error) error {
+ l := d.Participation
+ var required bool
+ var err error
+ if l != nil {
+ required, err = l.UpdateRequired()
+ } else {
+ var opened rootparticipation.Ledger
+ opened, required, err = rootparticipation.OpenForUpdate()
+ l = &opened
+ if l.State != nil {
+ defer l.State.Close()
+ }
}
- l, err := rootparticipation.Open()
if err != nil {
return err
}
- defer l.State.Close()
- return fn(l)
+ if !required {
+ return legacy()
+ }
+ return fn(*l)
+}
+
+func (d Disk) ParticipationRequired(ctx context.Context) (bool, error) {
+ required := false
+ err := d.WithParticipation(ctx, func(rootparticipation.Ledger) error { required = true; return nil }, func() error { return nil })
+ return required, err
}
func (d Disk) ControllersIdle(ctx context.Context, dataDir ...string) error {
@@ -199,17 +229,24 @@ func (d Disk) ControllersIdle(ctx context.Context, dataDir ...string) error {
return err
}
if len(dataDir) == 1 {
- return checkLegacyParticipationHint(dataDir[0])
+ return checkLegacyParticipation(dataDir[0], false)
}
return nil
+ }, func() error {
+ if len(dataDir) != 1 {
+ return ErrFloor
+ }
+ return checkLegacyParticipation(dataDir[0], true)
})
}
-func (d Disk) BeginParticipation(ctx context.Context, id, dataDir string, commit func() error) error {
- if err := checkLegacyParticipationHint(dataDir); err != nil {
- return err
- }
- return d.WithParticipation(ctx, func(l rootparticipation.Ledger) error { return l.BeginUpdateWith(ctx, id, commit) })
+func (d Disk) BeginParticipation(ctx context.Context, id, dataDir string, commit, legacy func() error) error {
+ return d.WithParticipation(ctx, func(l rootparticipation.Ledger) error {
+ if err := checkLegacyParticipation(dataDir, false); err != nil {
+ return err
+ }
+ return l.BeginUpdateWith(ctx, id, commit)
+ }, legacy)
}
func (d Disk) EndParticipation(ctx context.Context, id string, commit func() error) error {
@@ -222,5 +259,10 @@ func (d Disk) EndParticipation(ctx context.Context, id string, commit func() err
return err
}
return l.EndUpdateWith(ctx, id, commit)
+ }, func() error {
+ if commit != nil {
+ return commit()
+ }
+ return nil
})
}
diff --git a/internal/hostfloor/participation.go b/internal/hostfloor/participation.go
index 4c7387539..9d24596ec 100644
--- a/internal/hostfloor/participation.go
+++ b/internal/hostfloor/participation.go
@@ -9,19 +9,29 @@ import (
"sort"
"strings"
+ "github.com/carlosframework/platform/internal/rootparticipation"
"github.com/carlosframework/platform/internal/safefile"
)
// CheckParticipation reads only directory entries. Floor-armed boxes require
// the controller directory; legacy region HA boxes may not have one yet.
func CheckParticipation(dataDir string, requireDirectory bool) error {
- return (Disk{}).ControllersIdle(context.Background(), dataDir)
+ return (Disk{}).CheckParticipation(context.Background(), dataDir, requireDirectory)
}
-func checkLegacyParticipationHint(dataDir string) error {
+func (d Disk) CheckParticipation(ctx context.Context, dataDir string, requireDirectory bool) error {
+ return d.WithParticipation(ctx, func(l rootparticipation.Ledger) error {
+ if err := l.Idle(ctx); err != nil {
+ return err
+ }
+ return checkLegacyParticipation(dataDir, false)
+ }, func() error { return checkLegacyParticipation(dataDir, requireDirectory) })
+}
+
+func checkLegacyParticipation(dataDir string, requireDirectory bool) error {
dir := filepath.Join(dataDir, ".hostexec")
f, err := safefile.OpenDir(dir)
- if errors.Is(err, os.ErrNotExist) {
+ if errors.Is(err, os.ErrNotExist) && !requireDirectory {
return nil
}
if err != nil {
diff --git a/internal/hostfloor/participation_flagoff_test.go b/internal/hostfloor/participation_flagoff_test.go
new file mode 100644
index 000000000..2e72bc0c8
--- /dev/null
+++ b/internal/hostfloor/participation_flagoff_test.go
@@ -0,0 +1,111 @@
+package hostfloor
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPlan14ParticipationFlagOffFloor(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", "")
+ for _, mirror := range []string{"absent", "empty", "pending", "symlink"} {
+ t.Run(mirror, func(t *testing.T) {
+ d := Disk{Dir: t.TempDir(), Participation: floorParticipationFixture(t)}
+ if err := d.Participation.State.Remove("census.json"); err != nil {
+ t.Fatal(err)
+ }
+ d.Participation.VerifyLaunches = nil
+ data := t.TempDir()
+ dir := filepath.Join(data, ".hostexec")
+ if mirror == "empty" || mirror == "pending" {
+ if err := os.Mkdir(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if mirror == "pending" {
+ if err := os.WriteFile(filepath.Join(dir, "pending.json"), []byte(`{}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if mirror == "symlink" {
+ if err := os.Symlink(t.TempDir(), dir); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, strict := range []bool{false, true} {
+ want := mirror == "empty" || (!strict && mirror == "absent")
+ if err := d.CheckParticipation(t.Context(), data, strict); (err == nil) != want {
+ t.Fatalf("strict=%t err=%v want admitted=%t", strict, err, want)
+ }
+ }
+ want := mirror == "empty"
+ if err := d.ControllersIdle(t.Context(), data); (err == nil) != want {
+ t.Fatalf("idle=%v want admitted=%t", err, want)
+ }
+ if err := d.Begin("legacy-update", data); (err == nil) != want {
+ t.Fatalf("begin=%v want admitted=%t", err, want)
+ }
+ marker := filepath.Join(d.Dir, "update-begun.json")
+ if _, err := os.Stat(marker); (err == nil) != want {
+ t.Fatalf("marker=%v", err)
+ }
+ if want {
+ if err := d.EndParticipation(t.Context(), "legacy-update", func() error { return RemoveDurable(marker) }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
+ t.Fatalf("completion retained marker: %v", err)
+ }
+ }
+ if _, err := d.Participation.UpdateID(); !os.IsNotExist(err) {
+ t.Fatalf("legacy floor created root reservation: %v", err)
+ }
+ })
+ }
+}
+
+func TestPlan14ParticipationRetainedCensusFloor(t *testing.T) {
+ for _, flag := range []string{"", "1"} {
+ for _, state := range []string{"idle", "pending", "no-launch-proof", "malformed", "missing"} {
+ t.Run(flag+"/"+state, func(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", flag)
+ d := Disk{Dir: t.TempDir(), Participation: floorParticipationFixture(t)}
+ data := t.TempDir()
+ if err := os.Mkdir(filepath.Join(data, ".hostexec"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ switch state {
+ case "pending":
+ if err := d.Participation.State.Write("shop.example.json", []byte(`{"Status":"pending"}`)); err != nil {
+ t.Fatal(err)
+ }
+ case "no-launch-proof":
+ d.Participation.VerifyLaunches = nil
+ case "malformed":
+ if err := d.Participation.State.Write("census.json", []byte(`{`)); err != nil {
+ t.Fatal(err)
+ }
+ case "missing":
+ if err := d.Participation.State.Remove("census.json"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ want := state == "idle" || (state == "missing" && flag == "")
+ if err := d.ControllersIdle(t.Context(), data); (err == nil) != want {
+ t.Fatalf("idle=%v want admitted=%t", err, want)
+ }
+ if err := d.Begin("update", data); (err == nil) != want {
+ t.Fatalf("begin=%v want admitted=%t", err, want)
+ }
+ if state == "idle" {
+ if err := d.Participation.Acquire(t.Context(), func() error { t.Fatal("acquired across update"); return nil }); err == nil {
+ t.Fatal("update did not exclude acquisition")
+ }
+ if err := d.EndParticipation(t.Context(), "update", nil); err != nil {
+ t.Fatal(err)
+ }
+ }
+ })
+ }
+ }
+}
diff --git a/internal/hostfloor/participation_test.go b/internal/hostfloor/participation_test.go
index 2a2383b25..a4734bb34 100644
--- a/internal/hostfloor/participation_test.go
+++ b/internal/hostfloor/participation_test.go
@@ -7,18 +7,23 @@ import (
)
func TestParticipationPreservesFloorAndLegacyDirectoryRules(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", "")
+ d := Disk{Participation: floorParticipationFixture(t)}
+ if err := d.Participation.State.Remove("census.json"); err != nil {
+ t.Fatal(err)
+ }
data := t.TempDir()
- if err := CheckParticipation(data, false); err == nil {
- t.Fatal("legacy directory absence replaced root completeness")
+ if err := d.CheckParticipation(t.Context(), data, false); err != nil {
+ t.Fatal(err)
}
- if err := CheckControllersIdle(data); err == nil {
+ if err := d.ControllersIdle(t.Context(), data); err == nil {
t.Fatal("floor accepted a missing controller directory")
}
if err := os.Symlink(t.TempDir(), filepath.Join(data, ".hostexec")); err != nil {
t.Skip(err)
}
for _, strict := range []bool{false, true} {
- if err := CheckParticipation(data, strict); err == nil {
+ if err := d.CheckParticipation(t.Context(), data, strict); err == nil {
t.Fatalf("symlink accepted with requireDirectory=%v", strict)
}
}
diff --git a/internal/provision/testdata/plan14-root-reads-merge.tsv b/internal/provision/testdata/plan14-root-reads-merge.tsv
index 9ee12c675..f70dc98b9 100644
--- a/internal/provision/testdata/plan14-root-reads-merge.tsv
+++ b/internal/provision/testdata/plan14-root-reads-merge.tsv
@@ -250,7 +250,7 @@ internal/sim/hostlife/mutate.sh external legacy accepted-legacy accepted-legacy;
@coverage internal/console 41714f3779b343460800cb25b31782dc7d5c1e83b2aacf7d8d0a28f39c47bf76
@coverage internal/console/templates 6492b7455fa3315105339cca03edb4372ac0d9bf746c018e13a308cf0b74cc05
@coverage internal/edgesfile 553af147ca4882b8425afddf1ed250ee40a6cac92361d667cb4a690393be5272
-@coverage internal/host d387b30eb462bab6a344e07efee7ad3954cffca8dfa9fb0dbf888dd9c41dc361
+@coverage internal/host 101625bb4dc26d58bb36d71a589b631d27e6d7b2a3298a0458342826606604df
@coverage internal/hostbox afb08c287eaccb43adde1c488bd122482711ce4e29ecb675c9428f27130c3d93
@coverage internal/hostclaim feaf701db4b49ce92733b9d6c1fa1c0ae8efc6e13439ce201fca1a9e24dcf927
@coverage internal/hostfiles 554285c123758375c13388b2fa42f654819392dac0b027816a713548ec17dcc6
@@ -268,7 +268,7 @@ internal/sim/hostlife/mutate.sh external legacy accepted-legacy accepted-legacy;
@coverage internal/status d5ef42fe4dc2cb72d1ce5e133e2c95a3e85960a222eb424f00b259b961b8d976
@coverage internal/updatewords 93bb83474bad5d20281c34e72fadf56e2847e9a7eee2385a78f46a11fc39a3b6
@coverage internal/hostexec 778261669d5e079b9f98d74807557fd5de5c4e0f663bd0f9d380cdad77ffbcf8
-@coverage internal/rootparticipation 99764b1531e32b6065716e7b92964de53e22448cc76644e4ac9acfab02fdcf07
+@coverage internal/rootparticipation aca36bd46a7879b200c765a3e66ed01329158546a258de6facc922c95e18bf75
internal/host/rootexecution_authority.go ClaimRootHTTP.executionAuthority authority D Fresh claim, deployment binding and root installation authorize participation, including the retained source only. file:Read@553 107989f561409fd8c11c2666d0672b8dbeb368842fe00ad6a7b84fece2150438
internal/host/rootexecution_read.go rootExecutionRead authority D Production root observer selects the descriptor-checked participation reader. c70216cd8e62430b43669d6cea5bcb9101fcb31c04aea178a951a620c369b1e5
internal/hostexec/participation.go PreinstallationParticipant authority D D1 selects only the fresh claim executor or named target in its allowed phase; Exec is a claim field, not a process invocation. No installation authority. file:Exec@1035,file:Exec@1307,file:Exec@276 f14568ea08c9d439d7239ac9d5d596a134a17627e1eac878c074629ca2ba7304
@@ -291,28 +291,30 @@ internal/host/rootexecution_migration.go ClaimRootHTTP.executionAuthorityWithMig
internal/hostexec/rootbroker.go RootBroker.recoverSettlement authority D Recovery serializes settlement with in-flight requests and requires cleanup, exact conditional release and CC9. TestPlan14ExecutionRecoverySettlesWithoutControllerToken and TestPlan14ExecutionRecoveryJoinsRemoteWrite. 88c7b2d1a863590e549a2477732badcb6e003289da8db46aee16a1d777a8f77d
internal/host/claimparticipation.go productionClaimLocal.participateHost authority D Production selects the broker client; the old local controller path is selected only by explicit legacy test fixtures. file:Read@121 035931959f2b44012e10624314b90bad360ca77f8fdf3d8300e2a29ffd33c0fb
internal/host/claimparticipation.go productionClaimLocal.prepareLegacyWith authority D Production selects the broker client; the old local controller path is selected only by explicit legacy test fixtures. file:Open@3226,file:ReadFile@2116 20a34982bf5184cba4e4691040ede402a2c93ccbc0571c9483ad28843a298673
-internal/host/guardtransaction.go systemApplier.checkGuardRecovery authority D Root participation exclusion persists through guard transaction completion and recovery. file:ReadFile@408,file:ReadFile@635 b1efe974172ec1e07bb27dc98e39b56af2eb459afd28335123451dc14fa21e40
+internal/host/guardtransaction.go systemApplier.checkGuardRecovery authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:ReadFile@408,file:ReadFile@635 b1efe974172ec1e07bb27dc98e39b56af2eb459afd28335123451dc14fa21e40
internal/host/rootexecution_client.go productionClaimLocal.prepareWith authority D Agent selects the single root lease owner over authenticated RPC; its mirror grants no root authority. file:Read@3239 269e72c57424e72d9fdbdd9191eac625877cca4ef1eff12e66d55299c727409b
-internal/host/rootexecution_recovery.go recoverRootExecution authority D Root recovery reads its complete census and reconstructs local cleanup without agent or broker RPC. file:Open@179 362b728a79c7542a5ec6ac980072e9ad2358ee09ee27f8f0b2dbd842581b9019
+internal/host/rootexecution_recovery.go recoverRootExecution authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. 5d0d5ecefd1fa2ca18068408dbaa7bff327a16d05d170580af0ac7c066ea2559
internal/host/rootexecution_runtime.go newClaimExecutionRuntime authority D Instance-control owns broker, checked store, monitor and bounded cleanup lifetime. file:Read@205 89dd8159a5181d315a3febff26646919ec4135807ea2cd2b1f89847d9f512f47
internal/host/rootexecution_runtime.go var openClaimExecutionAuthority authority D Instance-control owns broker, checked store, monitor and bounded cleanup lifetime. file:Open@76 dc9e1a3d2ccc63de2eec84fe1727d4c741372a227eb423d508749c4347731433
internal/host/rootexecution_settle.go ClaimRootHTTP.beginExecutionRecoveryFence authority D Root cleanup checks exact retained installation, launch journal and kernel evidence; CC9 requires a fresh terminal or exclusion claim. file:Read@146 dfbfdf5ae974363678e8e395ef0be2a64ea8c81c3f97ba766eeadccbba9c2fe6
internal/host/rootexecution_settle.go ClaimRootHTTP.cleanupExecution authority D Root cleanup checks exact retained installation, launch journal and kernel evidence; CC9 requires a fresh terminal or exclusion claim. file:Read@2438,file:Read@3761 9f715acdc31d97f33c1ab6efe227bd248a91a7a150498974c151426c943a9025
internal/host/rootexecution_settle.go ClaimRootHTTP.settleExecution authority D Root cleanup checks exact retained installation, launch journal and kernel evidence; CC9 requires a fresh terminal or exclusion claim. file:Read@108 ecd07f9675915c9cc747246369f624bbdf9a6f45369f3efdc1b2b49a8bd0e69b
-internal/host/systemparticipation.go systemApplier.beginExecutableChange authority D Executable updates use the root census and durable shared exclusion even after flag disable; legacy markers cannot prove idle. file:ReadFile@397 f3f8d6c7c81d17987898f26d2d0d6cc2434a6d1ed416eebb93e0b5318c5efe5b
-internal/host/systemparticipation.go systemApplier.clearUpdateBegun authority D Executable updates use the root census and durable shared exclusion even after flag disable; legacy markers cannot prove idle. file:Lstat@240,file:Lstat@522 0bd119032eed23a907985286105fa86bce820a0e5fcb26ae5ac8317718395497
-internal/hostfloor/disk.go Disk.Begin authority D Floor begin and completion serialize with root acquisition through the same durable transaction marker. file:Stat@151 6fb7ebd46a718df5d2f42531b8d62d97183c938d46f89c0ce3abdb71533494a9
-internal/hostfloor/disk.go Disk.WithParticipation authority D Floor begin and completion serialize with root acquisition through the same durable transaction marker. file:Open@175 3c0aa824ccfac423f0682463fd948f168ea00ef2894a321f6abb9effbf157891
-internal/hostfloor/participation.go CheckParticipation authority D Service files add refusal hints only; missing service files never establish root completeness. 0052ae112cc3cbf4acac979df4f19cd5cacdc23269d05584bd6025b515c30b8d
-internal/hostfloor/participation.go checkLegacyParticipationHint hint D Service files add refusal hints only; missing service files never establish root completeness. file:ReadDir@299 05ccec45f7e0b231253d024640defdf0c93e71f40303761b0fdc0cf3acb5af79
+internal/host/systemparticipation.go systemApplier.beginExecutableChange authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:ReadFile@561 e60e0fbb88aec4fa4b43ec5ba819ff633e5593cb85b8475d41951b9e1f848b68
+internal/host/systemparticipation.go systemApplier.clearUpdateBegun authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:Lstat@240,file:Lstat@522 971dae18d92aab847128b375532d6b8a86338fbcc03ed7e82ad91e0cd5297810
+internal/hostfloor/disk.go Disk.Begin authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:Stat@151 3bafc18b88f16afd726564684adf3ef9874d8271c925bcd299b9cbcdb0ccebd0
+internal/hostfloor/disk.go Disk.WithParticipation authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. 250c5c9b3e8a696c60f83a4c3d42eb8ef84a2371f55d20b7eeb540be42f5e285
+internal/hostfloor/participation.go CheckParticipation authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. 2353e6bce63d2889d1fae0fb0e6b098d0fc1de665e794f01e7085e5373eae53d
+internal/hostfloor/participation.go checkLegacyParticipation authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:ReadDir@339 e308eb277b9071c11efc6a3f1b6cbbbd7d59ca8420a3cc142b2f27af1d762379
internal/rootparticipation/ledger.go Ledger.BeginUpdateWith authority D Root census and transaction lock exclude acquisition and update; interrupted intents clear only after positive launch completeness. file:Read@314 70e9ee76d88fde902d99ed61b852bebdc5d40cc40fd10834c5be6f6095df193c
internal/rootparticipation/ledger.go Ledger.EndUpdateWith authority D Root census and transaction lock exclude acquisition and update; interrupted intents clear only after positive launch completeness. file:Read@247 9c7fbfb25b613c16742673c9785171394fc2a9206c3f996a2549e5d4c85a8911
internal/rootparticipation/ledger.go Ledger.UpdateID authority D Root census and transaction lock exclude acquisition and update; interrupted intents clear only after positive launch completeness. file:Read@110 41c1f1a6dd5baddadc384ef78f319fc3111f88e724ef0b39b3a3752a7057281d
internal/rootparticipation/ledger.go Ledger.readCensus authority D Root census and transaction lock exclude acquisition and update; interrupted intents clear only after positive launch completeness. file:Read@136 db268d0dbd24db646bbdab2b88ad17b1c43b7963a51ce473ee0a8895a1f951e6
-internal/rootparticipation/production.go Open authority D Production opens the fixed checked root namespace; missing E2 launch/kernel verifier refuses idle and update. file:Open@45,file:Open@5 82b11f7548ce7ace572a697f66b6ef22239fc3fdf2e85a7c5fde7b8720c4a75b
+internal/rootparticipation/production.go Open authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:Open@45,file:Open@5 82b11f7548ce7ace572a697f66b6ef22239fc3fdf2e85a7c5fde7b8720c4a75b
@remove internal/host/claimparticipation.go productionClaimLocal.prepareWith
-@coverage internal/hostfloor 52f6b3ff07b4e632913bfff806e74eaeb9aac0b14c6353a0b02bb1caf119baa3
-internal/host/guardtransaction.go installFloorHook authority D Root update recovery requires positive participation and shared exclusion with or without an armed floor. file:Lstat@93,file:ReadDir@259 6956aea590b55d435ef0321a6e2b365f3df074bbe91d09acb6215f19e4de622c
-internal/host/guardtransaction.go legacyGuardEntries authority D Root update recovery requires positive participation and shared exclusion with or without an armed floor. file:ReadFile@265 2d73ba69020ae86926e29d9e82da581517da3dbc23e4364b4cad8f7746a2d278
-internal/host/guardtransaction.go systemApplier.beginGuard authority D Root update recovery requires positive participation and shared exclusion with or without an armed floor. file:Read@679,file:Read@903 d5056ac9aa4bff1fc5add3e8ae59ce8352d2285446290e03663466c5097aa54c
-internal/host/guardtransaction.go systemApplier.guardTransaction authority D Root update recovery requires positive participation and shared exclusion with or without an armed floor. file:ReadFile@84 6d364c272ca45684115c5d61eb13249a5e4b93b92cc17ce2c306c3a7f05fa302
+@coverage internal/hostfloor e1b8271eaa6e464b4b5b9a480ea71e71ceafcf9750bf2f07ef2537713ba88d1e
+internal/host/guardtransaction.go installFloorHook authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:Lstat@93,file:ReadDir@259 6956aea590b55d435ef0321a6e2b365f3df074bbe91d09acb6215f19e4de622c
+internal/host/guardtransaction.go legacyGuardEntries authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:ReadFile@265 2d73ba69020ae86926e29d9e82da581517da3dbc23e4364b4cad8f7746a2d278
+internal/host/guardtransaction.go systemApplier.beginGuard authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:Read@679,file:Read@903 d5056ac9aa4bff1fc5add3e8ae59ce8352d2285446290e03663466c5097aa54c
+internal/host/guardtransaction.go systemApplier.guardTransaction authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:ReadFile@84 6d364c272ca45684115c5d61eb13249a5e4b93b92cc17ce2c306c3a7f05fa302
+internal/rootparticipation/production.go Ledger.UpdateRequired authority D Select root participation when claims are enabled or a census exists; only absent state with claims off selects the legacy protocol. file:Read@172 98feb072e094d618a23cd8239f182179eed4881eee5f063dc825693dbe13e064
+internal/rootparticipation/production.go OpenForUpdate authority D Select root participation when claims are enabled or a census exists; only absent state with claims off selects the legacy protocol. file:Open@56 41558ce678c04427ee55c0edbe68cd652402247a0c8449975c4a47d344182053
diff --git a/internal/rootparticipation/production.go b/internal/rootparticipation/production.go
index 5f4820fe0..7932f52e2 100644
--- a/internal/rootparticipation/production.go
+++ b/internal/rootparticipation/production.go
@@ -2,8 +2,12 @@ package rootparticipation
import (
"context"
+ "errors"
+ "os"
"sync"
+ "github.com/carlosframework/platform/internal/hostwrite"
+
"github.com/carlosframework/platform/internal/rootstate"
)
@@ -44,3 +48,35 @@ func VerifyLaunchCensus(ctx context.Context, revision uint64) error {
}
return verify(ctx, revision)
}
+
+// UpdateRequired keeps a retained census authoritative after claims are disabled.
+// Only an absent census with the flag off selects the legacy update protocol.
+func (l Ledger) UpdateRequired() (bool, error) {
+ if hostwrite.Enabled(os.Getenv) {
+ return true, nil
+ }
+ if l.State == nil {
+ return false, ErrUnavailable
+ }
+ _, err := l.State.Read("census.json")
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil
+ }
+ return true, err
+}
+
+func OpenForUpdate() (Ledger, bool, error) {
+ l, err := Open()
+ if errors.Is(err, os.ErrNotExist) && !hostwrite.Enabled(os.Getenv) {
+ return Ledger{}, false, nil
+ }
+ if err != nil {
+ return Ledger{}, false, err
+ }
+ required, err := l.UpdateRequired()
+ if err != nil {
+ l.State.Close()
+ return Ledger{}, false, err
+ }
+ return l, required, nil
+}