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>
4 files changed,
+324
−18
internal/host/relocationattempts_test.go+73 −0internal/host/relocationowners.go+13 −3internal/host/relocationowners_test.go+142 −0internal/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 { |