carlos / platform Public

WIP claim Plan 14 track E checkpoint 6 (not for merge): independent named-unit recovery ahead of history selection, signed-promotion restart capture, retained recovery outcomes in mint refusals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 78468311c68ba1c52b8ed4005baacff521692fa2 parent 91fe61c
16 files changed, +956 −73
  • internal/host/agent.go +29 −31
  • internal/host/namedrecovery_active.go +204 −0
  • internal/host/plan14_active_recovery_process_test.go +143 −0
  • internal/host/plan14_active_recovery_test.go +189 −0
  • internal/host/plan14_recovery_mint_test.go +84 −0
  • internal/host/plan14_recovery_without_health_test.go +95 −0
  • internal/host/proofbinding.go +30 −10
  • internal/host/recoveryrefusals.go +74 −0
  • internal/host/roothistory_recovery.go +10 −7
  • internal/host/rootrestart.go +28 −6
  • internal/host/rootrollback.go +12 −2
  • internal/host/rootrollback_resume.go +1 −2
  • internal/provision/plan9_b8_census_test.go +2 −2
  • internal/provision/testdata/plan14-root-reads-merge.tsv +20 −13
  • internal/release/adopt.go +31 −0
  • internal/release/roothistory_pass.go +4 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index db3f88ac7..f34f0df70 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -4392,17 +4392,6 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni
