host: src_gen only bridges a recovery with no manifest of its own; later admissions follow their lineage (whole-branch fixes, item 3 re-ruled; platform-1e P1)
Every non-prepared restoring or reverting wake replaced the chain with the src_gen chain, so newer recovery manifests were ignored: a dirty return's second cold admission published the stopped source's a.txt=A over the rollback manifest's B (or ErrNoEvidence with the source manifest absent), and a clean retry after publishing its own root took the source's again. The holder's own chain now comes first and the newest authorized manifest through it wins (the prepared rollback generation's, a later recovery publication's, a revert's own lineage), through the normal chain and origin proof. src_gen bridges only a recovery that restores the recorded source baseline (restoresSourceBaseline: a restoring op with no return seal journalled, so never a dirty return) and only while its lineage holds no manifest, mirroring the database selector's not-rooted case. Real-C8 restart regressions: a restart after the prepared rollback manifest publishes B, with the source present or absent; a dirty return's second and third admissions follow the rollback generation and its certified descendant; a dirty return with no manifest in its lineage refuses rather than bridging; a clean return and a revert after a newer recovery publication take the newer one; a first admission with no recovery manifest still bridges from src_gen. The no-mint rule is pinned: a recovery probe (dirty and clean) through the real C8 keeps the restored env byte for byte, mints no CARLOS_INSTANCE_KEY, and the generation's env item is that env.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+220
−8
internal/host/movesfiles.go+33 −8internal/host/movesfiles_test.go+130 −0internal/host/movesrecoveryowners_test.go+57 −0
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go| index bccf114a6..d694da7b3 100644 |
| --- a/internal/host/movesfiles.go |
| +++ b/internal/host/movesfiles.go |
| @@ -354,6 +354,22 @@ func recoverySourceGeneration(c *hostclaim.Claim) (string, bool) { |
| return "", false |
| } |
| +// restoresSourceBaseline reports whether a recovery admission restores the |
| +// recorded source baseline (platform-1e's framing of item 3): a restoring |
| +// op that is not a dirty return (a dirty return journals its return seal |
| +// and restores the rollback generation or a certified descendant, never |
| +// the stopped source), with a journalled src_gen. The files bridge from |
| +// src_gen applies only to these, and only while their recovery lineage |
| +// holds no manifest (the database selector's "not rooted"); every other |
| +// admission follows its recovery lineage. |
| +func restoresSourceBaseline(c *hostclaim.Claim) bool { |
| + if c == nil || c.Op.Kind != hostclaim.KindRestoring || c.Op.Journal[moves.JournalReturnSeal] != "" { |
| + return false |
| + } |
| + src, ok := recoverySourceGeneration(c) |
| + return ok && src != "" |
| +} |
| + |
| // 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. |
| @@ -892,17 +908,26 @@ func (f *movesFiles) Establish(ctx context.Context, o hostclaim.Observed, in Cla |
| } |
| p.SetOrigins([]string{own}) |
| default: |
| - // 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) |
| + // The chain is the holder's: the newest authorized manifest through |
| + // it wins, a recovery's own included (the prepared rollback |
| + // generation's, or a later recovery publication's). Only when that |
| + // chain holds no manifest, the first recovery admission, whose |
| + // predecessor is the target's generation in the other region, does |
| + // src_gen bridge the missing source baseline (item 3 as re-ruled; |
| + // the database selector's distinction, movesrecoveryowners.go |
| + // baseline: the recorded source restore only while the recovery has |
| + // no root of its own). |
| + base, origins, found, err := f.newestManifest(ctx, o, in.Admission, h.Location, movesFilesChain(h, f.HomeRegion, f.Region)) |
| if err != nil { |
| return err |
| } |
| + if !found && restoresSourceBaseline(o.Claim) { |
| + src, _ := recoverySourceGeneration(o.Claim) |
| + base, origins, found, err = f.newestManifest(ctx, o, in.Admission, h.Location, movesFilesChainFrom(h, src, f.HomeRegion, f.Region)) |
| + if err != nil { |
| + return err |
| + } |
| + } |
| if !found { |
| never := false |
| if f.Evidence != nil { |
diff --git a/internal/host/movesfiles_test.go b/internal/host/movesfiles_test.go| index ee3af098d..1d464a9e1 100644 |
| --- a/internal/host/movesfiles_test.go |
| +++ b/internal/host/movesfiles_test.go |
| @@ -1169,8 +1169,13 @@ func TestRecoveryEstablishesFromTheRecordedSourceGeneration(t *testing.T) { |
| o.Claim.Op.Journal["fence.ie-box"] = `{"enrollment":"enr","key":"control/box-fences/enr.json","sha256":"ab"}` |
| }, |
| "a revert": func(o *hostclaim.Observed) { |
| + // A revert's holder never left the source: its admission's |
| + // predecessor is src in this region, so it follows its own |
| + // lineage with no bridge (platform-1e's framing). |
| o.Claim.Op.Kind = hostclaim.KindReverting |
| delete(o.Claim.Op.Intent, "rollback") |
| + o.Claim.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "src", Region: "uk", Predecessor: "@root", PredecessorRegion: "uk", Mode: "cold", Session: 3, Box: "uk-box"}, |
| + {Generation: "r1", Region: "uk", Predecessor: "src", PredecessorRegion: "uk", Mode: "cold", Session: 3, Box: "uk-box"}} |
| }, |
| } { |
| t.Run(name, func(t *testing.T) { |
| @@ -1213,6 +1218,131 @@ func TestRecoveryEstablishesFromTheRecordedSourceGeneration(t *testing.T) { |
| } |
| } |
| +// recoveryRestartObserved is the source box's observation after a later |
| +// recovery admission: the holder at the fresh generation `next`, admitted |
| +// after the recovery generation `prev` in this region (the prepared |
| +// rollback generation, or an earlier recovery admission), itself admitted |
| +// after the target's T in the other region. |
| +func recoveryRestartObserved(kind, prev, next string) hostclaim.Observed { |
| + o := servingObserved(next, "src:uk:@root:uk", "mv1:ie:src:uk", prev+":uk:mv1:ie", next+":uk:"+prev+":uk") |
| + o.Claim.Op = hostclaim.Op{Kind: kind, ID: "mv1", 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: "src", hostclaim.JournalSourceStamp: "15", hostclaim.JournalCutSession: "3"}} |
| + if kind == hostclaim.KindReverting { |
| + delete(o.Claim.Op.Intent, "rollback") |
| + } |
| + return o |
| +} |
| + |
| +// src_gen only bridges the missing source baseline (the re-review's |
| +// ruling on item 3, mirroring the database selector): once a recovery |
| +// manifest exists, the prepared rollback generation's or a later recovery |
| +// publication's, the newest authorized recovery manifest wins through the |
| +// normal chain and origin proof. A restart after the dirty return's |
| +// prepared rollback manifest publishes the rollback's contents (B), not |
| +// the source's (A), even with the source manifest absent; a clean return |
| +// and a revert after a newer recovery publication take the newer one; a |
| +// first admission with no recovery manifest still bridges from src_gen. |
| +func TestRecoveryRestartTakesTheNewestRecoveryManifest(t *testing.T) { |
| + ctx := context.Background() |
| + publish := func(x *filesFixture, gen, body string) hostfiles.Manifest { |
| + return x.publish(gen, func(p *hostfiles.Publisher) { |
| + if err := p.Stage(ctx, "a.txt", []byte(body), 0o600, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + } |
| + t.Run("a restart after the prepared rollback manifest publishes B", func(t *testing.T) { |
| + for name, withSource := range map[string]bool{"source present": true, "source absent": false} { |
| + t.Run(name, func(t *testing.T) { |
| + x := newFilesFixture(t) |
| + x.writeEnv("K=1\n") |
| + if withSource { |
| + publish(x, "src", "A") |
| + } |
| + rb := publish(x, "rb1", "B") |
| + o := recoveryRestartObserved(hostclaim.KindRestoring, "rb1", "r2") |
| + if err := x.f.Establish(ctx, o, launchInputs("r2"), x); err != nil { |
| + t.Fatalf("the restart: %v", err) |
| + } |
| + m, found := x.manifest("r2") |
| + if !found || m.Entries["a.txt"].Object != rb.Entries["a.txt"].Object { |
| + t.Fatalf("r2 took %+v, want the rollback's B (%s)", m.Entries["a.txt"], rb.Entries["a.txt"].Object) |
| + } |
| + }) |
| + } |
| + }) |
| + for name, kind := range map[string]string{"a clean return": hostclaim.KindRestoring, "a revert": hostclaim.KindReverting} { |
| + t.Run(name+" after a newer recovery publication", func(t *testing.T) { |
| + x := newFilesFixture(t) |
| + x.writeEnv("K=1\n") |
| + publish(x, "src", "A") |
| + newer := publish(x, "r1", "C") // the earlier recovery admission's publication |
| + o := recoveryRestartObserved(kind, "r1", "r2") |
| + if err := x.f.Establish(ctx, o, launchInputs("r2"), x); err != nil { |
| + t.Fatalf("the restart: %v", err) |
| + } |
| + m, found := x.manifest("r2") |
| + if !found || m.Entries["a.txt"].Object != newer.Entries["a.txt"].Object { |
| + t.Fatalf("r2 took %+v, want the newer recovery's C", m.Entries["a.txt"]) |
| + } |
| + }) |
| + } |
| + t.Run("a dirty return's later admissions follow the rollback lineage", func(t *testing.T) { |
| + // The second cold admission restores from the rollback generation, |
| + // the third from its certified descendant (the second's publication). |
| + x := newFilesFixture(t) |
| + x.writeEnv("K=1\n") |
| + publish(x, "src", "A") |
| + rb := publish(x, "rb1", "B") |
| + o := recoveryRestartObserved(hostclaim.KindRestoring, "rb1", "r2") |
| + o.Claim.Op.Journal[moves.JournalReturnSeal] = "sealed" |
| + if err := x.f.Establish(ctx, o, launchInputs("r2"), x); err != nil { |
| + t.Fatalf("the second admission: %v", err) |
| + } |
| + if m, _ := x.manifest("r2"); m.Entries["a.txt"].Object != rb.Entries["a.txt"].Object { |
| + t.Fatalf("r2 took %+v, want the rollback's B", m.Entries["a.txt"]) |
| + } |
| + r2 := publish(x, "r2", "D") // the second admission's own publication |
| + o3 := servingObserved("r3", "src:uk:@root:uk", "mv1:ie:src:uk", "rb1:uk:mv1:ie", "r2:uk:rb1:uk", "r3:uk:r2:uk") |
| + o3.Claim.Op = o.Claim.Op |
| + if err := x.f.Establish(ctx, o3, launchInputs("r3"), x); err != nil { |
| + t.Fatalf("the third admission: %v", err) |
| + } |
| + if m, _ := x.manifest("r3"); m.Entries["a.txt"].Object != r2.Entries["a.txt"].Object { |
| + t.Fatalf("r3 took %+v, want the descendant's D", m.Entries["a.txt"]) |
| + } |
| + }) |
| + t.Run("a dirty return never bridges to the source", func(t *testing.T) { |
| + // A dirty return restores the rollback generation, never the stopped |
| + // source: with no manifest in its lineage it refuses rather than |
| + // publishing the source's older files. |
| + x := newFilesFixture(t) |
| + x.writeEnv("K=1\n") |
| + publish(x, "src", "A") |
| + o := recoveryRestartObserved(hostclaim.KindRestoring, "rb1", "r2") |
| + o.Claim.Op.Journal[moves.JournalReturnSeal] = "sealed" |
| + if err := x.f.Establish(ctx, o, launchInputs("r2"), x); !errors.Is(err, hostfiles.ErrNoEvidence) { |
| + t.Fatalf("a dirty return without its rollback manifest: %v", err) |
| + } |
| + if _, found := x.manifest("r2"); found { |
| + t.Fatal("a dirty return published the source's files") |
| + } |
| + }) |
| + t.Run("a first admission with no recovery manifest bridges from src_gen", func(t *testing.T) { |
| + x := newFilesFixture(t) |
| + x.writeEnv("K=1\n") |
| + src := publish(x, "src", "A") |
| + o := recoveryObserved(hostclaim.KindRestoring, "src", "mv1", "r1") |
| + if err := x.f.Establish(ctx, o, launchInputs("r1"), x); err != nil { |
| + t.Fatalf("the first admission: %v", err) |
| + } |
| + if m, found := x.manifest("r1"); !found || m.Entries["a.txt"].Object != src.Entries["a.txt"].Object { |
| + t.Fatalf("r1 took %+v, want the source's A", m.Entries["a.txt"]) |
| + } |
| + }) |
| +} |
| + |
| // 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 |
diff --git a/internal/host/movesrecoveryowners_test.go b/internal/host/movesrecoveryowners_test.go| index 08bea426e..004ff67e2 100644 |
| --- a/internal/host/movesrecoveryowners_test.go |
| +++ b/internal/host/movesrecoveryowners_test.go |
| @@ -373,6 +373,63 @@ func TestDirtyReturnEstablishesThePreparedManifestThroughC8(t *testing.T) { |
| } |
| } |
| +// The no-mint rule (platform-1e, whole-branch fixes): a recovery probe |
| +// preserves the restored environment byte for byte and never mints a |
| +// CARLOS_INSTANCE_KEY; the real C8 then carries exactly those bytes as |
| +// the generation's env item. A later change that minted during recovery |
| +// fails here, loudly, rather than exempting <host>.env from verification. |
| +func TestRecoveryProbeNeverMintsAnInstanceKey(t *testing.T) { |
| + for _, kind := range []string{rcDirty, rcClean} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := newRCFixture(t, kind) |
| + ctx := t.Context() |
| + f.owners.Files = f.realC8() |
| + envPath := filepath.Join(f.dataDir, poHost+".env") |
| + restored, err := os.ReadFile(envPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Contains(string(restored), activator.InstanceKeyVar+"=") { |
| + t.Fatalf("the restored env already carries a key: %q", restored) |
| + } |
| + gen := "src" |
| + if kind == rcDirty { |
| + gen = rpRB |
| + } |
| + // The manifest the recovery establishes from: the source's, or the |
| + // dirty return's prepared one, built from the restored tree. |
| + pm := f.preparedManifestJournalled(gen, poOp, rcS) |
| + o := f.read() |
| + in := f.selectAdmission(o) |
| + admitted := f.admitThroughWriter(o, in) |
| + if kind == rcClean { |
| + // The restore placed the admitted generation's database (the |
| + // start check's, not C8's). |
| + if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", admitted.Claim.Holder.Generation); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := f.owners.Materialize(ctx, admitted, in, poWork{action: "spawn", f: f.poFixture}); err != nil { |
| + t.Fatalf("the recovery's materialization: %v", err) |
| + } |
| + after, err := os.ReadFile(envPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(after) != string(restored) { |
| + t.Fatalf("the recovery changed the restored env:\n%q\n%q", restored, after) |
| + } |
| + if strings.Contains(string(after), activator.InstanceKeyVar+"=") { |
| + t.Fatalf("the recovery minted a key: %q", after) |
| + } |
| + m, found, err := hostfiles.Load(ctx, casBucketStore{b: f.bucket, bucket: "data"}, movesFilesPrefix("instances/"+poHost, admitted.Claim.Holder.Generation)) |
| + if err != nil || !found || m.Platform[hostfiles.EnvItem].SHA256 != sha(restored) { |
| + t.Fatalf("the env item is not the restored env: %+v %v %v (prepared %s)", m.Platform[hostfiles.EnvItem], found, err, pm.Key) |
| + } |
| + }) |
| + } |
| +} |
| + |
| // Plan 4 Task 9: a clean return's probe is a cold admission into a fresh |
| // generation restored from src_gen in this region, its coverage |
| // clean-return: the admission records no writes lost. |