carlos / platform Public

host: the recovery probe adapter accepts the claim's legacy source generation (whole-branch fixes, item 4)

recoveryProbeMatches refused a declared or clean return whose request
named an empty SourceGeneration, and admits compared the journal with
the request only when the request's was set. "" is the claim's valid
encoding of a legacy source (sessions that predate admission records),
which recoverRun.probing passes through from the journal unchanged and
the owners normalise as @root; those recoveries failed before reaching
the owners. The adapter now accepts "" and holds the claim's src_gen to
the request's normalised as the owners do (selectedGeneration).

Regression through the production adapter: a clean return and a
declared return of a legacy source reach the wake; a request naming
another generation than the claim's still refuses. The recovery-pass
test pins the request's source generation to the claim's rather than
to non-empty.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1f459538c55e14b4db689e50740a166295a9b563 parent 6bc852e
2 files changed, +76 −4
  • internal/host/movesrecoveryprobe.go +10 −3
  • internal/host/plan10_j2_adapter_test.go +66 −1
diff --git a/internal/host/movesrecoveryprobe.go b/internal/host/movesrecoveryprobe.go
index 1c69bce44..c2ffbaba3 100644
--- a/internal/host/movesrecoveryprobe.go
+++ b/internal/host/movesrecoveryprobe.go
@@ -187,11 +187,16 @@ func recoveryProbeMatches(rec provision.Record, req recoveryProbeRequest) error
switch {
case req.Declared != declared:
return fmt.Errorf("%w: the request's declared return is not the record's", errRecoveryProbeRequest)
- case declared && (!req.Clean || req.Coverage != "" || req.Generation != "" || req.SourceGeneration == ""):
+ // SourceGeneration may be "" (whole-branch fixes, item 4): the claim's
+ // legacy encoding of a source whose sessions predate admission records,
+ // which recoverRun.probing passes through from the journal and the
+ // owners normalise as hostclaim.RootGeneration; admits holds it to the
+ // claim's.
+ case declared && (!req.Clean || req.Coverage != "" || req.Generation != ""):
return fmt.Errorf("%w: a declared return admits a fresh generation restored from src_gen, uncovered", errRecoveryProbeRequest)
case declared:
case req.Clean:
- if req.Coverage != recoveryCoverageClean || req.Generation != "" || req.SourceGeneration == "" {
+ if req.Coverage != recoveryCoverageClean || req.Generation != "" {
return fmt.Errorf("%w: a clean return admits a fresh generation restored from src_gen", errRecoveryProbeRequest)
}
case req.Coverage != recoveryCoverageSealed || req.Generation != req.RollbackID:
@@ -226,7 +231,9 @@ func (a recoveryProbeAdapter) admits(obs hostclaim.Observed, rec provision.Recor
if declared != req.Declared {
return fmt.Errorf("%w: the claim's loss of the target is not the request's", hostbox.ErrChanged)
}
- if req.SourceGeneration != "" && c.Op.Journal[hostclaim.JournalSrcGen] != req.SourceGeneration {
+ // The claim's src_gen and the request's, normalised as the owners
+ // normalise them (selectedGeneration: "" is the legacy root), must agree.
+ if selectedGeneration(c.Op.Journal[hostclaim.JournalSrcGen]) != selectedGeneration(req.SourceGeneration) {
return fmt.Errorf("%w: the claim's src_gen is not the request's", hostbox.ErrChanged)
}
return nil
diff --git a/internal/host/plan10_j2_adapter_test.go b/internal/host/plan10_j2_adapter_test.go
index 5f22d9dbf..c4b87cf39 100644
--- a/internal/host/plan10_j2_adapter_test.go
+++ b/internal/host/plan10_j2_adapter_test.go
@@ -2,6 +2,7 @@ package host
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"os/exec"
@@ -353,6 +354,70 @@ func TestPlan10J2MissingWakeKeepsRecoveryAtProbing(t *testing.T) {
// With a wake entry the same production adapter drives the real recovery
// pass: each return records the inputs it renders at probing, before the
// wake, and the recovery goes on to opened.
+// The production adapter accepts the claim's legacy encoding of a source
+// generation (whole-branch fixes, item 4): "" is a source whose sessions
+// predate admission records, which recoverRun.probing passes through
+// from the claim's journal unchanged and the owners normalise as @root.
+// A declared return and a clean return of such a source reach the wake;
+// a request naming another generation than the claim's still does not.
+func TestPlan10J2AdapterAcceptsALegacySourceGeneration(t *testing.T) {
+ ctx := context.Background()
+ for name, declared := range map[string]bool{"a clean return": false, "a declared return": true} {
+ t.Run(name, func(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ created := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ inc, err := hostclaim.Incarnation("bab", "app", p1Host, created)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := provision.Record{Host: p1Host, Account: "bab", App: "app", Region: "ie",
+ Move: &provision.MoveState{ID: p1ID, Incarnation: inc, From: "ie", To: "uk", State: provision.MoveRollingBack,
+ Rollback: &provision.MoveRollback{ID: "rb_legacy", SourcePhase: provision.RbSourceProbing, TargetPhase: provision.RbTargetReleased}}}
+ journal := map[string]string{hostclaim.JournalSrcGen: "", hostclaim.JournalSourceStamp: "15", hostclaim.JournalCutSession: "15"}
+ if declared {
+ lossKey, err := hostclaim.LossRecordKey("l1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.Move.Rollback.LossRef = lossKey
+ journal["loss.l1"] = `{"kind":"move-target-lost","target":"uk-box"}`
+ }
+ c := &hostclaim.Claim{V: 2, Host: p1Host, Gen: 9,
+ Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/app/instances/" + p1Host + ".json", Account: "bab", App: "app", Incarnation: inc, CreatedAt: created, Location: "instances/" + p1Host, Stamp: 15, Session: 20, WriterBox: "uk-box",
+ Admissions: []hostclaim.AdmissionRecord{{Session: 20, Box: "uk-box", Generation: p1ID, Region: "uk", PredecessorRegion: "ie", Mode: "cold", Coverage: "sealed"}}},
+ Op: hostclaim.Op{Kind: hostclaim.KindRestoring, ID: p1ID, Phase: hostclaim.PhaseProbing, Exec: "ie-box", Participants: []string{"ie-box", "uk-box"},
+ Intent: map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box", "rollback": "rb_legacy"}, Journal: journal}}
+ if declared {
+ c.LossRefs, c.Lost = []string{"l1"}, []string{"uk-box"}
+ }
+ key, _ := hostclaim.Key(p1Host)
+ body, _ := json.Marshal(c)
+ if _, err := s.Put(ctx, key, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ woke := 0
+ a := recoveryProbeAdapter{Box: "ie-box", Claims: true,
+ Route: func(string) (*registry.Route, error) { return &registry.Route{Host: p1Host, DBPath: "/data/x.db"}, nil },
+ Current: func(context.Context, release.Store, provision.Record) (provision.MoveInputs, error) {
+ return provision.MoveInputs{Release: "v1"}, nil
+ },
+ Wake: func(context.Context, registry.Route, activator.StartPolicy) error { woke++; return nil }}
+ req := recoveryProbeRequest{RollbackID: "rb_legacy", Clean: true, Declared: declared, Attempts: recoveryProbeAttempts, SourceGeneration: "",
+ Record: func(context.Context, provision.MoveInputs) error { return nil }}
+ if !declared {
+ req.Coverage = recoveryCoverageClean
+ }
+ if out, err := a.probe(ctx, s, rec, req); err != nil || out != recoveryProbeAnswered || woke != 1 {
+ t.Fatalf("a legacy source's return: %v %v woke=%d", out, err, woke)
+ }
+ req.SourceGeneration = "other"
+ if _, err := a.probe(ctx, s, rec, req); !errors.Is(err, hostbox.ErrChanged) || woke != 1 {
+ t.Fatalf("another generation than the claim's: %v woke=%d", err, woke)
+ }
+ })
+ }
+}
+
func TestPlan10J2AdapterDrivesTheRecoveryPass(t *testing.T) {
for _, dirty := range []bool{false, true} {
t.Run(fmt.Sprintf("dirty=%v", dirty), func(t *testing.T) {
@@ -396,7 +461,7 @@ func TestPlan10J2AdapterDrivesTheRecoveryPass(t *testing.T) {
t.Fatalf("wakes %+v probes %d", wakes, len(h.probes))
}
req := h.probes[0]
- if req.Clean == dirty || req.Attempts != recoveryProbeAttempts || (dirty && (req.Coverage != recoveryCoverageSealed || req.Generation == "")) || (!dirty && (req.Coverage != recoveryCoverageClean || req.SourceGeneration == "")) {
+ if req.Clean == dirty || req.Attempts != recoveryProbeAttempts || (dirty && (req.Coverage != recoveryCoverageSealed || req.Generation == "")) || (!dirty && (req.Coverage != recoveryCoverageClean || req.SourceGeneration != h.claim(t).Op.Journal[hostclaim.JournalSrcGen])) {
t.Fatalf("request %+v", req)
}
})