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>
16 files changed,
+956
−73
internal/host/agent.go+29 −31internal/host/namedrecovery_active.go+204 −0internal/host/plan14_active_recovery_process_test.go+143 −0internal/host/plan14_active_recovery_test.go+189 −0internal/host/plan14_recovery_mint_test.go+84 −0internal/host/plan14_recovery_without_health_test.go+95 −0internal/host/proofbinding.go+30 −10internal/host/recoveryrefusals.go+74 −0internal/host/roothistory_recovery.go+10 −7internal/host/rootrestart.go+28 −6internal/host/rootrollback.go+12 −2internal/host/rootrollback_resume.go+1 −2internal/provision/plan9_b8_census_test.go+2 −2internal/provision/testdata/plan14-root-reads-merge.tsv+20 −13internal/release/adopt.go+31 −0internal/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 |