carlos / platform Public

host: a relocation probe reads no release from an unreadable one, opens P empty only on the pin's word with the authority agreeing, and takes a later attempt's empty listing as empty only over an empty P; root's reader needs a bound reserved start

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 66a44eb122347fe85f20e63965f137f8c767b05d parent f28a805
4 files changed, +324 −18
  • internal/host/relocationattempts_test.go +73 −0
  • internal/host/relocationowners.go +13 −3
  • internal/host/relocationowners_test.go +142 −0
  • internal/host/relocationwake.go +96 −15
diff --git a/internal/host/relocationattempts_test.go b/internal/host/relocationattempts_test.go
index 698b70f8f..1329a2051 100644
--- a/internal/host/relocationattempts_test.go
+++ b/internal/host/relocationattempts_test.go
@@ -166,3 +166,76 @@ func TestRootInstallsTheRelocationReaderOnlyWithRegionHA(t *testing.T) {
}
}
}
+
+// Absence is never evidence: root's reader answers nothing for an
+// operation whose journal is missing, unreadable or malformed, and the
+// journal's own settles and restarts refuse once it is gone. (A fresh
+// journal for a missing file is the writer's first state; the reader
+// refuses it because no session is bound, and this pins that direction.)
+func TestRootReaderRefusesAMissingOrUnreadableJournal(t *testing.T) {
+ ctx := context.Background()
+ claim := func() *hostclaim.Claim {
+ return &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"},
+ Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op1", Phase: hostclaim.PhaseRelocatingProbing, Exec: "dst", Journal: map[string]string{"install_stamp": "9", "prepared": "P1", "probe_attempts.dst": "1"}}}
+ }
+ dd := t.TempDir()
+ r := relocationAttemptReader{DataDir: dd}
+ o := hostclaim.Observed{Host: "shop.example", Claim: claim()}
+ if _, err := r.ReadAttempt(ctx, o); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("no journal: %v", err)
+ }
+ path, _ := relocationAttemptsPath(dd, "op1")
+ if err := os.MkdirAll(path, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := r.ReadAttempt(ctx, o); err == nil {
+ t.Fatal("a journal that cannot be read answered")
+ }
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(`{"v":1,"host":"shop.example","op":"op1"`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := r.ReadAttempt(ctx, o); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("a malformed journal: %v", err)
+ }
+ // A journal with its maps missing is malformed too, not empty.
+ if err := os.WriteFile(path, []byte(`{"v":1,"host":"shop.example","op":"op1"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := readRelocationAttempts(dd, "shop.example", "op1"); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("a journal without its maps: %v", err)
+ }
+}
+
+// Root takes a start only when the journal vouches for it twice: the
+// holder's session is bound to the record attempt k, and k's current
+// start is still reserved. A well-formed start under an unbound session,
+// a session bound to another attempt, or a start already settled failed
+// or healthy, answers nothing.
+func TestRootReaderNeedsABoundReservedStart(t *testing.T) {
+ ctx := context.Background()
+ attempt := hostclaim.WakeRetryAttempt{ID: "a", InvocationID: "b", Session: 7, Box: "dst"}
+ claim := &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"},
+ Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op1", Phase: hostclaim.PhaseRelocatingProbing, Exec: "dst", Journal: map[string]string{"install_stamp": "9", "prepared": "P1", "probe_attempts.dst": "1"}}}
+ o := hostclaim.Observed{Host: "shop.example", Claim: claim}
+ for name, shape := range map[string]func(*relocationAttemptJournal){
+ "unbound session": func(j *relocationAttemptJournal) { delete(j.Sessions, "7") },
+ "session bound to another attempt": func(j *relocationAttemptJournal) { j.Sessions["7"] = 2 },
+ "a failed start": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.State = probeFailed; j.Starts["1"] = s },
+ "a healthy start": func(j *relocationAttemptJournal) { s := j.Starts["1"]; s.State = probeHealthy; j.Starts["1"] = s },
+ } {
+ dd := t.TempDir()
+ j, _ := readRelocationAttempts(dd, "shop.example", "op1")
+ j.Sessions["7"] = 1
+ j.Starts["1"] = relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: probeReserved, Attempt: attempt}
+ shape(&j)
+ if err := j.write(dd); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := (relocationAttemptReader{DataDir: dd}).ReadAttempt(ctx, o); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("%s: read %+v %v", name, got, err)
+ }
+ }
+}
diff --git a/internal/host/relocationowners.go b/internal/host/relocationowners.go
index 3656574a8..5093cf20a 100644
--- a/internal/host/relocationowners.go
+++ b/internal/host/relocationowners.go
@@ -128,8 +128,11 @@ func relocationBeforeAdmission(box string, releases hostclaim.ReleaseAuthority)
if hostclaim.PreparedOpened(c) {
return nil
}
- var rel hostclaim.Release
- if json.Unmarshal([]byte(c.Op.Journal["release"]), &rel) == nil && rel.ReleasedBy != "" {
+ rel, released, err := journaledRelease(c)
+ if err != nil {
+ return err // present and unreadable is never a failover
+ }
+ if released {
return releases.VerifyRelease(ctx, *c, rel)
}
src := c.Op.Intent["source_box"]
@@ -179,6 +182,9 @@ func (a ClaimAssembly) relocationAdmit(cfg RelocationAssemblyConfig) func(contex
return 0, false, ErrClaimStopUnavailable
}
c := o.Claim
+ if _, _, err := journaledRelease(c); err != nil {
+ return 0, false, err
+ }
if certified != hostrelocate.CertifiedFor(c) {
return 0, false, fmt.Errorf("%w: the participant and the owners disagree on certification", hostrelocate.ErrRefused)
}
@@ -192,7 +198,11 @@ func (a ClaimAssembly) relocationAdmit(cfg RelocationAssemblyConfig) func(contex
if err != nil {
return 0, false, err
}
- if auth.InstallManifest != "" {
+ empty, err := emptyPrepared(c, auth)
+ if err != nil {
+ return 0, false, err
+ }
+ if !empty {
im, _, err := readInstallManifest(ctx, cfg.Versions, cfg.Bucket, moves.GenerationPrefix(c.Holder.Location, auth.Generation))
if err != nil {
return 0, false, err
diff --git a/internal/host/relocationowners_test.go b/internal/host/relocationowners_test.go
index 3d24a6ba5..6cc657b6e 100644
--- a/internal/host/relocationowners_test.go
+++ b/internal/host/relocationowners_test.go
@@ -641,3 +641,145 @@ func TestRelocationStaysUnboundWithoutTheC8Provider(t *testing.T) {
t.Fatalf("relocating bound without C8: %v %v", bound, err)
}
}
+
+// authority rewrites the journaled prepared authority in place.
+func (f *roFixture) authority(mutate func(*hostclaim.PreparedAuthority)) hostclaim.Observed {
+ f.t.Helper()
+ return f.update(func(c *hostclaim.Claim) error {
+ var a hostclaim.PreparedAuthority
+ if err := json.Unmarshal([]byte(c.Op.Journal["prepared_authority"]), &a); err != nil {
+ return err
+ }
+ mutate(&a)
+ c.Op.Journal["prepared_authority"] = string(mustJSON(f.t, a))
+ return nil
+ })
+}
+
+// Absence is never evidence: a journaled release that is present but
+// unreadable is not "no release". Before the fix a damaged release on a
+// claim whose source is lost and fenced passed the admission hook as a
+// failover, and Select, AdmissionEvidence and the probe attempt went on
+// uncertified (CertifiedFor reads an unparseable release as none).
+func TestRelocationAnUnreadableReleaseIsNeverAFailover(t *testing.T) {
+ ctx := t.Context()
+ for name, raw := range map[string]string{"unparseable": `{"released_by":`, "no releasing box": `{}`} {
+ t.Run(name, func(t *testing.T) {
+ f := newROFixture(t, roOptions{failover: true})
+ good, err := f.inputs.Select(ctx, f.route, f.read(), roProbe)
+ if err != nil {
+ t.Fatal(err)
+ }
+ damaged := f.update(func(c *hostclaim.Claim) error { c.Op.Journal["release"] = raw; return nil })
+ releases := &roReleases{}
+ if err := relocationBeforeAdmission("dst", releases)(ctx, damaged); !errors.Is(err, hostbox.ErrChanged) || releases.calls != 0 {
+ t.Fatalf("the hook took a damaged release for a failover: %v (%d checks)", err, releases.calls)
+ }
+ if _, err := f.inputs.Select(ctx, f.route, damaged, roProbe); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("Select opened P over a damaged release: %v", err)
+ }
+ if err := f.inputs.AdmissionEvidence(ctx, damaged, good.Admission, good.Baseline); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("AdmissionEvidence passed over a damaged release: %v", err)
+ }
+ act := &relocationProbeActivator{}
+ probe := ClaimAssembly{}.relocationAdmit(RelocationAssemblyConfig{Activator: act, Versions: f.bucket, Bucket: "data"})
+ if _, _, err := probe(ctx, damaged, false); !errors.Is(err, hostbox.ErrChanged) || len(act.probes) != 0 {
+ t.Fatalf("the probe attempt ran over a damaged release: %v (%d probes)", err, len(act.probes))
+ }
+ })
+ }
+}
+
+// Absence is never evidence: a later attempt's empty listing is an empty
+// predecessor only over an evidenced empty P. Under a non-empty pin P's
+// snapshot is in the location, so a listing that finds nothing is a wrong
+// bucket or a listing not yet visible, never an empty cold admission.
+func TestRelocationLaterAttemptEmptyListingNeedsAnEmptyPin(t *testing.T) {
+ ctx := t.Context()
+ f := newROFixture(t, roOptions{})
+ first, err := f.inputs.Select(ctx, f.route, f.read(), roProbe)
+ if err != nil {
+ t.Fatal(err)
+ }
+ opened := f.admit(f.read(), first)
+ // The same inputs over a bucket that lists nothing for the location.
+ blank := leasetest.NewVersionedBucket()
+ blank.AddBucket("data")
+ elsewhere := &RelocationWakeInputs{Launch: f.inputs.Launch, FilesManifest: f.c8, Markers: plan9D5Markers{blank}, Claims: f.claims, FilesStore: f.files, Data: blank, Bucket: "data", Region: "ie", Box: "dst",
+ NewID: f.inputs.NewID, Now: f.inputs.Now, Releases: f.releases}
+ if in, err := elsewhere.Select(ctx, f.route, opened, roProbe); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("an empty listing under a pinned snapshot selected empty=%v: %v", in.Empty, err)
+ }
+ h := opened.Claim.Holder
+ fresh := hostclaim.Admission{ObservedOpID: roOp, ObservedPhase: hostclaim.PhaseRelocatingProbing, ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: "dst", Mode: "cold", NewGeneration: "fresh9", Region: "ie", PredecessorRegion: "ie"}
+ if err := elsewhere.EmptyAuthority(ctx, opened, ClaimLaunchInputs{Empty: true, Admission: fresh}); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("EmptyAuthority proved an empty later attempt under a pinned snapshot: %v", err)
+ }
+ if err := elsewhere.AdmissionEvidence(ctx, opened, fresh, nil); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("AdmissionEvidence passed an empty later attempt under a pinned snapshot: %v", err)
+ }
+ // Over an evidenced empty P the same later attempt is the ordinary
+ // empty cold admission.
+ e := newROFixture(t, roOptions{empty: true})
+ ein, err := e.inputs.Select(ctx, e.route, e.read(), roProbe)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eopened := e.admit(e.read(), ein)
+ again, err := e.inputs.Select(ctx, e.route, eopened, roProbe)
+ if err != nil || !again.Empty || again.Baseline != nil || again.Admission.KeepStamp {
+ t.Fatalf("a later attempt over an empty P: %+v %v", again, err)
+ }
+ if err := e.inputs.EmptyAuthority(ctx, eopened, again); err != nil {
+ t.Fatalf("the empty later attempt's authority: %v", err)
+ }
+ if err := e.inputs.AdmissionEvidence(ctx, eopened, again.Admission, nil); err != nil {
+ t.Fatalf("the empty later attempt's evidence: %v", err)
+ }
+}
+
+// Absence is never evidence: P opens empty only on the pin's word
+// (RecoveryPin.Empty), which the prepared authority must agree with. A
+// missing install manifest alone, an authority with a manifest under an
+// empty pin, or an unreadable pin, refuses at Select and at the probe
+// attempt, before any admission.
+func TestRelocationEmptyPNeedsThePinAndTheAuthorityToAgree(t *testing.T) {
+ ctx := t.Context()
+ admit := func(f *roFixture) (func(context.Context, hostclaim.Observed, bool) (uint64, bool, error), *relocationProbeActivator) {
+ act := &relocationProbeActivator{}
+ return ClaimAssembly{}.relocationAdmit(RelocationAssemblyConfig{Activator: act, Versions: f.bucket, Bucket: "data"}), act
+ }
+ t.Run("no manifest under a non-empty pin", func(t *testing.T) {
+ f := newROFixture(t, roOptions{})
+ o := f.authority(func(a *hostclaim.PreparedAuthority) { a.InstallManifest, a.SHA256, a.SnapshotTXID = "", "", 0 })
+ if in, err := f.inputs.Select(ctx, f.route, o, roProbe); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("selected empty=%v: %v", in.Empty, err)
+ }
+ probe, act := admit(f)
+ if _, _, err := probe(ctx, o, true); !errors.Is(err, hostbox.ErrChanged) || len(act.probes) != 0 {
+ t.Fatalf("the probe ran without P's manifest: %v (%d probes)", err, len(act.probes))
+ }
+ })
+ t.Run("a manifest under an empty pin", func(t *testing.T) {
+ f := newROFixture(t, roOptions{empty: true})
+ o := f.authority(func(a *hostclaim.PreparedAuthority) { a.InstallManifest = "somewhere/install.json" })
+ if _, err := f.inputs.Select(ctx, f.route, o, roProbe); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("selected: %v", err)
+ }
+ probe, act := admit(f)
+ if _, _, err := probe(ctx, o, true); !errors.Is(err, hostbox.ErrChanged) || len(act.probes) != 0 {
+ t.Fatalf("the probe ran: %v (%d probes)", err, len(act.probes))
+ }
+ })
+ t.Run("an unreadable pin", func(t *testing.T) {
+ f := newROFixture(t, roOptions{})
+ o := f.update(func(c *hostclaim.Claim) error { c.Op.Journal["pin"] = `{"empty":`; return nil })
+ if _, err := f.inputs.Select(ctx, f.route, o, roProbe); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("selected: %v", err)
+ }
+ probe, act := admit(f)
+ if _, _, err := probe(ctx, o, true); !errors.Is(err, hostbox.ErrChanged) || len(act.probes) != 0 {
+ t.Fatalf("the probe ran: %v (%d probes)", err, len(act.probes))
+ }
+ })
+}
diff --git a/internal/host/relocationwake.go b/internal/host/relocationwake.go
index d13df9d55..57c2a5fdf 100644
--- a/internal/host/relocationwake.go
+++ b/internal/host/relocationwake.go
@@ -93,6 +93,46 @@ func preparedAuthority(c *hostclaim.Claim) (hostclaim.PreparedAuthority, error)
return auth, nil
}
+// journaledRelease is the operation's journaled release: absent, or
+// present and naming its releasing box. A present entry that does not
+// parse, or names no box, is an error, never "no release": an unreadable
+// entry is not absence, and absence is never evidence (Global Constraint).
+// Without this, a damaged release on a claim whose source is lost and
+// fenced would open P as a failover, uncertified, and declare the
+// source's writes lost.
+func journaledRelease(c *hostclaim.Claim) (hostclaim.Release, bool, error) {
+ raw, ok := c.Op.Journal["release"]
+ if !ok {
+ return hostclaim.Release{}, false, nil
+ }
+ var rel hostclaim.Release
+ if json.Unmarshal([]byte(raw), &rel) != nil || rel.ReleasedBy == "" {
+ return hostclaim.Release{}, false, fmt.Errorf("%w: the journaled release is unreadable", hostbox.ErrChanged)
+ }
+ return rel, true, nil
+}
+
+// emptyPrepared reports whether P is the evidenced empty bootstrap: the
+// journaled pin says so (RecoveryPin.Empty, which the pinner admits only
+// for a release reporting final_txid 0 or a holder with no writer) and the
+// prepared authority carries no install manifest. The two must agree: an
+// unreadable pin, or a pin and an authority that disagree, is an error,
+// never an empty P. A missing install manifest alone is not evidence of
+// an empty database, and a later attempt's empty listing is legitimate
+// only over an empty P (a pinned snapshot lives in the location, so an
+// empty listing under a non-empty pin is a wrong bucket or a listing not
+// yet visible).
+func emptyPrepared(c *hostclaim.Claim, auth hostclaim.PreparedAuthority) (bool, error) {
+ var ref hostclaim.RecoveryPin
+ if json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref) != nil {
+ return false, fmt.Errorf("%w: the journaled pin is unreadable", hostbox.ErrChanged)
+ }
+ if ref.Empty != (auth.InstallManifest == "") {
+ return false, fmt.Errorf("%w: the pin and the prepared authority disagree on an empty P", hostbox.ErrChanged)
+ }
+ return ref.Empty, nil
+}
+
// Select (§5.8 "The probe's admission"): the first attempt opens P with
// KeepStamp, certified only by this operation's verified release (C4);
// it is a prepared admission (Prepared, main's pre-replication marker)
@@ -125,12 +165,12 @@ func (r *RelocationWakeInputs) Select(ctx context.Context, rt registry.Route, o
adm.PredecessorRegion = h.Admissions[n-1].Region
}
if !hostclaim.PreparedOpened(c) {
- certified := hostrelocate.CertifiedFor(c)
+ rel, released, err := journaledRelease(c)
+ if err != nil {
+ return ClaimLaunchInputs{}, err
+ }
+ certified := released && hostrelocate.CertifiedFor(c)
if certified {
- var rel hostclaim.Release
- if json.Unmarshal([]byte(c.Op.Journal["release"]), &rel) != nil {
- return ClaimLaunchInputs{}, hostbox.ErrChanged
- }
if err := r.Releases.VerifyRelease(ctx, *c, rel); err != nil {
return ClaimLaunchInputs{}, err
}
@@ -139,8 +179,12 @@ func (r *RelocationWakeInputs) Select(ctx context.Context, rt registry.Route, o
if err != nil {
return ClaimLaunchInputs{}, err
}
+ empty, err := emptyPrepared(c, auth)
+ if err != nil {
+ return ClaimLaunchInputs{}, err
+ }
adm.NewGeneration, adm.KeepStamp, adm.PredecessorCertified = prepared, true, certified
- if auth.InstallManifest == "" {
+ if empty {
// An evidenced empty pin opened as P: no snapshot, so no root
// until the first transaction; main's fenced publisher waits
// for it (ErrClaimRootPending).
@@ -168,6 +212,20 @@ func (r *RelocationWakeInputs) Select(ctx context.Context, rt registry.Route, o
plan, err := genselect.Select(ctx, r.Data, r.request(o))
switch {
case errors.Is(err, genselect.ErrEmpty):
+ // Nothing under the location is an empty predecessor only over an
+ // empty P: a pinned snapshot is in the location, so an empty
+ // listing under a non-empty pin is never evidence.
+ auth, err := preparedAuthority(c)
+ if err != nil {
+ return ClaimLaunchInputs{}, err
+ }
+ empty, err := emptyPrepared(c, auth)
+ if err != nil {
+ return ClaimLaunchInputs{}, err
+ }
+ if !empty {
+ return ClaimLaunchInputs{}, fmt.Errorf("%w: the location lists nothing under a pinned snapshot", hostbox.ErrChanged)
+ }
in.Empty = true
case err != nil:
return ClaimLaunchInputs{}, err
@@ -295,13 +353,25 @@ func (r *RelocationWakeInputs) EmptyAuthority(ctx context.Context, o hostclaim.O
if !in.Empty || in.Baseline != nil || in.Prepared {
return hostbox.ErrChanged
}
+ auth, err := preparedAuthority(o.Claim)
+ if err != nil {
+ return err
+ }
+ empty, err := emptyPrepared(o.Claim, auth)
+ if err != nil {
+ return err
+ }
if in.Admission.NewGeneration == o.Claim.Op.Journal["prepared"] {
- var ref hostclaim.RecoveryPin
- if json.Unmarshal([]byte(o.Claim.Op.Journal["pin"]), &ref) != nil || !ref.Empty {
+ if !empty {
return fmt.Errorf("%w: no evidenced empty pin", hostrelocate.ErrRefused)
}
return nil
}
+ if !empty {
+ // A later attempt over a non-empty P always has a baseline: P's
+ // snapshot is in the location.
+ return fmt.Errorf("%w: an empty later attempt under a pinned snapshot", hostbox.ErrChanged)
+ }
versions, err := r.Data.ListVersions(ctx, r.Bucket, strings.TrimSuffix(o.Claim.Holder.Location, "/")+"/")
if err != nil {
return err
@@ -364,14 +434,21 @@ func (r *RelocationWakeInputs) AdmissionEvidence(ctx context.Context, o hostclai
return hostbox.ErrChanged
}
prepared := c.Op.Journal["prepared"]
+ auth, err := preparedAuthority(c)
+ if err != nil {
+ return err
+ }
+ empty, err := emptyPrepared(c, auth)
+ if err != nil {
+ return err
+ }
if in.NewGeneration == prepared {
+ if _, _, err := journaledRelease(c); err != nil {
+ return err
+ }
if !in.KeepStamp || hostclaim.PreparedOpened(c) || in.PredecessorCertified != hostrelocate.CertifiedFor(c) {
return hostbox.ErrChanged
}
- auth, err := preparedAuthority(c)
- if err != nil {
- return err
- }
raw, _, err := r.FilesStore.Get(ctx, hostfiles.ManifestKey(h.Location+"/files/"+prepared+"/"))
if err != nil {
return err
@@ -380,8 +457,7 @@ func (r *RelocationWakeInputs) AdmissionEvidence(ctx context.Context, o hostclai
return fmt.Errorf("%w: P's files manifest is not the prepared authority's", hostbox.ErrChanged)
}
if baseline == nil {
- var ref hostclaim.RecoveryPin
- if json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref) != nil || !ref.Empty || in.RemoteMaxTXID != 0 || auth.InstallManifest != "" {
+ if !empty || in.RemoteMaxTXID != 0 {
return hostbox.ErrChanged
}
return nil
@@ -395,7 +471,12 @@ func (r *RelocationWakeInputs) AdmissionEvidence(ctx context.Context, o hostclai
return hostbox.ErrChanged
}
if baseline == nil {
- return nil // an empty predecessor, proved by EmptyAuthority
+ // An empty predecessor, proved by EmptyAuthority, and possible
+ // only over an empty P.
+ if !empty || in.RemoteMaxTXID != 0 {
+ return hostbox.ErrChanged
+ }
+ return nil
}
fresh, err := genselect.Select(ctx, r.Data, r.request(o))
if err != nil {