package hostbox import ( "bytes" "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) type ColdSuccessor struct { Host, Incarnation, Box, Enrollment, DirectoryRevision, ETag, Generation, Op, Phase string Stamp, Session uint64 } type ColdHandover struct { OperationsOnly bool checkpoint func(string) afterIntent func() error afterPark func() error Guard hostguard.Guard Claims release.Store Registry *registry.Registry Inventory *StopInventoryDB Launches *LaunchJournal Roots map[string]string JournalDir string VerifyReceipt hostguard.CleanupVerify // Admit, when set, is root's fresh successor admission (roster binding // N5, B1). Run calls it before any other step, for a new handover and // for every replay of a journaled one, startup recovery included, so a // successor is started or resumed only under the binding the roster // gives this box now. Admit func(context.Context, ColdSuccessor) error // Obsolete, when set, reports a journaled successor stamped under a // binding other than root's startup binding (roster binding B3). // Recover never resumes such a handover; it settles it (settleObsolete). Obsolete func(ColdSuccessor) bool } type coldJournal struct { ServingInstallation *hostguard.RootInstallation `json:",omitempty"` V int IntendedAbsent bool ID, Phase string Prior hostguard.FenceTarget Barrier hostguard.FenceTarget Successor ColdSuccessor Inventory StopInventory Receipt hostguard.FenceReceipt Before, After registry.ColdRows } func ownershipFor(host, incarnation string, stamp uint64, box string) string { sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", host, incarnation, stamp, box))) return hex.EncodeToString(sum[:]) } func rowIDFor(owner, kind, name string) string { sum := sha256.Sum256([]byte(owner + "\x00" + kind + "\x00" + name)) return hex.EncodeToString(sum[:]) } func (h ColdHandover) Run(ctx context.Context, prior hostguard.FenceTarget, next ColdSuccessor) error { if h.OperationsOnly && (next.Op == "" || next.Phase == "") { return ErrUnsupported } if h.Claims == nil || h.Registry == nil || h.Inventory == nil || h.Launches == nil || h.VerifyReceipt == nil || !filepath.IsAbs(h.JournalDir) || len(h.Roots) == 0 || next.Host != prior.Host || next.Incarnation != prior.Incarnation || next.Stamp <= prior.Stamp || next.Session <= prior.Session || next.Stamp != next.Session || next.Box != prior.Box || next.Enrollment != prior.Enrollment || next.ETag == "" || next.Generation == "" { return ErrUnsupported } if h.Admit != nil { if err := h.Admit(ctx, next); err != nil { return err } } check := func(c *hostclaim.Claim) error { if c == nil || c.Host != next.Host || c.Holder == nil || c.Holder.Family != "instance" || c.Holder.Incarnation != next.Incarnation || c.Holder.Stamp != next.Stamp || c.Holder.Session != next.Session || c.Holder.Generation != next.Generation || c.Holder.WriterBox != next.Box || c.Op.ID != next.Op || c.Op.Phase != next.Phase || c.Op.Exec != next.Box { return ErrChanged } found := 0 for _, admission := range c.Holder.Admissions { if admission.Session == next.Session && admission.Box == next.Box && admission.Generation == next.Generation && admission.Mode == "cold" { found++ } } if found != 1 { return ErrChanged } return nil } observed, err := hostclaim.Read(ctx, h.Claims, next.Host) if err != nil { return err } if err = check(observed.Claim); err != nil { return err } id := "handover-" + ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box) path := filepath.Join(h.JournalDir, id+".json") var j coldJournal body, err := readTrustedJournal(path) if err == nil { if decodeStrict(body, &j) != nil || j.V != 1 || j.ID != id || j.Prior != prior || j.Successor != next || (j.Phase != "prepared" && j.Phase != "parked" && j.Phase != "complete") { return ErrTypedJournal } if j.Phase == "complete" { return h.Guard.CompleteColdHandover(ctx, j.Barrier, true, check, func() error { return nil }) } } else if !errors.Is(err, os.ErrNotExist) { return err } else { if observed.ETag != next.ETag { return ErrChanged } receipt, err := h.Guard.FenceReceiptFor(ctx, prior) if err != nil { return err } if err = h.VerifyReceipt(ctx, receipt); err != nil { return err } barrier := prior barrier.FenceID = id barrier.Stamp = next.Stamp barrier.Session = next.Session barrier.Op = next.Op barrier.Phase = next.Phase barrier.ClaimETag = next.ETag barrier.DirectoryRevision = next.DirectoryRevision err = h.Guard.CleanupWith(ctx, prior, h.VerifyReceipt, func() error { saved, loadErr := readTrustedJournal(path) if loadErr == nil { var existing coldJournal if decodeStrict(saved, &existing) != nil || existing.ID != id || existing.Prior != prior || existing.Successor != next { return ErrTypedJournal } j = existing return nil } if !errors.Is(loadErr, os.ErrNotExist) { return loadErr } in, err := h.Inventory.ReadExactStopInventory(ctx, prior) if err != nil { return err } if !in.Complete || in.Host != prior.Host || in.Incarnation != prior.Incarnation || in.InstallationStamp != prior.Stamp || in.Box != prior.Box || in.Enrollment != prior.Enrollment || in.DirectoryRevision != prior.DirectoryRevision { return ErrTypedInventory } row, err := h.Registry.Get(prior.Host) if err != nil { return err } if row == nil || !row.ClaimOwnership.Valid() || row.ClaimIncarnation != prior.Incarnation || row.ClaimStamp != prior.Stamp || row.Kind != "instance" || row.Unit != "" || row.Addr != "" || row.Addrs != "" || row.Backing != "" || row.Tunnel != "" || row.Platform || row.Canary || row.Replica { return ErrUnsupported } if err := ValidateAgentInventory(in, row, h.Roots["data"]); err != nil { return err } timers, err := h.Registry.TimersFor(prior.Host) if err != nil { return err } for _, timer := range timers { if timer.ClaimIncarnation != prior.Incarnation || timer.ClaimStamp != prior.Stamp || timer.ClaimOwner != row.ClaimOwner || timer.ClaimRowID == "" || timer.Registration == "" { return ErrTypedInventory } } before := registry.ColdRows{Route: *row, Timers: timers} after := registry.ColdRows{Route: *row, Timers: append([]registry.Timer(nil), timers...)} owner := ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box) after.Route.ClaimOwnership = registry.ClaimOwnership{ClaimIncarnation: next.Incarnation, ClaimStamp: next.Stamp, ClaimOwner: owner, ClaimRowID: rowIDFor(owner, "route", next.Host)} for i := range after.Timers { after.Timers[i].ClaimOwnership = after.Route.ClaimOwnership after.Timers[i].ClaimRowID = rowIDFor(owner, "timer", after.Timers[i].Name) } j = coldJournal{V: 1, ID: id, Phase: "prepared", Prior: prior, Barrier: barrier, Successor: next, Inventory: in, Receipt: receipt, Before: before, After: after} return writeColdJournal(path, j) }) if err != nil { return err } } if j.Phase == "complete" { return h.Guard.CompleteColdHandover(ctx, j.Barrier, true, check, func() error { return nil }) } if err := ValidateAgentInventory(j.Inventory, &j.Before.Route, h.Roots["data"]); err != nil { return err } if j.Phase == "prepared" { h.atCheckpoint("intent") } if h.afterIntent != nil && j.Phase == "prepared" { if err := h.afterIntent(); err != nil { return err } } if err = h.Guard.BeginColdHandover(ctx, prior, j.Barrier, check); err != nil { return err } if err = h.VerifyReceipt(ctx, j.Receipt); err != nil { return err } if effects, err := h.Launches.HasInstallationEffects(ctx, next.Host, next.Incarnation, next.Stamp); err != nil { return err } else if effects { return ErrChanged } if j.Phase == "prepared" { inventory := RetirementInventory{Host: prior.Host, Captured: hostguard.Captured{Incarnation: prior.Incarnation, Stamp: prior.Stamp}, Ownership: j.Before.Route.ClaimOwner} var intended []StopInventoryEntry names := map[string]int{} for _, entry := range j.Inventory.Entries { if entry.Kind != "route" && entry.Kind != "timer" { names[entry.ManagedRoot+"\x00"+entry.RelativePath]++ } if entry.Ownership != inventory.Ownership || entry.Kind == "unit-state" { return ErrTypedInventory } // An intended path is an intent PrepareState persisted before its // mkdir and never finalized. It carries no captured identity, so it // is handled only by retireIntended below, never as an artifact. if entry.Kind == "timer" && (entry.Materialization == "intended" || entry.TimerRetired) { if entry.Device != 0 || entry.Inode != 0 || entry.Type != 0 || entry.ManagedRoot != "registry" || entry.RowIdentity == "" { return ErrTypedInventory } present := false for _, timer := range j.Before.Timers { if entry.RelativePath != timer.Host+"/"+timer.Name { continue } if entry.TimerRetired || timer.Registration != entry.RowIdentity || timer.ClaimRowID != entry.RowIdentity || timer.ClaimOwner != entry.Ownership { return ErrTypedInventory } present = true } if !present { inventory.Artifacts = append(inventory.Artifacts, Artifact{Kind: ArtifactTimer, Root: entry.ManagedRoot, Name: entry.RelativePath, Owner: entry.Ownership, RowID: entry.RowIdentity + "~" + entry.RowIdentity, AllowAbsent: true}) } continue } if entry.Materialization == "intended" { if entry.Kind != "path" || entry.Device != 0 || entry.Inode != 0 || !managedName(entry.RelativePath) || h.Roots[entry.ManagedRoot] == "" { return ErrTypedInventory } intended = append(intended, entry) continue } if entry.Materialization != "materialized" { return ErrTypedInventory } if entry.Kind == "timer" { continue } inventory.Artifacts = append(inventory.Artifacts, Artifact{Kind: ArtifactKind(entry.Kind), Root: entry.ManagedRoot, Name: entry.RelativePath, Owner: entry.Ownership, RowID: entry.RowIdentity, Identity: entry.Identity, Dev: entry.Device, Ino: entry.Inode, Type: entry.Type}) } for _, timer := range j.Before.Timers { inventory.Artifacts = append(inventory.Artifacts, Artifact{Kind: ArtifactTimer, Root: "registry", Name: timer.Host + "/" + timer.Name, Owner: timer.ClaimOwner, RowID: timer.ClaimRowID + "~" + timer.Registration}) } for _, entry := range intended { if names[entry.ManagedRoot+"\x00"+entry.RelativePath] != 1 { return ErrTypedInventory } } intent, err := NewRetirementJournal(j.ID+"-old", inventory) if err != nil { return err } intent.Target = &j.Prior intent.StopReceipt = append([]byte(nil), j.Receipt.Bytes...) if err = h.retireIntended(ctx, j, intended); err != nil { return err } retire := TypedRetirement{JournalDir: filepath.Join(h.JournalDir, "retired"), Authority: InstallationCleanup{Guard: h.Guard, Verify: h.VerifyReceipt}, Filesystem: TypedParker{CapturePersistent: h.Guard.Root != nil, Roots: h.Roots, ParkedRoots: h.Roots, Incarnation: prior.Incarnation, Host: prior.Host, Retirement: intent.ID}, Rows: RegistryRows{Registry: h.Registry, Inventory: inventory}, Ack: func(context.Context, RetirementJournal) error { return nil }} if err = retire.Run(ctx, prior, intent); err != nil { return err } if err = h.Guard.CleanupWith(ctx, prior, h.VerifyReceipt, func() error { current, err := readTrustedJournal(path) if err != nil { return err } var saved coldJournal if decodeStrict(current, &saved) != nil || saved.ID != j.ID || saved.Prior != j.Prior || saved.Successor != j.Successor { return ErrTypedJournal } if saved.Phase == "complete" || saved.Phase == "parked" { j = saved return nil } if saved.Phase != "prepared" { return ErrTypedJournal } saved.Phase = "parked" j = saved return writeColdJournal(path, j) }); err != nil { return err } } if j.Phase == "complete" { return h.Guard.CompleteColdHandover(ctx, j.Barrier, true, check, func() error { return nil }) } h.atCheckpoint("parked") if h.afterPark != nil { if err := h.afterPark(); err != nil { return err } } return h.Guard.CompleteColdHandover(ctx, j.Barrier, false, check, func() error { if effects, err := h.Launches.HasInstallationEffects(ctx, next.Host, next.Incarnation, next.Stamp); err != nil { return err } else if effects { return ErrChanged } for _, entry := range j.Inventory.Entries { if entry.Kind == "route" || entry.Kind == "timer" { continue } rootPath := h.Roots[entry.ManagedRoot] if rootPath == "" { return ErrTypedInventory } root, err := os.OpenRoot(rootPath) if err != nil { return err } _, statErr := root.Lstat(entry.RelativePath) root.Close() if !errors.Is(statErr, os.ErrNotExist) { if statErr != nil { return statErr } return ErrChanged } } if err := coldNamespaceEmpty(h.Roots, next.Host, h.Guard.LockDir, filepath.Join(h.Guard.DataDir, "hostguard"), h.JournalDir); err != nil { return err } owner := j.After.Route.ClaimOwnership successor := StopInventory{Complete: true, Host: next.Host, Incarnation: next.Incarnation, InstallationStamp: next.Stamp, Box: next.Box, Enrollment: next.Enrollment, DirectoryRevision: next.DirectoryRevision, Entries: []StopInventoryEntry{{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: next.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "intended"}}} existing, err := h.Inventory.ReadInstallation(ctx, next.Host, next.Incarnation, next.Stamp) if err == nil { if len(existing.Entries) != 1 || existing.Entries[0].Kind != "route" { return ErrTypedInventory } existing.Entries[0].Materialization = "intended" a, _ := json.Marshal(existing) b, _ := json.Marshal(successor) if !bytes.Equal(a, b) { return ErrTypedInventory } } else if !errors.Is(err, sql.ErrNoRows) { return err } if err = h.Inventory.WriteStopInventory(ctx, successor); err != nil { return err } h.atCheckpoint("inventory-intent") if err = h.Registry.RestoreClaimRows(ctx, j.ID, j.ID+"-old", j.Before, j.After); err != nil { return err } h.atCheckpoint("rows") successor.Entries[0].Materialization = "materialized" if err = h.Inventory.WriteStopInventory(ctx, successor); err != nil { return err } h.atCheckpoint("inventory-materialized") j.Phase = "complete" if err := writeColdJournal(path, j); err != nil { return err } h.atCheckpoint("complete-journal") return nil }) } // retireIntended settles prior state paths whose intent is durable but whose // materialization is not. PrepareState verifies absence before persisting the // intent and refuses an intended name it finds present, so with the prior // controller fenced dead, the successor barred and without effects or path // inventory, the only writer of such a name is the prior's own mkdir. An absent // name moves nothing. A present one must still be an empty directory of the // recorded type; it is captured into a typed retirement journal and parked by // inode under exact cleanup authority, so it is preserved, never unlinked. // Anything else refuses before any artifact moves. func (h ColdHandover) retireIntended(ctx context.Context, j coldJournal, intended []StopInventoryEntry) error { if len(intended) == 0 { return nil } if successor, err := h.Inventory.ReadInstallation(ctx, j.Successor.Host, j.Successor.Incarnation, j.Successor.Stamp); err == nil { for _, entry := range successor.Entries { if entry.Kind != "route" { return ErrChanged } } } else if !errors.Is(err, sql.ErrNoRows) { return err } id := j.ID + "-intended" dir := filepath.Join(h.JournalDir, "retired") parker := TypedParker{CapturePersistent: h.Guard.Root != nil, Roots: h.Roots, ParkedRoots: h.Roots, Incarnation: j.Prior.Incarnation, Host: j.Prior.Host, Retirement: id} want := RetirementInventory{Host: j.Prior.Host, Captured: hostguard.Captured{Incarnation: j.Prior.Incarnation, Stamp: j.Prior.Stamp}, Ownership: j.Before.Route.ClaimOwner} body, err := readTrustedJournal(filepath.Join(dir, id+".json")) resumed := err == nil if resumed { // The captured identities were decided once, durably, by this // controller. Never recapture: the parked inode must be the journalled one. var saved RetirementJournal if decodeStrict(body, &saved) != nil || saved.ID != id || saved.Inventory.Host != want.Host || saved.Inventory.Captured != want.Captured || saved.Inventory.Ownership != want.Ownership { return ErrTypedJournal } want.Artifacts = saved.Inventory.Artifacts for _, a := range want.Artifacts { found := 0 for _, entry := range intended { if a.Root == entry.ManagedRoot && a.Name == entry.RelativePath { found++ } } if found != 1 { return ErrTypedJournal } } } else if !errors.Is(err, os.ErrNotExist) { return err } resumed = resumed || j.IntendedAbsent for _, entry := range intended { journalled := false for _, a := range want.Artifacts { if a.Root == entry.ManagedRoot && a.Name == entry.RelativePath { if a.Kind != ArtifactPath || a.Owner != entry.Ownership || entry.Type == 0 || a.Type != entry.Type { return ErrTypedJournal } journalled = true } } if journalled { continue } // Absence is read below the configured root only; a missing root is // not evidence that the name was never materialized. if info, err := os.Lstat(h.Roots[entry.ManagedRoot]); err != nil || !info.IsDir() { return ErrTypedInventory } a, err := parker.CaptureArtifact(entry.ManagedRoot, entry.RelativePath, entry.Ownership, ArtifactPath) if errors.Is(err, fs.ErrNotExist) { continue } if err != nil { return err } // A name that was absent when the journal was written has no recorded // decision; its later appearance is unknown ownership. if resumed || entry.Type == 0 || a.Type != entry.Type { return ErrChanged } if err = parker.VerifyEmptyDirectory(a); err != nil { return err } again, err := parker.CaptureArtifact(entry.ManagedRoot, entry.RelativePath, entry.Ownership, ArtifactPath) if err != nil || again != a { return ErrChanged } want.Artifacts = append(want.Artifacts, a) } if len(want.Artifacts) == 0 { if j.IntendedAbsent { return nil } // Persist the all-absent decision before the main retirement can move // anything. Replay must not attribute a newly appearing name to prior. return h.Guard.CleanupWith(ctx, j.Prior, h.VerifyReceipt, func() error { path := filepath.Join(h.JournalDir, j.ID+".json") body, err := readTrustedJournal(path) if err != nil { return err } var saved coldJournal if decodeStrict(body, &saved) != nil || saved.ID != j.ID || saved.Prior != j.Prior || saved.Successor != j.Successor || saved.Phase != "prepared" { return ErrTypedJournal } saved.IntendedAbsent = true return writeColdJournal(path, saved) }) } intent, err := NewRetirementJournal(id, want) if err != nil { return err } target := j.Prior intent.Target = &target intent.StopReceipt = append([]byte(nil), j.Receipt.Bytes...) retire := TypedRetirement{JournalDir: dir, Authority: InstallationCleanup{Guard: h.Guard, Verify: h.VerifyReceipt}, Filesystem: parker, Rows: RegistryRows{Registry: h.Registry, Inventory: want}, Ack: func(context.Context, RetirementJournal) error { return nil }} if err = retire.Run(ctx, j.Prior, intent); err != nil { return err } // Every captured name is parked now; one present again is not the prior's. for _, a := range want.Artifacts { if _, err := parker.CaptureArtifact(a.Root, a.Name, a.Owner, ArtifactPath); !errors.Is(err, fs.ErrNotExist) { if err != nil { return err } return ErrChanged } } return nil } func writeColdJournal(path string, j coldJournal) error { if err := checkTrustedJournalDir(filepath.Dir(path), true); err != nil { return err } body, err := json.Marshal(j) if err != nil { return err } file, err := os.CreateTemp(filepath.Dir(path), ".handover-*") if err != nil { return err } defer os.Remove(file.Name()) if _, err = file.Write(body); err == nil { err = file.Sync() } if closeErr := file.Close(); err == nil { err = closeErr } if err != nil { return err } if err = os.Rename(file.Name(), path); err != nil { return err } dir, err := os.Open(filepath.Dir(path)) if err != nil { return err } defer dir.Close() return dir.Sync() } func (j *LaunchJournal) HasInstallationEffects(ctx context.Context, host, incarnation string, stamp uint64) (bool, error) { if j == nil { return false, ErrUnsupported } if j.remote != nil { out, err := journalCall(ctx, j.remote, JournalRequest{Op: "has-effects", Target: hostguard.FenceTarget{Host: host, Incarnation: incarnation, Stamp: stamp}}) return out.Found, err } if j == nil || j.db == nil { return false, ErrUnsupported } rows, err := journalExecutorFor(ctx, j.db).QueryContext(ctx, "SELECT body FROM launches") if err != nil { return false, err } defer rows.Close() for rows.Next() { var body []byte if err = rows.Scan(&body); err != nil { return false, err } var in LaunchInventory if err = json.Unmarshal(body, &in); err != nil { return false, err } if !in.Completed.Valid() { return false, ErrTypedInventory } if in.Target.Host == host && in.Target.Incarnation == incarnation && in.Target.Stamp == stamp && (in.Completed.Count > 0 || len(in.Reservations) > 0 || len(in.Files) > 0 || len(in.ProducerIDs) > 0) { return true, nil } } return false, rows.Err() } func coldNamespaceEmpty(roots map[string]string, host string, operational ...string) error { for name, root := range roots { if !component(name) || !filepath.IsAbs(root) { return ErrTypedInventory } info, err := os.Lstat(root) if err != nil { return err } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return ErrTypedInventory } err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } rel, err := filepath.Rel(root, path) if err != nil { return err } if rel == "." { return nil } for _, dir := range operational { if dir != "" && path == filepath.Clean(dir) && entry.IsDir() { return filepath.SkipDir } } if entry.IsDir() && (rel == ".parked" || rel == "hostguard" || rel == ".hostclaims" || (name == "opt" && rel == "releases")) { return filepath.SkipDir } if strings.Contains(rel, host) || strings.Contains(rel, strings.ReplaceAll(host, "-", `\x2d`)) { return ErrTypedInventory } return nil }) if err != nil { return err } } return nil } func (h ColdHandover) Recover(ctx context.Context) error { entries, err := readTrustedJournalDir(h.JournalDir) if err != nil { return err } if len(entries) == 0 { return nil } pending, err := h.Guard.PendingFences(ctx) if err != nil { return err } var settleErr error for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue } body, err := readTrustedJournal(filepath.Join(h.JournalDir, entry.Name())) if err != nil { return err } var j coldJournal if decodeStrict(body, &j) != nil || j.ID+".json" != entry.Name() || j.V != 1 { return ErrTypedJournal } if j.Phase == "complete" { needsFinish := false for _, target := range pending { if target == j.Barrier { needsFinish = true } } if !needsFinish { continue } } if j.Phase == "settled" { continue } if h.Obsolete != nil && h.Obsolete(j.Successor) { // A failed settlement leaves the handover as it was, for the // next startup; the other journals are still recovered. settleErr = errors.Join(settleErr, h.settleObsolete(ctx, filepath.Join(h.JournalDir, entry.Name()), j)) continue } if err = h.Run(ctx, j.Prior, j.Successor); err != nil { if h.Guard.Root != nil && h.Guard.AbandonColdHandover(ctx, j.Barrier) == nil { continue } return errors.Join(settleErr, err) } } return settleErr } // settleObsolete settles a journaled cold handover whose successor binding // is obsolete (roster binding B3), whatever phase it reached short of // complete. The guard clears the barrier and retires the successor's stamp // under the host lock, then the journal is rewritten as "settled": Recover // skips it from then on and Run refuses it, so the successor is never // resumed or reused. The predecessor is untouched: its receipt and active // stamp stay, so its stop and retirement still complete, and a new // successor admitted under today's binding can take over from it. If the // journal rewrite fails, the next startup settles again; the guard step is // idempotent. func (h ColdHandover) settleObsolete(ctx context.Context, path string, j coldJournal) error { if j.Phase != "prepared" && j.Phase != "parked" && j.Phase != "complete" { return ErrTypedJournal } return h.Guard.SettleObsoleteColdHandover(ctx, j.Prior, j.Barrier, func() error { j.Phase = "settled" return writeColdJournal(path, j) }) } func (h ColdHandover) atCheckpoint(point string) { if h.checkpoint != nil { h.checkpoint(point) } }