// firing on a trigger that has nothing to do with it.
return
}
- if routes, err := reg.List(); err != nil {
- log.Printf("adopt: %v", err)
- } else {
- recovery := productionNamedUnitRecovery(store, pub, release.OptDir(), claims.Writes)
- for _, rt := range routes {
- if _, err := recovery.repair(ctx, rt.Host); err != nil {
- log.Printf("adopt: %v", err)
- }
- }
- }
-
// The restart-request half again (issue #113), here as the FLOOR under
// carlos-restart.path: that relay can drop an event that lands while the
// service is mid-run, exactly as the config one can, and a dropped restart
@@ -4475,11 +4464,15 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni
// a relayed stage is exactly the request that performs such a restart.
history, err := openRootHistory(store, release.OptDir())
if err != nil {
- log.Fatalf("adopt: %v", err)
+ log.Printf("adopt: %v", err)
+ } else {
+ defer history.History.Store.Close()
+ linkRootHistory(history, restartChecks, release.OptDir(), os.Getenv)
}
- defer history.History.Store.Close()
- linkRootHistory(history, restartChecks, release.OptDir(), os.Getenv)
- passOpts := []release.PassOption{release.WithRootHistory(history), release.WithCapabilities(caps.Ops{}), release.BakeDir(dataDir()), release.WithRestartFailurePropagation(), release.WithRestartAttemptSettled(restartChecks.finish),
+ recovery := productionNamedUnitRecovery(store, pub, release.OptDir(), claims.Writes)
+ passOpts := []release.PassOption{release.WithRootHistory(history), release.WithRecovery(func(ctx context.Context, rt registry.Route) (bool, error) {
+ return recovery.recover(ctx, rt.Host, restartChecks, os.Getenv, reg)
+ }), release.WithCapabilities(caps.Ops{}), release.BakeDir(dataDir()), release.WithRestartFailurePropagation(), release.WithRestartAttemptSettled(restartChecks.finish),
release.WithSelectedRestarts(
rootSelectedRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, settled, restartChecks, false),
rootSelectedRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, nil, restartChecks, true)),
@@ -4502,22 +4495,27 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni
// adopt a proven head over it (final review H4). After it, mint on a
// proving box and, on the hourly pass, say when a requirement is off;
// a route the pass changed is looked at again first.
- pr := proofRoot{
- recordProgram: func(b liveBuild) error { return recordRootProgram(history, b) },
- refusals: history.History.MintRefusals,
- obs: productionProofObserver(release.OptDir()),
- reg: reg,
- store: store,
- bakeDir: dataDir(),
- healthyDir: release.RootHealthyDir,
- policyPath: release.ProofPolicyPath,
- readKey: readProvingKey,
- hourly: !*stageOnly,
- logf: log.Printf,
- }
- if err := pr.around(ctx, func() error {
- return release.Pass(ctx, reg, store, pub, release.OptDir(), restart, passOpts...)
- }); err != nil {
+ pass := func() error { return release.Pass(ctx, reg, store, pub, release.OptDir(), restart, passOpts...) }
+ if history != nil {
+ pr := proofRoot{
+ recordProgram: func(b liveBuild) error { return recordRootProgram(history, b) },
+ refusals: func(b release.HistoryBinding) ([]release.Refusal, error) {
+ return restartChecks.mintRefusals(history.History.MintRefusals, b)
+ },
+ obs: productionProofObserver(release.OptDir()),
+ reg: reg,
+ store: store,
+ bakeDir: dataDir(),
+ healthyDir: release.RootHealthyDir,
+ policyPath: release.ProofPolicyPath,
+ readKey: readProvingKey,
+ hourly: !*stageOnly,
+ logf: log.Printf,
+ }
+ ordinary := pass
+ pass = func() error { return pr.around(ctx, ordinary) }
+ }
+ if err := pass(); err != nil {
log.Fatalf("adopt: %v", err)
}
diff --git a/internal/host/namedrecovery_active.go b/internal/host/namedrecovery_active.go
new file mode 100644
index 000000000..78e4eeca0
--- /dev/null
+++ b/internal/host/namedrecovery_active.go
@@ -0,0 +1,204 @@
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "reflect"
+
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func (n namedUnitRecovery) recover(ctx context.Context, host string, c rootRestartChecks, getenv func(string) string, reg *registry.Registry) (bool, error) {
+ if n.source == nil || n.readLive == nil {
+ return true, os.ErrPermission
+ }
+ record, _, err := provision.FindRecordByHost(ctx, n.source.store, host)
+ if err != nil {
+ return true, err
+ }
+ if record.Unit == "" {
+ return false, nil
+ }
+ if handled, err := n.repair(ctx, host); handled || err != nil {
+ return handled, err
+ }
+ rec, b, props, err := n.source.unitBinding(ctx, host)
+ if err != nil {
+ return true, err
+ }
+ if props.ActiveState != "active" || props.MainPID <= 0 {
+ return true, os.ErrPermission
+ }
+ old, err := n.source.forHost(ctx, host)
+ if err != nil || old.Source != b.Source {
+ return true, errors.Join(err, os.ErrPermission)
+ }
+ live := release.LiveLink(n.source.optDir, host)
+ before, err := n.readLive(live)
+ if err != nil || before != release.VersionPath(n.source.optDir, rec.Account, old.Version, rec.App) {
+ return true, errors.Join(err, os.ErrPermission)
+ }
+ pointer, manifest, err := release.StageSignedHead(ctx, n.source.store, n.pub, n.ratchet, n.source.optDir, b.Route)
+ if err != nil {
+ return true, err
+ }
+ if rec.Pin != "" && rec.Pin != pointer.Version {
+ return true, os.ErrPermission
+ }
+ target := release.VersionPath(n.source.optDir, rec.Account, pointer.Version, rec.App)
+ digest := manifest.Artifacts[release.AdoptPlatform()].SHA256
+ work, err := n.writes.Select(ctx, host, "release")
+ if err != nil {
+ return true, err
+ }
+ id := hostwrite.Identity{}
+ if n.writes.Enabled {
+ raw, err := json.Marshal(manifest)
+ if err != nil {
+ return true, err
+ }
+ sum := sha256.Sum256(raw)
+ id.Release = "sha256:" + hex.EncodeToString(sum[:])
+ }
+ effect := func(fn func() error) error { return work.With(ctx, id, fn) }
+ validate := func() error {
+ current, err := n.source.forHost(ctx, host)
+ if err != nil {
+ return err
+ }
+ if current.Source != old.Source || current.Version != old.Version || current.Digest != old.Digest || current.PID != old.PID || current.Start != old.Start || current.Invocation != old.Invocation {
+ return os.ErrPermission
+ }
+ fresh, err := release.Channel(ctx, n.source.store, n.pub, rec.Account, rec.App, b.Route.Channel)
+ if err != nil {
+ return err
+ }
+ if !reflect.DeepEqual(fresh, pointer) {
+ return os.ErrPermission
+ }
+ still, err := n.readLive(live)
+ if err != nil || still != before {
+ return os.ErrPermission
+ }
+ f, err := n.source.openFile(target)
+ if err != nil {
+ return err
+ }
+ hash := sha256.New()
+ _, readErr := io.Copy(hash, f)
+ f.Close()
+ if readErr != nil {
+ return readErr
+ }
+ if hex.EncodeToString(hash.Sum(nil)) != digest {
+ return os.ErrPermission
+ }
+ return nil
+ }
+ if before == target {
+ c.effect, c.verifyOnly = effect, true
+ if err := effect(validate); err != nil {
+ return true, err
+ }
+ if err := restartBoundUnit(n.source.sys, n.source.optDir, getenv, old, false, nil, c); err != nil {
+ return true, err
+ }
+ return true, effect(func() error {
+ if err := validate(); err != nil {
+ return err
+ }
+ current, err := reg.Get(host)
+ if err != nil || current == nil {
+ return errors.Join(err, os.ErrPermission)
+ }
+ if current.Version == pointer.Version {
+ return nil
+ }
+ return reg.SetVersions(host, pointer.Version, current.Version)
+ })
+ }
+ if c.attempts == nil || c.now == nil {
+ return true, os.ErrPermission
+ }
+ s, err := c.attempts()
+ if err != nil {
+ return true, err
+ }
+ defer s.Close()
+ unlock, err := s.Lock(ctx, rollbackHostKey(host)+".lock")
+ if err != nil {
+ return true, err
+ }
+ defer unlock()
+ var superseded *rollbackAttempt
+ if prior, err := readRollbackAttempt(s, host); err == nil {
+ sameTarget := prior.History != nil && prior.History.Source == old.Source && prior.History.Version == pointer.Version && prior.History.SHA256 == digest && prior.History.PromotedAt.Equal(pointer.PromotedAt)
+ if sameTarget && (!prior.Settled || prior.Failed) {
+ return true, os.ErrPermission
+ }
+ if !prior.Settled {
+ superseded = &prior
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return true, err
+ }
+ var attempt rollbackAttempt
+ err = effect(func() error {
+ if err := validate(); err != nil {
+ return err
+ }
+ if superseded != nil {
+ if err := settleRollbackAttempt(s, *superseded); err != nil {
+ return err
+ }
+ }
+ var err error
+ attempt, err = captureRollback(s, old, c.now(), unitSettleTimeout(getenv), target, id, historyRollbackLink{
+ Binding: release.HistoryBinding{Host: host, Account: rec.Account, App: rec.App},
+ Attempt: release.HistoryAttempt{ID: "signed-recovery", Source: old.Source, Version: pointer.Version, SHA256: digest, Platform: release.AdoptPlatform(), Started: c.now().UTC(), PromotedAt: pointer.PromotedAt}, Independent: true,
+ })
+ if err != nil {
+ return err
+ }
+ if err := release.PointLive(n.source.optDir, host, rec.Account, pointer.Version, rec.App); err != nil {
+ return err
+ }
+ dir, err := os.Open(filepath.Dir(live))
+ if err != nil {
+ return err
+ }
+ defer dir.Close()
+ return dir.Sync()
+ })
+ if err != nil {
+ return true, err
+ }
+ c.identity, c.effect = id, effect
+ if err := restartBoundUnit(n.source.sys, n.source.optDir, getenv, old, false, nil, c); err != nil {
+ attempt.Failed = true
+ if writeErr := writeRollbackState(s, rollbackJournalKey(host), attempt); writeErr != nil {
+ return true, errors.Join(err, writeErr)
+ }
+ return true, errors.Join(err, resumeHistoryRestart(ctx, n.source.sys, n.source.optDir, getenv, c, n.writes, s, attempt))
+ }
+ attempt.Verified = true
+ if err := settleRollbackAttempt(s, attempt); err != nil {
+ return true, err
+ }
+ return true, effect(func() error {
+ current, err := c.host(ctx, host)
+ if err != nil || current.Source != old.Source || current.Version != pointer.Version || current.Digest != digest {
+ return errors.Join(err, os.ErrPermission)
+ }
+ return reg.SetVersions(host, pointer.Version, old.Version)
+ })
+}
diff --git a/internal/host/plan14_active_recovery_process_test.go b/internal/host/plan14_active_recovery_process_test.go
new file mode 100644
index 000000000..e9de5741e
--- /dev/null
+++ b/internal/host/plan14_active_recovery_process_test.go
@@ -0,0 +1,143 @@
+package host
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func TestPlan14ActiveRecoveryProcessCrash(t *testing.T) {
+ if boundary := os.Getenv("PLAN14_ACTIVE_CRASH"); boundary != "" {
+ dir := os.Getenv("PLAN14_ACTIVE_DIR")
+ e, u, c, source, pub := signedRecoveryFixture(t)
+ storeDir := filepath.Join(dir, "deployment")
+ store := release.NewDirStore(storeDir)
+ keys, err := e.store.List(t.Context(), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range keys {
+ raw, _, err := e.store.Get(t.Context(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Put(t.Context(), key, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ source.store = store
+ disk := &rollbackDisk{dir: filepath.Join(dir, "attempts")}
+ if err := os.Mkdir(disk.dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ c.attempts = func() (rootstate.Store, error) { return disk, nil }
+ crash := func() {
+ raw, err := json.Marshal(rollbackProcessFixture{Props: u.props, Exe: u.exe, Opt: u.opt, Store: storeDir, Effects: u.effects})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "fixture.json"), raw, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "public-key"), []byte(os.Getenv("CARLOS_RELEASE_PUBKEY")), 0600); err != nil {
+ t.Fatal(err)
+ }
+ os.Exit(75)
+ }
+ disk.afterWrite = func(k string, raw []byte) {
+ if k != rollbackJournalKey(observedHost) {
+ return
+ }
+ var a rollbackAttempt
+ if json.Unmarshal(raw, &a) != nil {
+ t.Fatal("bad attempt")
+ }
+ if boundary == "intent" && !a.Failed && !a.Recovering && !a.Settled {
+ crash()
+ }
+ if boundary == "rollback pending" && a.Recovering && !a.Settled {
+ crash()
+ }
+ if boundary == "verified" && a.Verified && a.Settled {
+ crash()
+ }
+ }
+ u.crashNew = boundary == "forward failed" || boundary == "rollback pending" || boundary == "rollback running"
+ u.afterEffect = func() {
+ if len(u.effects) == 1 && (boundary == "forward running" || boundary == "forward failed") {
+ crash()
+ }
+ if len(u.effects) == 3 && boundary == "rollback running" {
+ crash()
+ }
+ }
+ n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink}
+ _, err = n.recover(t.Context(), observedHost, c, func(string) string { return "5s" }, e.root.reg)
+ t.Fatalf("crash boundary not reached: %v", err)
+ }
+ for _, boundary := range []string{"intent", "forward running", "forward failed", "rollback pending", "rollback running", "verified"} {
+ t.Run(boundary, func(t *testing.T) {
+ dir := t.TempDir()
+ cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestPlan14ActiveRecoveryProcessCrash$", "-test.count=1")
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "TMPDIR="+dir, "PLAN14_ACTIVE_CRASH="+boundary, "PLAN14_ACTIVE_DIR="+dir, "CARLOS_HOST_CLAIM=")
+ out, err := cmd.CombinedOutput()
+ var exit *exec.ExitError
+ if !errors.As(err, &exit) || exit.ExitCode() != 75 {
+ t.Fatalf("child: %v\n%s", err, out)
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, "fixture.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f rollbackProcessFixture
+ if err := json.Unmarshal(raw, &f); err != nil {
+ t.Fatal(err)
+ }
+ key, err := os.ReadFile(filepath.Join(dir, "public-key"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_RELEASE_PUBKEY", string(key))
+ u := &plan14RestartUnits{props: f.Props, exe: f.Exe, opt: f.Opt, effects: f.Effects}
+ source := &proofBindingSource{store: release.NewDirStore(f.Store), sys: u, optDir: u.opt, openFile: os.Open, unitDirs: []string{filepath.Dir(u.props.FragmentPath)}}
+ disk := &rollbackDisk{dir: filepath.Join(dir, "attempts")}
+ a, err := readRollbackAttempt(disk, observedHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := a.Created.Add(time.Second)
+ c := rootRestartChecks{attempts: func() (rootstate.Store, error) { return disk, nil }, host: source.forHost, rollback: source.rollback, prepareRollback: source.prepareRollback, recovered: source.recovered, ready: func(registry.Route) func() error { return func() error { return nil } }, now: func() time.Time { return now }, sleep: func(d time.Duration) { now = now.Add(d) }, history: func() (*release.RootHistoryPass, error) {
+ t.Fatal("independent recovery opened history")
+ return nil, os.ErrPermission
+ }}
+ before := len(u.effects)
+ if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil {
+ t.Fatal(err)
+ }
+ want := "v1"
+ if boundary == "forward running" || boundary == "verified" {
+ want = "v2"
+ }
+ if u.exe != release.VersionPath(u.opt, "bab", want, "console") || u.props.MainPID <= 0 {
+ t.Fatalf("wrong recovered process: %+v", u)
+ }
+ if boundary != "forward failed" && boundary != "rollback pending" && len(u.effects) != before {
+ t.Fatalf("effect repeated: %v", u.effects)
+ }
+ before = len(u.effects)
+ if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil || len(u.effects) != before {
+ t.Fatalf("replay: %v %v", err, u.effects)
+ }
+ })
+ }
+}
diff --git a/internal/host/plan14_active_recovery_test.go b/internal/host/plan14_active_recovery_test.go
new file mode 100644
index 000000000..45371e07a
--- /dev/null
+++ b/internal/host/plan14_active_recovery_test.go
@@ -0,0 +1,189 @@
+package host
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/provision"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestPlan14SignedActiveFixIgnoresHistory(t *testing.T) {
+ for _, fault := range []string{"unavailable", "missing", "malformed", "unwritable", "refused", "pending", "unknown bake", "claims on"} {
+ t.Run(fault, func(t *testing.T) {
+ e, u, c, source, pub := signedRecoveryFixture(t)
+ rt, err := e.root.reg.Get(observedHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := seedHostRootHistory(t, *rt)
+ b := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App}
+ switch fault {
+ case "missing":
+ h.Store.(*rollbackMemory).files = map[string][]byte{}
+ case "malformed":
+ for k := range h.Store.(*rollbackMemory).files {
+ h.Store.(*rollbackMemory).files[k] = []byte("{")
+ }
+ case "unwritable":
+ h.Store.(*rollbackMemory).failWrite = true
+ case "refused":
+ if err := h.AddRefusal(b, release.Refusal{Host: rt.Host, App: rt.App, Version: "v2", Kind: release.RefusalWrongProgram, RefusedAt: time.Now()}); err != nil {
+ t.Fatal(err)
+ }
+ case "pending":
+ if err := h.Begin(b, release.HistoryAttempt{ID: "unsettled", Version: "v0", SHA256: "abcd", Source: "old-source", Platform: release.AdoptPlatform(), Started: time.Now(), PromotedAt: time.Now()}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ deps := boundRootHistory(h, source.resolve, e.root.obs)
+ if fault == "unavailable" {
+ deps = nil
+ }
+ if fault == "unknown bake" {
+ if err := h.Store.Remove("bake.json"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := h.LoadBake([]release.HistoryBinding{b}, time.Now()); err == nil {
+ t.Fatal("fixture unexpectedly has bake completeness")
+ }
+ }
+ c.history = func() (*release.RootHistoryPass, error) { return nil, os.ErrPermission }
+ n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink}
+ if fault == "claims on" {
+ n.writes = hostwrite.Config{Enabled: true, Selector: namedRecoveryWork{}}
+ }
+ err = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, nil,
+ release.WithRootHistory(deps), release.WithRecovery(func(ctx context.Context, rt registry.Route) (bool, error) {
+ return n.recover(ctx, rt.Host, c, func(string) string { return "5s" }, e.root.reg)
+ }))
+ if err != nil || len(u.effects) != 1 || u.exe != release.VersionPath(u.opt, rt.Account, "v2", rt.App) {
+ t.Fatalf("history prevented active repair: effects=%v error=%v", u.effects, err)
+ }
+ got, err := e.root.reg.Get(rt.Host)
+ if err != nil || got.Version != "v2" {
+ t.Fatalf("missing verified version: %+v %v", got, err)
+ }
+ if err := release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, nil,
+ release.WithRootHistory(deps), release.WithRecovery(func(ctx context.Context, rt registry.Route) (bool, error) {
+ return n.recover(ctx, rt.Host, c, func(string) string { return "5s" }, e.root.reg)
+ })); err != nil || len(u.effects) != 1 {
+ t.Fatalf("repair replay: %v %v", err, u.effects)
+ }
+ })
+ }
+}
+
+func TestPlan14SignedActiveFixAuthority(t *testing.T) {
+ for _, attack := range []string{"unsigned target", "unsigned predecessor", "changed artifact", "changed record", "changed unit", "changed pointer", "changed invocation", "operator stop", "held claim", "untrusted live", "capture failure", "rollback", "rollback fails"} {
+ t.Run(attack, func(t *testing.T) {
+ e, u, c, source, pub := signedRecoveryFixture(t)
+ rt, err := e.root.reg.Get(observedHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink}
+ mutate := func() {}
+ switch attack {
+ case "unsigned target":
+ n.pub = make([]byte, 32)
+ case "unsigned predecessor":
+ if err := e.store.Delete(t.Context(), release.PromotionRecordKey(rt.Account, rt.App, "v1")); err != nil {
+ t.Fatal(err)
+ }
+ case "changed artifact":
+ mutate = func() {
+ if err := os.WriteFile(release.VersionPath(u.opt, rt.Account, "v2", rt.App), []byte("wrong"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ case "changed record":
+ mutate = func() {
+ rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.RecordSeq++
+ plan14PutRecord(t, e.store, *rec)
+ }
+ case "changed unit":
+ mutate = func() {
+ if err := os.WriteFile(u.props.FragmentPath, []byte("wrong"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ case "changed pointer":
+ mutate = func() {
+ if err := e.store.Delete(t.Context(), release.ChannelKey(rt.Account, rt.App, rt.Channel)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ case "changed invocation":
+ mutate = func() { u.props.InvocationID += "other" }
+ case "operator stop":
+ mutate = func() { u.props.ActiveState, u.props.SubState, u.props.MainPID = "inactive", "dead", 0 }
+ case "held claim":
+ n.writes = hostwrite.Config{Enabled: true, Selector: namedRecoveryWork{deny: true}}
+ case "untrusted live":
+ n.readLive = func(string) (string, error) { return "", os.ErrPermission }
+ case "capture failure":
+ disk, _ := c.attempts()
+ disk.(*rollbackMemory).failWrite = true
+ case "rollback", "rollback fails":
+ u.crashNew = true
+ u.badRollback = attack == "rollback fails"
+ }
+ if strings.HasPrefix(attack, "changed ") || attack == "operator stop" {
+ n.writes = hostwrite.Config{Enabled: true, Selector: namedRecoveryWork{before: mutate}}
+ }
+ handled, err := n.recover(t.Context(), rt.Host, c, func(string) string { return "5s" }, e.root.reg)
+ if !handled || err == nil {
+ t.Fatalf("attack accepted: %v %v", handled, err)
+ }
+ if attack == "rollback" || attack == "rollback fails" {
+ if len(u.effects) != 3 {
+ t.Fatalf("rollback absent: %v %v", u.effects, err)
+ }
+ if attack == "rollback" && u.exe != release.VersionPath(u.opt, rt.Account, "v1", rt.App) {
+ t.Fatal("wrong recovered executable")
+ }
+ count := len(u.effects)
+ _, _ = n.recover(t.Context(), rt.Host, c, func(string) string { return "5s" }, e.root.reg)
+ if len(u.effects) != count {
+ t.Fatalf("repeated failed attempt: %v", u.effects)
+ }
+ } else {
+ live, liveErr := release.LiveVersion(u.opt, rt.Host)
+ if len(u.effects) != 0 || liveErr != nil || live != "v1" {
+ t.Fatalf("unauthorized effect: %v live=%s error=%v", u.effects, live, liveErr)
+ }
+ }
+ })
+ }
+}
+
+func TestPlan14SignedFixSupersedesOlderAttempt(t *testing.T) {
+ e, u, c, source, pub := signedRecoveryFixture(t)
+ b, err := c.host(t.Context(), observedHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ disk, err := c.attempts()
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = captureRollback(disk, b, c.now(), unitSettleTimeout(func(string) string { return "5s" }), release.VersionPath(u.opt, b.Route.Account, "obsolete", b.Route.App), c.identity, historyRollbackLink{Binding: release.HistoryBinding{Host: b.Route.Host, Account: b.Route.Account, App: b.Route.App}, Attempt: release.HistoryAttempt{ID: "older-attempt", Version: "obsolete", Source: b.Source, SHA256: "old-digest", PromotedAt: c.now()}, Independent: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink}
+ handled, err := n.recover(t.Context(), observedHost, c, func(string) string { return "5s" }, e.root.reg)
+ if !handled || err != nil || len(u.effects) != 1 || u.exe != release.VersionPath(u.opt, b.Route.Account, "v2", b.Route.App) {
+ t.Fatalf("old attempt blocked new signed fix: %v %v %v", handled, err, u.effects)
+ }
+}
diff --git a/internal/host/plan14_recovery_mint_test.go b/internal/host/plan14_recovery_mint_test.go
new file mode 100644
index 000000000..509d26b1f
--- /dev/null
+++ b/internal/host/plan14_recovery_mint_test.go
@@ -0,0 +1,84 @@
+package host
+
+import (
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestPlan14IndependentRecoveryRetainsMintBarrier(t *testing.T) {
+ e, u, c, source, pub := signedRecoveryFixture(t)
+ rt, err := e.root.reg.Get(observedHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := seedHostRootHistory(t, *rt)
+ b := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App}
+ u.crashNew = true
+ n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink}
+ if _, err := n.recover(t.Context(), rt.Host, c, func(string) string { return "5s" }, e.root.reg); err == nil {
+ t.Fatal("failed forward repair reported success")
+ }
+ if refs, err := c.mintRefusals(h.MintRefusals, b); err != nil || len(refs) != 1 || refs[0].Version != "v2" {
+ t.Fatalf("recovery denial lost: %+v %v", refs, err)
+ }
+ if err := checkedUnitRestart(u, u.opt, func(string) string { return "5s" }, nil, c)(*rt); err != nil {
+ t.Fatal(err)
+ }
+ if refs, err := c.mintRefusals(h.MintRefusals, b); err != nil || len(refs) != 1 || refs[0].Version != "v2" {
+ t.Fatalf("ordinary restart erased recovery denial: %+v %v", refs, err)
+ }
+ foreign := b
+ foreign.App = "another"
+ called := false
+ if _, err := c.mintRefusals(func(release.HistoryBinding) ([]release.Refusal, error) { called = true; return nil, nil }, foreign); err != nil || !called {
+ t.Fatalf("unrelated app blocked: %v", err)
+ }
+}
+
+func TestPlan14RecoveryMintEvidence(t *testing.T) {
+ for _, state := range []string{"verified", "pending", "malformed", "unsynced"} {
+ t.Run(state, func(t *testing.T) {
+ e, _, c, source, pub := signedRecoveryFixture(t)
+ rt, err := e.root.reg.Get(observedHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := seedHostRootHistory(t, *rt)
+ n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink}
+ if _, err := n.recover(t.Context(), rt.Host, c, func(string) string { return "5s" }, e.root.reg); err != nil {
+ t.Fatal(err)
+ }
+ s, err := c.attempts()
+ if err != nil {
+ t.Fatal(err)
+ }
+ a, err := readRollbackAttempt(s, rt.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ switch state {
+ case "pending":
+ a.Settled = false
+ if err := writeRollbackState(s, rollbackJournalKey(rt.Host), a); err != nil {
+ t.Fatal(err)
+ }
+ case "malformed":
+ if err := s.Write(recoveryResultKey(a), []byte("{")); err != nil {
+ t.Fatal(err)
+ }
+ case "unsynced":
+ s.(*rollbackMemory).failWrite = true
+ }
+ refs, err := c.mintRefusals(h.MintRefusals, release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App})
+ if state == "verified" {
+ if err != nil || len(refs) != 0 {
+ t.Fatalf("verified recovery blocked mint: %+v %v", refs, err)
+ }
+ } else if err == nil {
+ t.Fatalf("%s granted mint", state)
+ }
+ })
+ }
+}
diff --git a/internal/host/plan14_recovery_without_health_test.go b/internal/host/plan14_recovery_without_health_test.go
new file mode 100644
index 000000000..8cfa40b12
--- /dev/null
+++ b/internal/host/plan14_recovery_without_health_test.go
@@ -0,0 +1,95 @@
+package host
+
+import (
+ "crypto/ed25519"
+ "encoding/base64"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func signedRecoveryFixture(t *testing.T) (*proofRootEnv, *plan14RestartUnits, rootRestartChecks, *proofBindingSource, ed25519.PublicKey) {
+ t.Helper()
+ e, rt, u, c, _ := plan14AdoptionFixture(t)
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub))
+ if err := e.store.Delete(t.Context(), release.ChannelKey(rt.Account, rt.App, rt.Channel)); err != nil {
+ t.Fatal(err)
+ }
+ for _, ver := range []string{"v1", "v2"} {
+ if err := release.Promote(t.Context(), e.store, priv, rt.Account, rt.App, rt.Channel, ver); err != nil {
+ t.Fatal(err)
+ }
+ }
+ source := &proofBindingSource{store: e.store, sys: u, optDir: u.opt, openFile: os.Open, unitDirs: []string{filepath.Dir(u.props.FragmentPath)}}
+ c.resolve, c.host, c.rollback, c.prepareRollback, c.recovered = source.resolve, source.forHost, source.rollback, source.prepareRollback, source.recovered
+ return e, u, c, source, pub
+}
+
+func TestPlan14SignedRestartWithoutHealthHistory(t *testing.T) {
+ e, u, c, _, _ := signedRecoveryFixture(t)
+ rt, err := e.root.reg.Get(observedHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.Pin = "v1"
+ plan14PutRecord(t, e.store, *rec)
+ err = checkedUnitRestart(u, u.opt, func(string) string { return "5s" }, nil, c)(*rt)
+ if err != nil || len(u.effects) != 1 {
+ t.Fatalf("signed restart depended on health history: %v %v", err, u.effects)
+ }
+}
+
+func TestPlan14CapturedRollbackWithoutHealthHistory(t *testing.T) {
+ e, rt, u, c, _ := plan14AdoptionFixture(t)
+ before, err := c.host(t.Context(), rt.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ disk, err := c.attempts()
+ if err != nil {
+ t.Fatal(err)
+ }
+ a, err := captureRollback(disk, before, c.now(), unitSettleTimeout(func(string) string { return "5s" }), release.VersionPath(u.opt, rt.Account, "v2", rt.App), c.identity)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a.Failed = true
+ if err := writeRollbackState(disk, rollbackJournalKey(rt.Host), a); err != nil {
+ t.Fatal(err)
+ }
+ source := &proofBindingSource{store: e.store, sys: u, optDir: u.opt, openFile: os.Open, unitDirs: []string{filepath.Dir(u.props.FragmentPath)}}
+ c.rollback, c.prepareRollback, c.recovered = source.rollback, source.prepareRollback, source.recovered
+ u.props.ActiveState, u.props.SubState, u.props.MainPID = "failed", "failed", 0
+ if err := rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c)(rt.Host); err != nil {
+ t.Fatal(err)
+ }
+ if len(u.effects) != 2 || u.exe != release.VersionPath(u.opt, rt.Account, "v1", rt.App) {
+ t.Fatalf("rollback blocked: %v", u.effects)
+ }
+}
+
+func TestPlan14SignedRestartRequiresTrustedLiveLink(t *testing.T) {
+ e, u, c, _, _ := signedRecoveryFixture(t)
+ rt, err := e.root.reg.Get(observedHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ c.readLive = func(string) (string, error) { return "", os.ErrPermission }
+ if err := checkedUnitRestart(u, u.opt, func(string) string { return "5s" }, nil, c)(*rt); err == nil || len(u.effects) != 0 {
+ t.Fatalf("untrusted pointer restarted: %v %v", err, u.effects)
+ }
+ if productionRootRestartChecks(u, u.opt).readLive == nil {
+ t.Fatal("production restart has no checked pointer reader")
+ }
+}
diff --git a/internal/host/proofbinding.go b/internal/host/proofbinding.go
index 9eacc2848..69f25650e 100644
--- a/internal/host/proofbinding.go
+++ b/internal/host/proofbinding.go
@@ -26,6 +26,7 @@ type proofBinding struct {
Version string
Digest string
Historical bool
+ Signed bool `json:",omitempty"`
History *release.HealthyBuild
Invocation string
PID int
@@ -185,11 +186,12 @@ func (s *proofBindingSource) binding(ctx context.Context, host string, rollback
return proofBinding{}, err
}
rt, source, etag, files := unit.Route, unit.Source, unit.RecordETag, unit.Files
+ running := rollback == nil || (len(prepare) == 2 && prepare[1])
+ if rollback != nil && rollback.Source != source {
+ return proofBinding{}, os.ErrPermission
+ }
var exe string
- if rollback != nil {
- if rollback.Source != source {
- return proofBinding{}, os.ErrPermission
- }
+ if !running {
exe, err = filepath.EvalSymlinks(release.LiveLink(s.optDir, host))
if len(prepare) == 1 && prepare[0] {
exe, err = release.VersionPath(s.optDir, rec.Account, rollback.Version, rec.App), nil
@@ -222,14 +224,24 @@ func (s *proofBindingSource) binding(ctx context.Context, host string, rollback
}
digest := hex.EncodeToString(hash.Sum(nil))
var history *release.HealthyBuild
- if s.healthy != nil {
+ if rollback == nil && s.healthy != nil {
history = s.healthy(rec.Account, rec.App, version, digest)
}
historical := history != nil
- if rollback != nil && (!historical || !rollback.Historical || rollback.Version != version || rollback.Digest != digest) {
- return proofBinding{}, os.ErrPermission
+ signed := false
+ if rollback != nil {
+ if !rollback.authorizedRecovery() || rollback.Version != version || rollback.Digest != digest {
+ return proofBinding{}, os.ErrPermission
+ }
+ historical, history, signed = rollback.Historical, rollback.History, rollback.Signed
+ } else if !historical {
+ pub, keyErr := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+ if keyErr == nil {
+ m, authErr := release.AuthorizedManifest(ctx, s.store, pub, rec.Account, rec.App, version)
+ signed = authErr == nil && m.Kind == "binary" && m.Artifacts[release.AdoptPlatform()].SHA256 == digest
+ }
}
- if rec.Pin != version && !historical {
+ if rollback == nil && rec.Pin != version && !historical && !signed {
if rec.Pin != "" {
return proofBinding{}, os.ErrPermission
}
@@ -246,7 +258,7 @@ func (s *proofBindingSource) binding(ctx context.Context, host string, rollback
}
}
start := ""
- if rollback == nil {
+ if running {
start, err = s.sys.StartTime(p.MainPID)
if err != nil || start == "" {
return proofBinding{}, os.ErrPermission
@@ -260,7 +272,11 @@ func (s *proofBindingSource) binding(ctx context.Context, host string, rollback
return proofBinding{}, os.ErrPermission
}
}
- return proofBinding{CandidateUnit: rec.Unit, History: history, Invocation: p.InvocationID, PID: p.MainPID, Start: start, Route: rt, Source: source, Network: true, Version: version, Digest: digest, Historical: historical, RecordKey: provision.RecordKey(rec.Account, rec.App, host), RecordETag: etag, Files: files}, nil
+ return proofBinding{CandidateUnit: rec.Unit, Signed: signed, History: history, Invocation: p.InvocationID, PID: p.MainPID, Start: start, Route: rt, Source: source, Network: true, Version: version, Digest: digest, Historical: historical, RecordKey: provision.RecordKey(rec.Account, rec.App, host), RecordETag: etag, Files: files}, nil
+}
+
+func (b proofBinding) authorizedRecovery() bool {
+ return b.Signed || (b.Historical && b.History != nil && b.History.Version == b.Version && b.History.SHA256 == b.Digest)
}
func (s *proofBindingSource) forHost(ctx context.Context, host string) (proofBinding, error) {
@@ -273,3 +289,7 @@ func (s *proofBindingSource) rollback(ctx context.Context, b proofBinding) (proo
func (s *proofBindingSource) prepareRollback(ctx context.Context, b proofBinding) (proofBinding, error) {
return s.binding(ctx, b.Route.Host, &b, true)
}
+
+func (s *proofBindingSource) recovered(ctx context.Context, b proofBinding) (proofBinding, error) {
+ return s.binding(ctx, b.Route.Host, &b, false, true)
+}
diff --git a/internal/host/recoveryrefusals.go b/internal/host/recoveryrefusals.go
new file mode 100644
index 000000000..18f5511bb
--- /dev/null
+++ b/internal/host/recoveryrefusals.go
@@ -0,0 +1,74 @@
+package host
+
+import (
+ "encoding/json"
+ "os"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func recoveryResultKey(a rollbackAttempt) string {
+ return rollbackHostKey(a.Host) + "." + a.ID + ".recovery"
+}
+
+func (c rootRestartChecks) mintRefusals(base func(release.HistoryBinding) ([]release.Refusal, error), binding release.HistoryBinding) ([]release.Refusal, error) {
+ if c.attempts == nil || base == nil {
+ return nil, os.ErrPermission
+ }
+ refs, err := base(binding)
+ if err != nil {
+ return nil, err
+ }
+ s, err := c.attempts()
+ if err != nil {
+ return nil, err
+ }
+ defer s.Close()
+ names, err := s.Names()
+ if err != nil {
+ return nil, err
+ }
+ seen := map[string]bool{}
+ for _, name := range names {
+ if !strings.HasSuffix(name, ".attempt") && !strings.HasSuffix(name, ".recovery") {
+ continue
+ }
+ raw, err := s.Read(name)
+ if err != nil {
+ return nil, err
+ }
+ var a rollbackAttempt
+ if json.Unmarshal(raw, &a) != nil || (name != rollbackJournalKey(a.Host) && name != recoveryResultKey(a)) {
+ return nil, os.ErrPermission
+ }
+ if !a.Independent {
+ continue
+ }
+ if a.History == nil || a.HistoryBinding.Host != a.Host || a.History.ID != a.ID || a.History.SHA256 == "" {
+ return nil, os.ErrPermission
+ }
+ if a.HistoryBinding.Account != binding.Account || a.HistoryBinding.App != binding.App {
+ continue
+ }
+ if !a.Settled {
+ return nil, os.ErrPermission
+ }
+ if err := s.Write(name, raw); err != nil {
+ return nil, err
+ }
+ if seen[a.ID] {
+ continue
+ }
+ seen[a.ID] = true
+ if a.Failed || a.Recovering {
+ refs = append(refs, release.Refusal{Schema: 1, Host: a.Host, App: a.HistoryBinding.App, Version: a.History.Version, SHA256: a.History.SHA256, Platform: a.History.Platform, PointerPromotedAt: a.History.PromotedAt, RefusedAt: a.Created, Kind: release.RefusalNotUp})
+ } else if !a.Verified {
+ return nil, os.ErrPermission
+ }
+ }
+ if len(refs) > 256 {
+ return nil, os.ErrPermission
+ }
+ return refs, nil
+}
diff --git a/internal/host/roothistory_recovery.go b/internal/host/roothistory_recovery.go
index ddfb6b620..18a45fa69 100644
--- a/internal/host/roothistory_recovery.go
+++ b/internal/host/roothistory_recovery.go
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"os"
- "path/filepath"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/release"
@@ -12,8 +11,9 @@ import (
)
type historyRollbackLink struct {
- Binding release.HistoryBinding
- Attempt release.HistoryAttempt
+ Binding release.HistoryBinding
+ Attempt release.HistoryAttempt
+ Independent bool
}
func linkRootHistory(h *release.RootHistoryPass, checks rootRestartChecks, optDir string, getenv func(string) string) {
@@ -39,11 +39,11 @@ func linkRootHistory(h *release.RootHistoryPass, checks rootRestartChecks, optDi
return err
}
defer unlock()
- live, err := filepath.EvalSymlinks(release.LiveLink(optDir, binding.Host))
+ live, err := checks.live(optDir, binding.Host)
if err != nil || live != release.VersionPath(optDir, binding.Account, b.Version, binding.App) {
return errors.Join(err, os.ErrPermission)
}
- _, err = captureRollback(s, b, checks.now(), unitSettleTimeout(getenv), release.VersionPath(optDir, binding.Account, attempt.Version, binding.App), identity, historyRollbackLink{binding, attempt})
+ _, err = captureRollback(s, b, checks.now(), unitSettleTimeout(getenv), release.VersionPath(optDir, binding.Account, attempt.Version, binding.App), identity, historyRollbackLink{Binding: binding, Attempt: attempt})
return err
}
}
@@ -67,7 +67,7 @@ func historyRestartAttempt(s rootstate.Store, b proofBinding, c rootRestartCheck
}
func (c rootRestartChecks) settleHistory(ctx context.Context, a rollbackAttempt, rollback bool) error {
- if a.History == nil {
+ if a.History == nil || a.Independent {
return nil
}
if c.history == nil {
@@ -147,13 +147,16 @@ func resumeHistoryRestart(ctx context.Context, sys unitSystem, optDir string, ge
if p.ActiveState == "inactive" {
return errUnitStopped
}
- live, err := filepath.EvalSymlinks(release.LiveLink(optDir, a.Host))
+ live, err := checks.live(optDir, a.Host)
previous := release.VersionPath(optDir, old.Route.Account, old.Version, old.Route.App)
if err != nil || (live != previous && live != a.Target) {
return os.ErrPermission
}
if p.ActiveState == "active" && p.MainPID > 0 {
observed, err := checks.host(ctx, a.Host)
+ if err != nil && checks.recovered != nil {
+ observed, err = checks.recovered(ctx, old)
+ }
if err != nil {
return err
}
diff --git a/internal/host/rootrestart.go b/internal/host/rootrestart.go
index 1804f105e..5debf361e 100644
--- a/internal/host/rootrestart.go
+++ b/internal/host/rootrestart.go
@@ -16,12 +16,14 @@ import (
type rootRestartChecks struct {
history func() (*release.RootHistoryPass, error)
+ readLive func(string) (string, error)
dispatch func(context.Context, registry.Route) (bool, error)
resolve proofBindingResolver
started *sync.Map
attempts func() (rootstate.Store, error)
rollback func(context.Context, proofBinding) (proofBinding, error)
prepareRollback func(context.Context, proofBinding) (proofBinding, error)
+ recovered func(context.Context, proofBinding) (proofBinding, error)
identity hostwrite.Identity
effect func(func() error) error
verifyOnly bool
@@ -36,7 +38,14 @@ func productionRootRestartChecks(sys unitSystem, optDir string) rootRestartCheck
s, err := openDeployStore(ctx)
return productionProofBindingSource(s, sys, optDir), err
}
- return rootRestartChecks{started: &sync.Map{},
+ return rootRestartChecks{started: &sync.Map{}, readLive: rootstate.ReadTrustedLink,
+ recovered: func(ctx context.Context, b proofBinding) (proofBinding, error) {
+ s, err := source(ctx)
+ if err != nil {
+ return proofBinding{}, err
+ }
+ return s.recovered(ctx, b)
+ },
history: func() (*release.RootHistoryPass, error) {
s, err := openDeployStore(context.Background())
if err != nil {
@@ -118,7 +127,7 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin
if before.ActiveState != "active" || before.MainPID <= 0 || before.MainPID != binding.PID || before.InvocationID != binding.Invocation {
return os.ErrPermission
}
- target, err := filepath.EvalSymlinks(release.LiveLink(optDir, candidate.Host))
+ target, err := c.live(optDir, candidate.Host)
if err != nil {
return err
}
@@ -164,7 +173,7 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
if before.ActiveState == "inactive" && before.SubState == "dead" {
return errUnitStopped
}
- want, err := filepath.EvalSymlinks(release.LiveLink(optDir, rt.Host))
+ want, err := c.live(optDir, rt.Host)
if err != nil {
return err
}
@@ -207,8 +216,14 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
return err
}
var observed proofBinding
+ observe := func() (proofBinding, error) {
+ if rollback && c.recovered != nil {
+ return c.recovered(context.Background(), binding)
+ }
+ return c.host(context.Background(), rt.Host)
+ }
checkedReady := func() error {
- first, err := c.host(context.Background(), rt.Host)
+ first, err := observe()
if err != nil || first.Source != binding.Source || want != release.VersionPath(optDir, first.Route.Account, first.Version, first.Route.App) {
return os.ErrPermission
}
@@ -218,7 +233,7 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
if err := ready(); err != nil {
return err
}
- last, err := c.host(context.Background(), rt.Host)
+ last, err := observe()
if err != nil || last.Source != first.Source || last.Version != first.Version || last.Digest != first.Digest || last.Invocation != first.Invocation || last.PID != first.PID || last.Start != first.Start {
return os.ErrPermission
}
@@ -232,7 +247,7 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
if err != nil {
return err
}
- current, err := c.host(context.Background(), rt.Host)
+ current, err := observe()
if err != nil {
return err
}
@@ -247,6 +262,13 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
return nil
}
+func (c rootRestartChecks) live(optDir, host string) (string, error) {
+ if c.readLive != nil {
+ return c.readLive(release.LiveLink(optDir, host))
+ }
+ return filepath.EvalSymlinks(release.LiveLink(optDir, host))
+}
+
func rollbackUnitRestart(_ *registry.Registry, sys unitSystem, optDir string, getenv func(string) string, checks ...rootRestartChecks) func(string) error {
c := productionRootRestartChecks(sys, optDir)
if len(checks) == 1 {
diff --git a/internal/host/rootrollback.go b/internal/host/rootrollback.go
index 0a43a3ada..637ac56a5 100644
--- a/internal/host/rootrollback.go
+++ b/internal/host/rootrollback.go
@@ -28,6 +28,7 @@ type rollbackAttempt struct {
Recovering bool
Settled bool
Verified bool `json:",omitempty"`
+ Independent bool `json:",omitempty"`
Target string
Previous string `json:",omitempty"`
PreviousDigest string `json:",omitempty"`
@@ -73,6 +74,11 @@ func readRollbackAttempt(s rootstate.Store, host string) (rollbackAttempt, error
func settleRollbackAttempt(s rootstate.Store, a rollbackAttempt) error {
a.Settled = true
+ if a.Independent {
+ if err := writeRollbackState(s, recoveryResultKey(a), a); err != nil {
+ return err
+ }
+ }
if err := writeRollbackState(s, rollbackJournalKey(a.Host), a); err != nil {
return err
}
@@ -80,7 +86,7 @@ func settleRollbackAttempt(s rootstate.Store, a rollbackAttempt) error {
}
func captureRollback(s rootstate.Store, b proofBinding, now time.Time, timeout time.Duration, target string, identity hostwrite.Identity, link ...historyRollbackLink) (rollbackAttempt, error) {
- if b.Source == "" || !b.Historical || b.Version == "" || b.Digest == "" || b.History == nil || b.History.Version != b.Version || b.History.SHA256 != b.Digest {
+ if b.Source == "" || b.Version == "" || b.Digest == "" || !b.authorizedRecovery() {
return rollbackAttempt{}, os.ErrPermission
}
if prior, err := readRollbackAttempt(s, b.Route.Host); err == nil {
@@ -106,7 +112,11 @@ func captureRollback(s rootstate.Store, b proofBinding, now time.Time, timeout t
if l.Binding.Host != b.Route.Host || l.Binding.Account != b.Route.Account || l.Binding.App != b.Route.App || l.Attempt.Source != b.Source {
return rollbackAttempt{}, os.ErrPermission
}
+ if l.Independent {
+ l.Attempt.ID = a.ID
+ }
a.History, a.HistoryBinding = &l.Attempt, l.Binding
+ a.Independent = l.Independent
}
if err := writeRollbackState(s, rollbackCapabilityKey(a.Host, a.ID), rollbackCapability{Attempt: a, Binding: b}); err != nil {
return rollbackAttempt{}, err
@@ -130,7 +140,7 @@ func loadRollback(s rootstate.Store, host string, now time.Time) (rollbackAttemp
return a, proofBinding{}, err
}
var cap rollbackCapability
- if json.Unmarshal(b, &cap) != nil || cap.Attempt.ID != a.ID || cap.Attempt.HistoryBinding != a.HistoryBinding || !sameHistoryAttempt(cap.Attempt.History, a.History) || cap.Attempt.Previous != a.Previous || cap.Attempt.PreviousDigest != a.PreviousDigest || cap.Attempt.Host != host || cap.Attempt.Target != a.Target || cap.Attempt.Identity != a.Identity || !cap.Attempt.Deadline.Equal(a.Deadline) || !cap.Attempt.Created.Equal(a.Created) || cap.Binding.Route.Host != host {
+ if json.Unmarshal(b, &cap) != nil || cap.Attempt.ID != a.ID || cap.Attempt.Independent != a.Independent || cap.Attempt.HistoryBinding != a.HistoryBinding || !sameHistoryAttempt(cap.Attempt.History, a.History) || cap.Attempt.Previous != a.Previous || cap.Attempt.PreviousDigest != a.PreviousDigest || cap.Attempt.Host != host || cap.Attempt.Target != a.Target || cap.Attempt.Identity != a.Identity || !cap.Attempt.Deadline.Equal(a.Deadline) || !cap.Attempt.Created.Equal(a.Created) || cap.Binding.Route.Host != host {
return a, proofBinding{}, os.ErrPermission
}
return a, cap.Binding, nil
diff --git a/internal/host/rootrollback_resume.go b/internal/host/rootrollback_resume.go
index 66145309b..5b6709cc6 100644
--- a/internal/host/rootrollback_resume.go
+++ b/internal/host/rootrollback_resume.go
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"os"
- "path/filepath"
"strings"
"github.com/carlosframework/platform/internal/hostwrite"
@@ -86,7 +85,7 @@ func resumeRootRestart(ctx context.Context, sys unitSystem, optDir string, geten
if p.ActiveState == "inactive" {
return errUnitStopped
}
- live, err := filepath.EvalSymlinks(release.LiveLink(optDir, a.Host))
+ live, err := checks.live(optDir, a.Host)
old := release.VersionPath(optDir, b.Route.Account, b.Version, b.Route.App)
if err != nil || (live != a.Target && live != old) {
return os.ErrPermission
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index e79283e54..c8c665b04 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -487,8 +487,8 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/hostbox/journalservice.go", "type JournalRequest", "key-site", "key:RecordKey", "14baf4c57a342a8b2dadaa95c965de4650ee352183de3a8c56768f47642ff5ab"},
{"internal/hostbox/journalservice.go", "validateJournalRequest", "key-site", "key:RecordKey", "724be9634772f00bf0d9da2c39c8049763c9aad6f3075c10f31c97ab69c15103"},
{"internal/host/proofbinding.go", "proofBindingSource.unitBinding", "key-site", "key:RecordKey", "52bdb3ebb7306622b8dede117f07f880f7ceef531ed820a78b0382bc3097f49c"},
- {"internal/host/proofbinding.go", "proofBindingSource.binding", "key-site", "key:RecordKey", "7025b41ae022b35bbdd3295e295d176a9eb07fa19d06b27d04a2638be47afc0c"},
- {"internal/host/proofbinding.go", "type proofBinding", "key-site", "key:RecordKey", "b5455029fb4bb12ca54c0c1f2594fb1633a7717e7796a8ba5ec37794954f8e26"},
+ {"internal/host/proofbinding.go", "proofBindingSource.binding", "key-site", "key:RecordKey", "d24db51185f1ebbee6dad69c3447e2ea4e30b15701ff312d6e3056c8858ffb29"},
+ {"internal/host/proofbinding.go", "type proofBinding", "key-site", "key:RecordKey", "48916c44acd08d29f4d93b267ccde50fa2289d437992e02b363620cb776c8f6a"},
{"internal/host/rootroute.go", "proofBindingSource.claimIdentity", "key-site", "key:RecordKey", "ad6e0fc3f2cd360473a5d0a2267d2dc7944b45d80947613f791db8d573f8fee6"},
{"internal/provision/rootrecord.go", "FindRecordByHost", "key-site", "key:ParseRecordKey,lit:instances", "10c8254cf896be034334d634151d169760a909ac1ba4c1fc334785e86e4b84d4"},
// Read-only discovery of signed loss records; no record write or lifecycle authority.
diff --git a/internal/provision/testdata/plan14-root-reads-merge.tsv b/internal/provision/testdata/plan14-root-reads-merge.tsv
index a12070096..03a1e4af2 100644
--- a/internal/provision/testdata/plan14-root-reads-merge.tsv
+++ b/internal/provision/testdata/plan14-root-reads-merge.tsv
@@ -251,7 +251,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 2ac9bb658f01f52d0f7cd9390543567ed45a215048000c02ab3b30ee9ef8031e
+@coverage internal/host 07947d4d11bcd74430058add44fff2e48a583972b6bdf6e50c22ca1f34654eb4
@coverage internal/hostbox afb08c287eaccb43adde1c488bd122482711ce4e29ecb675c9428f27130c3d93
@coverage internal/hostclaim feaf701db4b49ce92733b9d6c1fa1c0ae8efc6e13439ce201fca1a9e24dcf927
@coverage internal/hostfiles 554285c123758375c13388b2fa42f654819392dac0b027816a713548ec17dcc6
@@ -264,7 +264,7 @@ internal/sim/hostlife/mutate.sh external legacy accepted-legacy accepted-legacy;
@coverage internal/moves 5218cf9bd31f6347ae49a595a32455f441b1718ecfa0a9559f99036d32f56498
@coverage internal/provision a5c7116ee1a063ecdbe8bd5ea1832370ce66960ab13cc7afd34c0973dd21b457
@coverage internal/registry bb6028d50884545209c11e65131dc3389a0991c9a94a0954965e0df4b82b3232
-@coverage internal/release 3612c05f11fe94f2f759118cf7ee40067fc38f6a6f3bce71edaf442f0a5e4bc1
+@coverage internal/release f2e7d72eaea033392b530395b49aee16b4041da0dbcebd392a3d4705d8f37d71
@coverage internal/sim/hostlife eaac101ea7970c74fee621e5cd7515ec7e4907bc5874c65eab86666af0f7dc61
@coverage internal/status d5ef42fe4dc2cb72d1ce5e133e2c95a3e85960a222eb424f00b259b961b8d976
@coverage internal/updatewords 93bb83474bad5d20281c34e72fadf56e2847e9a7eee2385a78f46a11fc39a3b6
@@ -285,14 +285,14 @@ internal/rootparticipation/ledger.go Ledger.idle authority D D3 enumerates root
internal/hostexec/rootclient.go requestIdentity authority D D1 supplies collision-resistant request correlation only; root authenticates peer and authority independently. Production client integration pending. file:Read@73 2acec351fdc4ab671e65b4a9bb5017aeb094fbf78fad8e34d713e48a2846c158
internal/hostexec/rootserving.go RootBroker.servingView authority D D1 reads only completed root census and settled root participation under transaction exclusion, rechecks claim authority and peer identity, and returns no lease or token. Production client selection is pending. file:Read@441 9a57720834b3d7c661854196cf0ed1261c643673b5ba900c38987a162f5de0bb
internal/release/roothistory.go RootHistory.read authority E1 Root per-binding refusal/program completeness and pending attempt state; strict decode and exact durable recommit before authority reads. E1 production integration is selected but incomplete. file:Read@251 51c0dfb65407409d6d3cb85d3e5f50051e0f104e1e72efb2b1225c0202d979f7
-internal/host/agent.go runAdoptUnderRootRelease authority C,J1,E1 Root release pass attempts independently authorized stopped-unit pointer recovery before opening release history. Recovery bookkeeping failures are reported without preventing this repair. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. Active forward migration remains incomplete. registry:Host@9965,whole:Route@10725,whole:Route@17670,whole:Route@17706,whole:Route@7846,whole:Route@9919 071cbe1f533980a31257ae33689140d29f96da3a986f27a75c4693f7bd2197dc
+internal/host/agent.go runAdoptUnderRootRelease authority B3,E1 Root retains independent recovery results and includes exact failed target digests in proof mint refusals. Pending, malformed or unsynced evidence refuses minting; recovery remains independent. Fixtures: TestPlan14IndependentRecoveryRetainsMintBarrier, TestPlan14RecoveryMintEvidence, TestPlan14SignedActiveFixIgnoresHistory. registry:Host@14246,whole:Route@10417,whole:Route@17804,whole:Route@17840,whole:Route@7846 33b2f1201fe4f0f03ec0e901a537237f5d4d9dad192b1c71ac6b499ddfd3d6b5
internal/host/agent.go runAdoptWithEnsureSystemUnits authority B3,C,D,E1,E2,E3,E4,F,J1 E1 selects the checked private history, canonical named-unit provider and root mint inventory under release exclusion with claims off or on; reconstruction and restart consumption remain incomplete. file:Open@191 3dbb187bf1456f5343bbaa540d0f5886aa073d3319d0538e0827488e2b202120
internal/host/proofroot.go proofRoot.mint authority B3,J1,J4,E1 E1 production uses canonical observed programs and complete root refusal inventory; legacy diagnostics do not grant root mint authority. registry:Account@136,registry:App@1338,registry:App@1960,registry:App@2075,registry:App@2279,registry:App@316,registry:App@934,registry:Host@1135,registry:Host@1259,registry:Host@1599,registry:Host@2066,registry:Host@2189,registry:Host@287,registry:Host@469,registry:Host@622,whole:Route@129,whole:Route@144 8c4e2457e74ac917a5be303d74493247190e344585da622e989048fd0e00e61a
internal/host/proofroot.go proofRoot.observeAndRecord authority B3,J1,J4,E1 E1 production uses canonical observed programs and complete root refusal inventory; legacy diagnostics do not grant root mint authority. file:Readlink@108,registry:Account@443,registry:App@468,registry:Host@151,registry:Host@657,whole:Route@186,whole:Route@453,whole:Route@478 88065f1cfe7ad31178ba65c2672d8dd1e25fab26820290368fc063907f7b3b98
internal/host/roothistory.go boundRootHistory authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. file:Open@1061,file:Read@1379,registry:Account@613,registry:App@635,registry:Channel@456,registry:Channel@470,registry:Host@590,whole:Route@1469,whole:Route@377,whole:Route@472,whole:Route@592,whole:Route@615,whole:Route@637,whole:Route@846 fe46269101116374044658bbb87c1bd44317525dcadf227d4515b47845238be2
internal/host/roothistory.go openRootHistory authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. file:Open@104,registry:Account@800,registry:App@837,whole:Route@804,whole:Route@841 77b7ee45bd93e0e5d0dfd4ea444cb9df293461875ed0a42f8c177b84c65ed317
internal/host/roothistory.go recordRootProgram authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. whole:Route@151,whole:Route@297,whole:Route@398 fbbc9451c39309090580486d75da6120b02e1ba054157159846bea057ae0c80f
-internal/release/adopt.go PassFilter authority B3,E3,J1,E1 E1 root history and bake selection, retained denies, signed program consent, durable intent before effects and independently observed settlement; B3 crash linkage remains incomplete. registry:App@24918,registry:App@27375,registry:App@27867,registry:App@28104,registry:App@29511,registry:App@4713,registry:App@7738,registry:App@7879,registry:Hibernate@27750,registry:Host@1484,registry:Host@1551,registry:Host@1647,registry:Host@1682,registry:Host@27920,registry:Host@28836,registry:Host@29564,registry:UpdateSettings@7616,whole:Route@1098,whole:Route@1122,whole:Route@1153,whole:Route@1210,whole:Route@1296,whole:Route@1318,whole:Route@1418,whole:Route@2403,whole:Route@24913,whole:Route@27370,whole:Route@27628,whole:Route@27650,whole:Route@27697,whole:Route@27715,whole:Route@27725,whole:Route@27768,whole:Route@27789,whole:Route@27802,whole:Route@27862,whole:Route@27888,whole:Route@27962,whole:Route@28001,whole:Route@28017,whole:Route@28045,whole:Route@28099,whole:Route@28125,whole:Route@28878,whole:Route@28932,whole:Route@28948,whole:Route@29455,whole:Route@29506,whole:Route@29532,whole:Route@29606,whole:Route@29641,whole:Route@29657,whole:Route@3050,whole:Route@4635,whole:Route@4657,whole:Route@4708,whole:Route@4734,whole:Route@7567,whole:Route@7681,whole:Route@7726,whole:Route@7874 3adae93bfe6c7c12d3c8a78c3a6af528d7f5e3b538ab376b3653ec60f5871e02
+internal/release/adopt.go PassFilter authority B3,E3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:App@25750,registry:App@28207,registry:App@28699,registry:App@28936,registry:App@30343,registry:App@5545,registry:App@8570,registry:App@8711,registry:Hibernate@28582,registry:Host@2316,registry:Host@2383,registry:Host@2479,registry:Host@2514,registry:Host@28752,registry:Host@29668,registry:Host@30396,registry:Host@690,registry:Host@985,registry:UpdateSettings@8448,whole:Route@1020,whole:Route@1930,whole:Route@1954,whole:Route@1985,whole:Route@2042,whole:Route@2128,whole:Route@2150,whole:Route@2250,whole:Route@25745,whole:Route@28202,whole:Route@28460,whole:Route@28482,whole:Route@28529,whole:Route@28547,whole:Route@28557,whole:Route@28600,whole:Route@28621,whole:Route@28634,whole:Route@28694,whole:Route@28720,whole:Route@28794,whole:Route@28833,whole:Route@28849,whole:Route@28877,whole:Route@28931,whole:Route@28957,whole:Route@29710,whole:Route@29764,whole:Route@29780,whole:Route@30287,whole:Route@30338,whole:Route@30364,whole:Route@30438,whole:Route@30473,whole:Route@30489,whole:Route@3235,whole:Route@3882,whole:Route@5467,whole:Route@5489,whole:Route@5540,whole:Route@5566,whole:Route@579,whole:Route@617,whole:Route@675,whole:Route@793,whole:Route@817,whole:Route@8399,whole:Route@8513,whole:Route@8558,whole:Route@8706 910b2b15bb9b87757d6745c45dd77a8f83b2b78f450015e1df61a1c5c043f43c
internal/release/adopt.go adoptHost authority B3,E1,E2,E3,E4,J1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. registry:App@10601,registry:App@10761,registry:App@11790,registry:App@12514,registry:App@14076,registry:App@19077,registry:App@24341,registry:App@3114,registry:App@31948,registry:App@6423,registry:App@7724,registry:App@7918,registry:Hibernate@32809,registry:Host@10481,registry:Host@10514,registry:Host@10614,registry:Host@10774,registry:Host@10876,registry:Host@10908,registry:Host@10973,registry:Host@11423,registry:Host@11556,registry:Host@11986,registry:Host@12112,registry:Host@12604,registry:Host@13380,registry:Host@13848,registry:Host@14062,registry:Host@14404,registry:Host@14994,registry:Host@18725,registry:Host@18833,registry:Host@19045,registry:Host@19769,registry:Host@19927,registry:Host@20389,registry:Host@20441,registry:Host@20588,registry:Host@20675,registry:Host@2075,registry:Host@21781,registry:Host@22025,registry:Host@2264,registry:Host@22685,registry:Host@23070,registry:Host@23211,registry:Host@2345,registry:Host@24309,registry:Host@24436,registry:Host@25778,registry:Host@26428,registry:Host@29661,registry:Host@30279,registry:Host@30604,registry:Host@30678,registry:Host@30931,registry:Host@3127,registry:Host@32120,registry:Host@32287,registry:Host@32394,registry:Host@32622,registry:Host@32765,registry:Host@33510,registry:Host@33636,registry:Host@33724,registry:Host@4248,registry:Host@5211,registry:Host@5316,registry:Host@6535,registry:Host@6583,registry:Host@6700,registry:Host@6732,registry:Host@7610,registry:Host@7778,registry:Host@7904,registry:Host@8023,registry:Host@8501,registry:Host@8888,registry:Host@9114,registry:Host@9210,registry:Host@9301,registry:Kind@552,registry:Unit@577,whole:Route@10596,whole:Route@10756,whole:Route@11780,whole:Route@12504,whole:Route@14044,whole:Route@14609,whole:Route@14970,whole:Route@16193,whole:Route@16496,whole:Route@16652,whole:Route@18982,whole:Route@19067,whole:Route@21510,whole:Route@2176,whole:Route@21909,whole:Route@24331,whole:Route@29232,whole:Route@3109,whole:Route@32096,whole:Route@3554,whole:Route@6418,whole:Route@9738 6767d178b99cc164cb17b78e707127ef47942717d219da593b03ce186da4dfe1
internal/release/rollback.go writeGroupRefusals authority E1 E1 group refusal writer persists root denial before its diagnostic projection; pending attempt remains restrictive. registry:App@801,whole:Route@783,whole:Route@882,whole:Route@888,whole:Route@952 301386c9d57f9f13db3c31a6cf3a26a44c7ca53ff8d98d910c10a7e8530b8795
internal/release/rootbake.go RootHistory.LoadBake authority E1 E1 strict root bake and app completeness revisions; backward clocks refuse and visible records are durably recommitted. file:Read@261 bd188ea6f2e76d277e1274043bf0f2a447391b1dc540aa400b36ad2573ccd84e
@@ -306,25 +306,32 @@ internal/release/roothistory_pass.go passConfig.bindProof authority E1 E1 canoni
internal/release/roothistory_pass.go passConfig.recheckHistory authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. registry:Host@184,whole:Route@162 2a7c830d3889757864bf69ddc17a9ee1cff2151dd27c90e69c9b6e4f85854332
internal/release/roothistory_pass.go passConfig.recordRefusal authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. registry:Host@151,whole:Route@241 298ca88f7dc02be7779aaa527990b4e4548c1fe5498fba0272a6e7041b236354
internal/host/roothistory_recovery.go historyRestartAttempt authority B3,E1 Captured root capability and exact process/source identity authorize restart independently of release history availability. Fixture: TestPlan14HistoryUnavailableAfterCaptureCannotBlockRestart. registry:Host@187,registry:Host@361,whole:Route@189,whole:Route@363 fa50ee745d945fe107440590bee94d804fd20b5298c15bc0a18540a3c58467be
-internal/host/roothistory_recovery.go linkRootHistory authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:EvalSymlinks@845,file:Prepare@121,registry:Account@374,registry:App@412,whole:Route@376,whole:Route@414 b4c36059dcd6aca69f585265ff22cd7439480319fa4b37d8c7979a35cd444b24
-internal/host/roothistory_recovery.go resumeHistoryRestart authority B3,E1 Captured rollback capability authorizes recovery without a release-history read. Verified settlement precedes history bookkeeping; replay never repeats effects. Fixtures: TestPlan14HistoryUnavailableCannotBlockCapturedRollback and TestPlan14HistoryRecoveryProcessCrash. file:EvalSymlinks@955,registry:Account@1053,registry:Account@2124,registry:App@1085,registry:App@2156,registry:Unit@824,whole:Route@1057,whole:Route@1089,whole:Route@2128,whole:Route@2160,whole:Route@828 3e0c8a9692133c83718c207931cb5620af5107e782fbc8e0c54519610585cbad
-internal/host/roothistory_recovery.go rootRestartChecks.settleHistory authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. registry:Account@563,registry:App@1334,registry:App@610,whole:Route@1336,whole:Route@565,whole:Route@612,whole:Route@828,whole:Route@974 a43e6c47441fa671759bde8a235917c1335efe9b1563b935587ccaed48a9eb76
-internal/host/rootrestart.go checkedUnitRestart authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:EvalSymlinks@1023,registry:Host@1070,registry:Host@1273,registry:Host@1809,registry:Host@510,registry:Unit@1691,registry:Unit@711,whole:Route@1699,whole:Route@638,whole:Route@719 46858f40c42310417a10e1fe333a299173ce576dd8a48a12dcbdc87a424d4f4b
-internal/host/rootrestart.go productionRootRestartChecks authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Open@722,whole:Route@1354,whole:Route@1621,whole:Route@660 42884d03f345975518948b4947473dd268a7ef344b0c3d054ccf578b55a92e18
+internal/host/roothistory_recovery.go linkRootHistory authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:Prepare@121,registry:Account@374,registry:App@412,whole:Route@376,whole:Route@414 a60324796d2b25b3b130bb0439bbd9a415b679e6b51bc92a44f23f77e2d93e1e
+internal/host/roothistory_recovery.go resumeHistoryRestart authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:Account@1025,registry:Account@2194,registry:App@1057,registry:App@2226,registry:Unit@824,whole:Route@1029,whole:Route@1061,whole:Route@2198,whole:Route@2230,whole:Route@828 7e11454ad9a1adf74bf437a54626674c285aa5e3f3eb3f37a0301daa2410ba71
+internal/host/roothistory_recovery.go rootRestartChecks.settleHistory authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:Account@580,registry:App@1351,registry:App@627,whole:Route@1353,whole:Route@582,whole:Route@629,whole:Route@845,whole:Route@991 ea977169cb738ba5e3e9bb83f86fbe0fca6e66ec6fcd9416dae94e8f39bc47fc
+internal/host/rootrestart.go checkedUnitRestart authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:Host@1038,registry:Host@1240,registry:Host@1776,registry:Host@510,registry:Unit@1658,registry:Unit@711,whole:Route@1666,whole:Route@638,whole:Route@719 d9b0f7437141a37976f44356b3d3448a1110427a012d4b76ed829a24e904fdf9
+internal/host/rootrestart.go productionRootRestartChecks authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:Open@953,whole:Route@1585,whole:Route@1852,whole:Route@891 5b7c8629fb63c12ca451e577e5ce1b20caccfe08d3ec9b6ec80e6f24c0bd3e78
internal/host/rootrestart.go rollbackUnitRestart authority B3,J1,E1 Captured canonical rollback authority is unchanged. Durable verified outcome permits later history settlement without another restart. Fixtures: TestPlan14HistoryRollbackLink and TestPlan14HistoryUnavailableCannotBlockCapturedRollback. registry:Unit@904,whole:Route@912 f87a4c8c0760cb660248de25ed7bd043a9f09b033ead364cb9b44fc0f90ec95e
-internal/host/rootrollback.go captureRollback authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Read@807,registry:Account@1233,registry:App@1269,registry:Host@1196,registry:Host@444,registry:Host@926,whole:Route@1198,whole:Route@1235,whole:Route@1271,whole:Route@446,whole:Route@928 bda2805292bba2046ebd04dad3cf9ceafd9d2793b8fa459cb073e4597332639d
-internal/host/rootrollback.go loadRollback authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Read@451,registry:Host@1014,whole:Route@1026 6929d919e83b02b708bb4d45b53617a94502fb85e44f8914b869a0a2b90b7eda
-internal/host/rootrollback_resume.go resumeRootRestart authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:EvalSymlinks@1384,registry:Account@1477,registry:Account@1997,registry:App@1505,registry:App@2025,registry:Unit@1255,whole:Route@1257,whole:Route@1479,whole:Route@1507,whole:Route@1999,whole:Route@2027 d51ee3af37b32715f6ddc4d7e70d9dff0eb5d9a50af15cb3bb331d8a796d24a6
+internal/host/rootrollback.go captureRollback authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:Read@731,registry:Account@1157,registry:App@1193,registry:Host@1120,registry:Host@368,registry:Host@850,whole:Route@1122,whole:Route@1159,whole:Route@1195,whole:Route@370,whole:Route@852 92a4f94e94ea4d55318a48c8de23bbe3d8b745711c9dda65b5d3b0b248bbfc46
+internal/host/rootrollback.go loadRollback authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:Read@451,registry:Host@1058,whole:Route@1070 b9f53dbbe3dff8bc8c56778d670b4597b0f0b85b8bbed48006a273e9116420a5
+internal/host/rootrollback_resume.go resumeRootRestart authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:Account@1449,registry:Account@1969,registry:App@1477,registry:App@1997,registry:Unit@1255,whole:Route@1257,whole:Route@1451,whole:Route@1479,whole:Route@1971,whole:Route@1999 6af653f6cc395d48c8d4d49e054f6a73a9de73987a78ff5130d833bfa49a7f1e
internal/release/roothistory_pass.go RootHistoryPass.CompleteAttempt authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. whole:Route@136,whole:Route@255 97d46675209fa1d5ba064afded9b8b3265b741be9a30dfec6efab01a745d1d82
internal/release/roothistory_pass.go type RootHistoryPass authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Prepare@215 c90889be3f234547b634149feceb82515c03de58598912758152f39604f23274
internal/release/roothistory_reconstruct.go RootHistory.ReconstructionStatus authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@189 f2da2a6c97bc40bf972f1346b578c47334585053d85244c9ba6b63c45f0570a3
internal/release/roothistory_reconstruct.go RootHistory.reconstructionImages authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@1190,file:Read@226,file:Read@2940,file:Read@629 7fcdb81486fea19d7e05482dee69513bfe31294fec61eff9f6ed7db82117d806
internal/release/roothistory_reconstruct.go RootHistory.reconstructionState authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@178 7c53af838e0dc7ec7ad6ae5341da983bde442288126e9eea21f80d6a62fc478a
internal/release/roothistory_reconstruct.go RootHistory.resumeReconstruction authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@915 39d8949054462cf53823e4b6fc802a08349bbac79750f6beb5b90095880925b8
-internal/host/proofbinding.go proofBindingSource.binding authority B3,J1 Canonical unit verification is factored into unitBinding; running executable and historical rollback checks retain their prior authority. Fixtures: TestPlan14ProofBindingProvenance and TestPlan14HistoryRecoveryRefusesChanges. file:EvalSymlinks@444,registry:Unit@2376,whole:Route@265,whole:Route@2813,whole:Route@2820 7025b41ae022b35bbdd3295e295d176a9eb07fa19d06b27d04a2638be47afc0c
+internal/host/proofbinding.go proofBindingSource.binding authority B3,J1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:EvalSymlinks@518,registry:Unit@2948,whole:Route@265,whole:Route@3401,whole:Route@3408 d24db51185f1ebbee6dad69c3447e2ea4e30b15701ff312d6e3056c8858ffb29
internal/host/namedrecovery.go namedUnitRecovery.repair authority E1,J1 Signed channel target, canonical deployment record, checked named unit and root-owned live pointer authorize stopped-unit pointer repair. Rechecks source, claim selection, pointer and artifact at the effect; does not start a process or change grants. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. Uses account/app from the same canonical read as the unit binding. Fixture: TestPlan14SignedNamedRecoveryUsesOneCanonicalRecord. file:Open@2588,registry:Channel@1837,whole:Route@1083,whole:Route@1845 94eed25b00195883795a5e394eecfb1a59fcbed9f4f14c500fb61fd07d72e9a7
internal/host/proofbinding.go proofBindingSource.unitBinding authority B3,J1,E1 Reads canonical deployment record, claim, unit file identities and systemd facts without assuming a running PID. Existing running/rollback binding uses the same verifier. Fixtures: TestPlan14SignedNamedRecoveryAuthority and TestPlan14ProofBindingProvenance. registry:Unit@487,registry:Unit@588,registry:Unit@678,whole:Route@2071,whole:Route@2078 52bdb3ebb7306622b8dede117f07f880f7ceef531ed820a78b0382bc3097f49c
internal/release/signedhead.go StageSignedHead authority E1 Canonical route is supplied by the root unit verifier. Verifies signed channel, digest ratchet and manifest/artifact before staging; unavailable pin census never authorizes restaging differing bytes. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. registry:Account@202,registry:Account@302,registry:Account@403,registry:App@214,registry:App@415,registry:Channel@222,registry:Host@434 ca02f04e55556ba0233dfa0848b0c533a9d9aa06cad6abd33cfd2968b3bd838a
internal/rootstate/link_linux.go ReadTrustedLink authority A,E1 Checks the full root-owned parent chain before descriptor-based live-link reading. Fixture: TestPlan14TrustedLiveLink; privileged acceptance remains required. file:Open@156 90f8e8de4e2c71f07b5d749cbe4bb1c1dfa426576a82138823449f56fc9ec5ab
internal/rootstate/link_linux.go directory.readLink authority A,E1 Pins the symlink inode with O_PATH and checks owner/type/link count before readlinkat. Fixture: TestPlan14TrustedLiveLink. file:Fstat@356,file:Openat@183,file:Readlinkat@554 a8559fe63dce48830916004ecfe7fbbe5d419568c7daf7a3073cf1b5bb12c3d7
@coverage internal/rootstate b3a51f6cba72802362791eb31d6dff2ce988f144538a643502e1ed5a7178e261
+internal/host/namedrecovery_active.go namedUnitRecovery.recover authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:Open@4979,registry:Channel@2235,registry:Version@3224,registry:Version@3330,whole:Route@1155,whole:Route@2237,whole:Route@3150 170e7356248ccecb97ee754829ffc2d32b9b6d008e8d4d2a3c96a16777d02390
+internal/host/proofbinding.go proofBindingSource.recovered authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:Host@123,whole:Route@125 9ddd5ae1e5b981654e47a0f97cba43b93e32617feddf57918136c57c9943f8ae
+internal/host/rootrestart.go restartBoundUnit authority B3,J1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:Account@2241,registry:Account@3332,registry:App@2277,registry:App@3372,registry:Host@1512,registry:Host@2082,registry:Host@483,registry:Host@647,registry:Unit@1067,registry:Unit@1135,registry:Unit@1785,registry:Unit@2817,registry:Unit@3666,registry:Unit@393,whole:Route@198,whole:Route@2247,whole:Route@2283,whole:Route@316,whole:Route@3340,whole:Route@3380,whole:Route@3590 3a93ce468ce2e5ee8df4ccc1c7a058e5bffa7a228f3b1f982cd327956ce4e79e
+internal/host/rootrestart.go rootRestartChecks.live authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:EvalSymlinks@158 28721577488e77d229d3a5cbe6eb5806a4af0696d350a144c5ed2e4d10c36828
+internal/host/recoveryrefusals.go rootRestartChecks.mintRefusals authority B3,E1 Root retains independent recovery results and includes exact failed target digests in proof mint refusals. Pending, malformed or unsynced evidence refuses minting; recovery remains independent. Fixtures: TestPlan14IndependentRecoveryRetainsMintBarrier, TestPlan14RecoveryMintEvidence, TestPlan14SignedActiveFixIgnoresHistory. file:Read@616 1bb27522e17f4c16f7c09390f2a132ce6a1050e73fc807179d1fa9f4ff71278e
+internal/host/rootrollback.go settleRollbackAttempt authority B3,E1 Independent signed recovery authority and retained attempt outcomes. Fixtures: TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14IndependentRecoveryRetainsMintBarrier, TestPlan14ActiveRecoveryProcessCrash. b8161c73aa8f01768788071c7fbb86843e46808f04cdc6b1f096f30a5e5f5601
+internal/host/proofbinding.go proofBinding.authorizedRecovery authority B3,E1 Independent signed recovery authority and retained attempt outcomes. Fixtures: TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14IndependentRecoveryRetainsMintBarrier, TestPlan14ActiveRecoveryProcessCrash. a7a7cc9edf8437ed2a1fd4d4ed3d54e9ffab78b21ed3d2867a2254c7836e4f13
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 31afbea08..e118b1893 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -37,6 +37,7 @@ var adoptPlatform = runtime.GOOS + "-" + runtime.GOARCH
type PassOption func(*passConfig)
type passConfig struct {
+ recovery func(context.Context, registry.Route) (bool, error)
rootHistory *RootHistoryPass
requireRootHistory bool
historySelected map[string]historySelection
@@ -445,6 +446,36 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
}
cfg.pub = pub
cfg.route = reg.Get
+ if cfg.recovery != nil {
+ routes, err := reg.List()
+ if err != nil {
+ return err
+ }
+ handled := map[string]bool{}
+ var failures []error
+ remaining := false
+ for _, rt := range routes {
+ if extra != nil && !extra(rt) {
+ continue
+ }
+ done, err := cfg.recovery(ctx, rt)
+ handled[rt.Host] = done
+ if err != nil {
+ failures = append(failures, err)
+ }
+ if !done && (Adoptable(rt) || SidecarAdoptable(rt)) {
+ remaining = true
+ }
+ }
+ if !remaining {
+ return errors.Join(failures...)
+ }
+ prior := extra
+ extra = func(rt registry.Route) bool { return !handled[rt.Host] && (prior == nil || prior(rt)) }
+ cfg.recovery = nil
+ opts = append(opts, func(c *passConfig) { c.recovery = nil })
+ return errors.Join(errors.Join(failures...), PassFilter(ctx, reg, s, pub, optDir, restart, extra, opts...))
+ }
// Settings spec §4.3: the failure book is read fresh for every pass, so a
// record the agent persisted since the last one counts now.
if cfg.settings && cfg.openFailed != nil {
diff --git a/internal/release/roothistory_pass.go b/internal/release/roothistory_pass.go
index 2e0dfc873..13d781700 100644
--- a/internal/release/roothistory_pass.go
+++ b/internal/release/roothistory_pass.go
@@ -25,6 +25,10 @@ type historySelection struct {
source string
}
+func WithRecovery(fn func(context.Context, registry.Route) (bool, error)) PassOption {
+ return func(c *passConfig) { c.recovery = fn }
+}
+
func WithRootHistory(h *RootHistoryPass) PassOption {
return func(c *passConfig) {
c.rootHistory = h