carlos / platform Public

host: the files-gen refresh and repair verify content (Plan 3 Task 7 fix round 3)

A fresh generation's publication records the files tree's plain digest
(archive.PlainDigest, the landing's measure) as its install record in
the op journal before the marker; the refresh requires the same digest
again, with the landing's placement, under the writer's fence. The
repair compares the tree with G's recorded digest (the journal's, or the
source's stopped baseline for the move id) and refuses without one:
never on inode identity alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3e78af9868495480b889d9e08b1c02ee65856047 parent b007f4e
3 files changed, +149 −11
  • internal/host/movesprobeowners.go +107 −11
  • internal/host/movesprobeowners_committed_test.go +38 −0
  • internal/host/movesprobeowners_test.go +4 −0
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go
index 7718d9350..512a1c360 100644
--- a/internal/host/movesprobeowners.go
+++ b/internal/host/movesprobeowners.go
@@ -941,8 +941,21 @@ func (m *movesProbeOwners) PublishFenced(ctx context.Context, o hostclaim.Observ
if work == nil {
return ClaimPublicationReceipt{}, fmt.Errorf("%w: no admitted writer's fence for session %d", hostbox.ErrChanged, h.Session)
}
- // The files on disk must be the landing's before anything is published.
- if err := work.With(ctx, in.Identity, func() error { return m.verifyFiles(o.Host, v) }); err != nil {
+ // The files on disk must be the landing's before anything is published,
+ // and their content is recorded as this generation's install record
+ // (fix round 3): the digest the refresh, and any later repair, compare.
+ var digest string
+ if err := work.With(ctx, in.Identity, func() error {
+ if err := m.verifyFiles(o.Host, v); err != nil {
+ return err
+ }
+ var err error
+ digest, err = m.filesDigest(o.Host)
+ return err
+ }); err != nil {
+ return ClaimPublicationReceipt{}, err
+ }
+ if err := m.recordFilesDigest(o.Host, v.op, h.Generation, digest); err != nil {
return ClaimPublicationReceipt{}, err
}
if err := pub.Sync(ctx); err != nil {
@@ -969,7 +982,7 @@ func (m *movesProbeOwners) PublishFenced(ctx context.Context, o hostclaim.Observ
return ClaimPublicationReceipt{}, hostbox.ErrChanged
}
receipt := ClaimPublicationReceipt{MarkerVersion: current.ID, Root: root}
- if err := m.refreshFilesGen(ctx, o.Host, v, work, in.Identity); err != nil {
+ if err := m.refreshFilesGen(ctx, o.Host, v, work, in.Identity, digest); err != nil {
return ClaimPublicationReceipt{}, err
}
m.record(o.Host, h.Session, receipt)
@@ -994,7 +1007,7 @@ func (m *movesProbeOwners) PublishFenced(ctx context.Context, o hostclaim.Observ
return ClaimPublicationReceipt{}, hostwrite.ErrUnavailable
}
receipt := ClaimPublicationReceipt{MarkerVersion: version, Root: root}
- if err := m.refreshFilesGen(ctx, o.Host, v, work, in.Identity); err != nil {
+ if err := m.refreshFilesGen(ctx, o.Host, v, work, in.Identity, digest); err != nil {
return ClaimPublicationReceipt{}, err
}
m.record(o.Host, h.Session, receipt)
@@ -1044,7 +1057,7 @@ func (m *movesProbeOwners) verifyFiles(host string, v probeLanding) error {
// published. Task 4's start check is unchanged: the next admission's cold
// predecessor is this generation. <dbPath>.gen needs no refresh: every cold
// restore rewrites it to the admitted generation (H11).
-func (m *movesProbeOwners) refreshFilesGen(ctx context.Context, host string, v probeLanding, work hostwrite.Work, id hostwrite.Identity) error {
+func (m *movesProbeOwners) refreshFilesGen(ctx context.Context, host string, v probeLanding, work hostwrite.Work, id hostwrite.Identity, digest string) error {
h := v.holder
marker, err := json.Marshal(moves.FilesGen{Op: h.Generation, Incarnation: h.Incarnation})
if err != nil {
@@ -1052,7 +1065,7 @@ func (m *movesProbeOwners) refreshFilesGen(ctx context.Context, host string, v p
}
dir, _ := m.FilesDir(host)
return work.With(ctx, id, func() error {
- if err := m.verifyFiles(host, v); err != nil {
+ if err := m.verifyFilesContent(host, v, digest); err != nil {
return err
}
if err := syncPath(dir); err != nil {
@@ -1062,6 +1075,84 @@ func (m *movesProbeOwners) refreshFilesGen(ctx context.Context, host string, v p
})
}
+// filesPlainStep is the op journal step a published generation's files
+// digest is recorded under: its install record (fix round 3).
+const filesPlainStep = "files_plain_"
+
+// filesDigest is the files tree's plain digest, the landing's own measure
+// (archive.PlainDigest over the files directory and <host>.env; .files-gen
+// is not part of it). The tree is capped at 256 MiB.
+func (m *movesProbeOwners) filesDigest(host string) (string, error) {
+ dir, env := m.FilesDir(host)
+ plain, _, _, err := archivePlainDigest(dir, env)
+ if err != nil {
+ return "", err
+ }
+ if plain == "" {
+ return "", fmt.Errorf("%w: the files tree has no digest", hostbox.ErrChanged)
+ }
+ return plain, nil
+}
+
+// recordFilesDigest journals generation g's files digest, the install record
+// a later repair compares. A different digest already recorded for g is a
+// conflict.
+func (m *movesProbeOwners) recordFilesDigest(host, op, g, digest string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ j, err := moves.ReadJournal(m.DataDir, host, op)
+ if err != nil {
+ return err
+ }
+ if have, ok := j.Steps[filesPlainStep+g]; ok {
+ if have != digest {
+ return fmt.Errorf("%w: generation %s recorded other files", hostbox.ErrChanged, g)
+ }
+ return nil
+ }
+ j.Mark(filesPlainStep+g, digest)
+ return j.Write(m.DataDir)
+}
+
+// recordedFilesDigest is generation g's recorded files digest: the move id's
+// is the source's stopped baseline the landing installed (Move.Source.Files);
+// a later generation's is the one its publication journalled. false when
+// none is recorded.
+func (m *movesProbeOwners) recordedFilesDigest(host string, v probeLanding, g string) (string, bool) {
+ if g == v.op {
+ src := v.rec.Move.Source
+ if src == nil || src.Files == nil || src.Files.Plain == "" {
+ return "", false
+ }
+ return src.Files.Plain, true
+ }
+ j, err := moves.ReadJournal(m.DataDir, host, v.op)
+ if err != nil {
+ return "", false
+ }
+ d, ok := j.Steps[filesPlainStep+g]
+ return d, ok && d != ""
+}
+
+// verifyFilesContent requires both the landing's placement (identity) and
+// the recorded content (the plain digest): never identity alone.
+func (m *movesProbeOwners) verifyFilesContent(host string, v probeLanding, want string) error {
+ if want == "" {
+ return fmt.Errorf("%w: no recorded files digest", hostbox.ErrChanged)
+ }
+ if err := m.verifyFiles(host, v); err != nil {
+ return err
+ }
+ got, err := m.filesDigest(host)
+ if err != nil {
+ return err
+ }
+ if got != want {
+ return fmt.Errorf("%w: the files on disk are not the recorded ones", hostbox.ErrChanged)
+ }
+ return nil
+}
+
func (m *movesProbeOwners) record(host string, session uint64, receipt ClaimPublicationReceipt) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -1302,9 +1393,10 @@ func pinnedLaunchIdentity(in *provision.MoveInputs) hostwrite.Identity {
// before the start check, inside the admitted writer's fence: when the
// location's published marker names G, G is the admitted generation's cold
// predecessor (Task 4's rule), the local .files-gen names an older generation
-// of G's own ancestry, and the files on disk are proved the landing's (the
-// check publication runs), .files-gen is moved to G. Anything else is left
-// alone, for the unchanged start check to decide.
+// of G's own ancestry, and the files on disk are proved G's (the landing's
+// placement and, fix round 3, the content: the plain digest G's install
+// record holds), .files-gen is moved to G. No recorded digest, or anything
+// else, is left alone, for the unchanged start check to decide.
func (m *movesProbeOwners) repairFilesGen(ctx context.Context, o hostclaim.Observed, v probeLanding, work hostwrite.Work, id hostwrite.Identity) error {
h := o.Claim.Holder
marker, err := m.readMarker(ctx, h)
@@ -1323,13 +1415,17 @@ func (m *movesProbeOwners) repairFilesGen(ctx context.Context, o hostclaim.Obser
if err != nil || files == nil || files.Op == g || files.Incarnation != h.Incarnation || !ancestorOf(h, g, files.Op) {
return nil
}
+ want, ok := m.recordedFilesDigest(o.Host, v, g)
+ if !ok {
+ return nil // nothing recorded for G: the start check decides
+ }
body, err := json.Marshal(moves.FilesGen{Op: g, Incarnation: h.Incarnation})
if err != nil {
return err
}
return work.With(ctx, id, func() error {
- if m.verifyFiles(o.Host, v) != nil {
- return nil // not the landing's files: the start check decides
+ if m.verifyFilesContent(o.Host, v, want) != nil {
+ return nil // not G's files: the start check decides
}
if err := syncPath(dir); err != nil {
return err
diff --git a/internal/host/movesprobeowners_committed_test.go b/internal/host/movesprobeowners_committed_test.go
index 1ad06cf46..a7cf045a4 100644
--- a/internal/host/movesprobeowners_committed_test.go
+++ b/internal/host/movesprobeowners_committed_test.go
@@ -404,3 +404,41 @@ func TestMovesProbeOwnersCommittedWakeJudgesTheRealLaunch(t *testing.T) {
func committedPinsIdentity(p *provision.MoveInputs) hostwrite.Identity {
return hostwrite.Identity{Config: committedEnvPrefix + p.EnvReport, Release: p.Release}
}
+
+// Task 7 fix round 3: the repair verifies content, never the directory's
+// identity alone. With g2's publication interrupted before its refresh, a
+// file modified, deleted or added inside the original directory (the same
+// inode) refuses the repair: .files-gen stays g1 and the start check
+// refuses. Matching contents still recover (TestMovesProbeOwnersRepairAnInterruptedFilesGenRefresh).
+func TestMovesProbeOwnersRepairVerifiesTheFilesContent(t *testing.T) {
+ for name, change := range map[string]func(dir string) error{
+ "a modified file": func(dir string) error { return os.WriteFile(filepath.Join(dir, "a.txt"), []byte("stale"), 0o600) },
+ "a deleted file": func(dir string) error { return os.Remove(filepath.Join(dir, "a.txt")) },
+ "an added file": func(dir string) error { return os.WriteFile(filepath.Join(dir, "b.txt"), []byte("new"), 0o600) },
+ } {
+ t.Run(name, func(t *testing.T) {
+ f, restarted, again := restartedProbePublished(t)
+ if err := f.publishFresh(restarted, again); err != nil {
+ t.Fatal(err)
+ }
+ g1 := restarted.Claim.Holder.Generation
+ w1, in1, err := f.committedWake(f.commit())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := f.publishFresh(w1, in1); err != nil {
+ t.Fatal(err)
+ }
+ f.writeFilesGenOnly(g1)
+ if err := change(f.files); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := f.committedWake(f.read()); !errors.Is(err, moves.ErrStartFiles) {
+ t.Fatalf("a repair over changed contents: %v", err)
+ }
+ if fg := f.filesGen(); fg.Op != g1 {
+ t.Fatalf(".files-gen was repaired over changed contents: %+v", fg)
+ }
+ })
+ }
+}
diff --git a/internal/host/movesprobeowners_test.go b/internal/host/movesprobeowners_test.go
index 0cbc8bc8c..815f399cb 100644
--- a/internal/host/movesprobeowners_test.go
+++ b/internal/host/movesprobeowners_test.go
@@ -114,6 +114,10 @@ func newPOFixture(t *testing.T) *poFixture {
j.Mark(landStepInstall, string(body))
// The install receipt the landing journals: the files directory and
// <host>.env, placed (moveslandinstall.go).
+ // The files the landing extracted (fix round 3 changes them in place).
+ if err := os.WriteFile(filepath.Join(f.files, "a.txt"), []byte("the landing's file"), 0o600); err != nil {
+ t.Fatal(err)
+ }
envPath := filepath.Join(f.dataDir, poHost+".env")
if err := os.WriteFile(envPath, []byte("CARLOS_ADMIN_TOKEN=t\n"), 0o600); err != nil {
t.Fatal(err)