host: a recovery's files baseline is the recorded source restore, src_gen (whole-branch fixes, item 3)
Establish walked the ordinary admission predecessor chain. A recovery admission records the target's last generation as its predecessor, in the other region, so the walk stopped at once; the fresh recovery generation had no manifest, and C8 refused with ErrNoEvidence although src_gen's manifest was valid in this bucket. The recovery database's source pin never reached the files provider. For a restoring or reverting op the chain now starts at the source's generation the claim journalled at the flip (recoverySourceGeneration: src_gen, the same pin the database restore uses), then its own admissions (movesFilesChainFrom). The origin proof still goes only through movesOriginProof with the bound observation: chainOriginProof proves src_gen from the claim's journal, and a proof that refuses it refuses the whole. A legacy source (src_gen "") names no files generation and refuses, never a bootstrap. Regressions: clean, declared-loss and fenced returns, and a revert, all establish the fresh generation from src_gen's manifest with the carried set src_gen plus the new generation; a proof refusing src_gen refuses; a legacy source refuses with ErrNoEvidence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+128
−4
internal/host/movesfiles.go+46 −4internal/host/movesfiles_test.go+82 −0
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go| index 521b1883e..bccf114a6 100644 |
| --- a/internal/host/movesfiles.go |
| +++ b/internal/host/movesfiles.go |
| @@ -332,10 +332,36 @@ var readFileBounded = func(path string, limit int64) ([]byte, bool, error) { |
| // manifest never references a source-region object (P6-D3). Warm |
| // admissions keep their generation and are passed over. |
| func movesFilesChain(h *hostclaim.Holder, home, region string) []string { |
| - if h == nil || !movesFilesGeneration(h.Generation) { |
| + if h == nil { |
| return nil |
| } |
| - cur := h.Generation |
| + return movesFilesChainFrom(h, h.Generation, home, region) |
| +} |
| + |
| +// recoverySourceGeneration is the generation a recovery's files baseline |
| +// starts from (whole-branch fixes, item 3): for a restoring or reverting |
| +// op, the source's generation the claim journalled at the flip (src_gen), |
| +// which the recovery's database restore is pinned to as well; "" names a |
| +// legacy source with no files generation. ok is false for any other op. |
| +func recoverySourceGeneration(c *hostclaim.Claim) (string, bool) { |
| + if c == nil { |
| + return "", false |
| + } |
| + switch c.Op.Kind { |
| + case hostclaim.KindRestoring, hostclaim.KindReverting: |
| + return c.Op.Journal[hostclaim.JournalSrcGen], true |
| + } |
| + return "", false |
| +} |
| + |
| +// movesFilesChainFrom is movesFilesChain started at generation start |
| +// instead of the holder's: start, then each cold admission's predecessor |
| +// in this region, as the admissions record them. |
| +func movesFilesChainFrom(h *hostclaim.Holder, start, home, region string) []string { |
| + if h == nil || !movesFilesGeneration(start) { |
| + return nil |
| + } |
| + cur := start |
| chain := []string{cur} |
| seen := map[string]bool{cur: true} |
| for i := len(h.Admissions) - 1; i >= 0; i-- { |
| @@ -438,7 +464,16 @@ func (p chainOriginProof) Proven(_ context.Context, o hostclaim.Observed, _ host |
| if generation == "" || o.Absent || o.Claim == nil || o.Claim.Holder == nil { |
| return false, nil |
| } |
| - return slices.Contains(movesFilesChain(o.Claim.Holder, p.home, p.region), generation), nil |
| + if slices.Contains(movesFilesChain(o.Claim.Holder, p.home, p.region), generation) { |
| + return true, nil |
| + } |
| + // A recovery's source generation (item 3): the claim journalled it at |
| + // the flip, and the recovery restores from it; the chain through its |
| + // own admissions follows. |
| + if src, ok := recoverySourceGeneration(o.Claim); ok && src != "" { |
| + return slices.Contains(movesFilesChainFrom(o.Claim.Holder, src, p.home, p.region), generation), nil |
| + } |
| + return false, nil |
| } |
| func (f *movesFiles) proof() movesOriginProof { |
| @@ -857,7 +892,14 @@ func (f *movesFiles) Establish(ctx context.Context, o hostclaim.Observed, in Cla |
| } |
| p.SetOrigins([]string{own}) |
| default: |
| - base, origins, found, err := f.newestManifest(ctx, o, in.Admission, h.Location, movesFilesChain(h, f.HomeRegion, f.Region)) |
| + // The chain: the holder's, or for a recovery the recorded source |
| + // restore's (src_gen; item 3), since the recovery admission's |
| + // predecessor is the target's generation in the other region. |
| + chain := movesFilesChain(h, f.HomeRegion, f.Region) |
| + if src, ok := recoverySourceGeneration(o.Claim); ok { |
| + chain = movesFilesChainFrom(h, src, f.HomeRegion, f.Region) |
| + } |
| + base, origins, found, err := f.newestManifest(ctx, o, in.Admission, h.Location, chain) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/host/movesfiles_test.go b/internal/host/movesfiles_test.go| index f86dccdac..ee3af098d 100644 |
| --- a/internal/host/movesfiles_test.go |
| +++ b/internal/host/movesfiles_test.go |
| @@ -1131,6 +1131,88 @@ func TestEstablishProvesAndCarriesOnlyReferencedOrigins(t *testing.T) { |
| } |
| } |
| +// recoveryObserved is the source box's observation after a recovery |
| +// admission: the holder at the fresh recovery generation R, admitted after |
| +// the target's admission T in the other region, under a restoring op (or |
| +// a reverting one) whose journal names the source's generation src_gen, |
| +// as the flip wrote it. |
| +func recoveryObserved(kind, srcGen, target, recovered string) hostclaim.Observed { |
| + o := servingObserved(recovered, "src:uk:@root:uk", target+":ie:src:uk", recovered+":uk:"+target+":ie") |
| + if srcGen == "" { |
| + o.Claim.Holder.Admissions = o.Claim.Holder.Admissions[1:] // a legacy source: no admission of its own |
| + } |
| + o.Claim.Op = hostclaim.Op{Kind: kind, ID: target, Phase: hostclaim.PhaseProbing, Exec: "uk-box", Participants: []string{"uk-box", "ie-box"}, |
| + Intent: map[string]string{"to": "ie", "source": "uk-box", "target": "ie-box", "rollback": "rb1"}, |
| + Journal: map[string]string{hostclaim.JournalSrcGen: srcGen, hostclaim.JournalSourceStamp: "15", hostclaim.JournalCutSession: "3"}} |
| + return o |
| +} |
| + |
| +// A recovery admission's files baseline is the recorded source restore |
| +// (whole-branch fixes, item 3): for a restoring or reverting op the chain |
| +// walk starts at the journalled src_gen, pinned as the database's source |
| +// restore is, not at the admission's predecessor, which is the target's |
| +// generation in the other region and ends the ordinary walk at once. The |
| +// origin proof still goes only through movesOriginProof with the bound |
| +// observation: src_gen is proven by the claim's journal, and a proof that |
| +// refuses it refuses the whole. Clean, declared-loss and fenced returns, |
| +// and a revert, all establish from src_gen's manifest; a legacy source |
| +// (src_gen "") has no files generation and refuses. |
| +func TestRecoveryEstablishesFromTheRecordedSourceGeneration(t *testing.T) { |
| + ctx := context.Background() |
| + for name, shape := range map[string]func(o *hostclaim.Observed){ |
| + "a clean return": func(*hostclaim.Observed) {}, |
| + "a declared-loss return": func(o *hostclaim.Observed) { |
| + o.Claim.Op.Journal["loss.l1"] = "1" |
| + o.Claim.LossRefs = []string{"control/loss-records/l1.json"} |
| + }, |
| + "a fenced return": func(o *hostclaim.Observed) { |
| + o.Claim.Op.Journal["fence.ie-box"] = `{"enrollment":"enr","key":"control/box-fences/enr.json","sha256":"ab"}` |
| + }, |
| + "a revert": func(o *hostclaim.Observed) { |
| + o.Claim.Op.Kind = hostclaim.KindReverting |
| + delete(o.Claim.Op.Intent, "rollback") |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + x := newFilesFixture(t) |
| + x.writeEnv("K=1\n") |
| + src := x.publish("src", func(p *hostfiles.Publisher) { |
| + if err := p.Stage(ctx, "a.txt", []byte("A"), 0o600, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + o := recoveryObserved(hostclaim.KindRestoring, "src", "mv1", "r1") |
| + shape(&o) |
| + if err := x.f.Establish(ctx, o, launchInputs("r1"), x); err != nil { |
| + t.Fatalf("establish from src_gen: %v", err) |
| + } |
| + m, found := x.manifest("r1") |
| + if !found || m.Entries["a.txt"].Object != src.Entries["a.txt"].Object || !reflect.DeepEqual(m.Origins, []string{movesFilesPrefix(filesLoc, "r1"), movesFilesPrefix(filesLoc, "src")}) { |
| + t.Fatalf("r1: %+v %v", m, found) |
| + } |
| + // The proof has the final say over src_gen. |
| + y := newFilesFixture(t) |
| + y.writeEnv("K=1\n") |
| + y.publish("src", func(p *hostfiles.Publisher) { |
| + if err := p.Stage(ctx, "a.txt", []byte("A"), 0o600, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + y.f.Proof = answeringProof{proven: map[string]bool{"r1": true}} |
| + if err := y.f.Establish(ctx, o, launchInputs("r1"), y); !errors.Is(err, hostfiles.ErrOrigin) { |
| + t.Fatalf("src_gen the proof refuses: %v", err) |
| + } |
| + }) |
| + } |
| + // A legacy source: src_gen "" names no files generation; nothing to |
| + // establish from, and never a bootstrap. |
| + x := newFilesFixture(t) |
| + x.writeEnv("K=1\n") |
| + if err := x.f.Establish(ctx, recoveryObserved(hostclaim.KindRestoring, "", "mv1", "r1"), launchInputs("r1"), x); !errors.Is(err, hostfiles.ErrNoEvidence) { |
| + t.Fatalf("a legacy source: %v", err) |
| + } |
| +} |
| + |
| // The instance key rule (O1'): the manifest Establish leaves carries the |
| // env file with exactly the bytes on disk when it runs, staged and |
| // committed before it returns, and no adoption item; the local adoption |