carlos / platform Public

moves: the recovery relabel is vouched for by the journalled placement digest (Task 3 fix 2, item 3)

PublishFenced no longer samples a digest at the publication. When the
publication will move .files-gen (it names the generation's cold
predecessor for this incarnation), the tree must hold the placement
digest journalled before the recovery's first start, checked under the
fence before the publication begins and again immediately before the
relabel; the placement identity is checked always. A file changed after
Materialize and before the publication is refused and keeps its label.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 31d5b5c685af2fcec13707508dacac52708e8993 parent 105d95c
2 files changed, +47 −28
  • internal/host/movesrecoveryowners.go +30 −22
  • internal/host/movesrecoveryowners_test.go +17 −6
diff --git a/internal/host/movesrecoveryowners.go b/internal/host/movesrecoveryowners.go
index ec5eadfc3..c5f103503 100644
--- a/internal/host/movesrecoveryowners.go
+++ b/internal/host/movesrecoveryowners.go
@@ -585,8 +585,9 @@ func (r movesRecoveryOwners) PublishFenced(ctx context.Context, o hostclaim.Obse
return ClaimPublicationReceipt{}, fmt.Errorf("%w: no admitted writer's fence for session %d", hostbox.ErrChanged, h.Session)
}
// The files on disk must be the tree the recovery placed before anything
- // is published, and their content is recorded as this generation's (the
- // landing's fix round 3 rule): the digest the relabel compares.
+ // is published, and a tree whose label the publication will move must
+ // still hold the content journalled when it was placed (fix round 2):
+ // the relabel is vouched for by that digest, never one sampled now.
placed, ok, err := r.placement(o.Host, v)
if err != nil {
return ClaimPublicationReceipt{}, err
@@ -594,45 +595,52 @@ func (r movesRecoveryOwners) PublishFenced(ctx context.Context, o hostclaim.Obse
if !ok {
return ClaimPublicationReceipt{}, fmt.Errorf("%w: the recovery recorded no placed tree", hostbox.ErrChanged)
}
- var digest string
if err := work.With(ctx, in.Identity, func() error {
- if err := r.verifyPlaced(o.Host, placed); err != nil {
- return err
- }
- var err error
- digest, err = m.filesDigest(o.Host)
+ _, err := r.vouchFiles(o.Host, h, placed)
return err
}); err != nil {
return ClaimPublicationReceipt{}, err
}
- if err := m.recordFilesDigest(o.Host, v.journalOp(), h.Generation, digest); err != nil {
- return ClaimPublicationReceipt{}, err
+ return m.publishFresh(ctx, o.Host, h, pub, func() error { return r.refreshFilesGen(ctx, o.Host, h, placed, work, in.Identity) })
+}
+
+// vouchFiles requires the files on disk to be the placed tree (identity)
+// and reports whether .files-gen will move to holder.generation: it names
+// that generation's cold predecessor for this incarnation. When it will,
+// the tree must also hold the journalled placement digest. A label that
+// will not move needs no content evidence: nothing is relabelled.
+func (r movesRecoveryOwners) vouchFiles(host string, h *hostclaim.Holder, placed recoveryPlacement) (bool, error) {
+ m := r.m
+ if err := r.verifyPlaced(host, placed); err != nil {
+ return false, err
}
- return m.publishFresh(ctx, o.Host, h, pub, func() error { return r.refreshFilesGen(ctx, o.Host, h, placed, digest, work, in.Identity) })
+ dir, _ := m.FilesDir(host)
+ files, _, err := m.localMarkers(host, filepath.Join(dir, ".none"))
+ if err != nil || files == nil || files.Incarnation != h.Incarnation || files.Op == h.Generation {
+ return false, err
+ }
+ if pred, ok := coldPredecessor(h, h.Generation); !ok || pred == "" || files.Op != pred {
+ return false, nil
+ }
+ return true, r.verifyContent(host, placed, placed.Plain)
}
// refreshFilesGen points .files-gen at the published generation, inside the
// admitted writer's fence, after the files on disk are proved the tree the
-// recovery placed (its identity) with the content just recorded for the
-// generation (digest): never by identity alone, and a mismatch fails the
+// recovery placed, by identity and by the journalled placement digest
+// (vouchFiles): never by identity alone, and a mismatch fails the
// publication and relabels nothing. It relabels only a .files-gen naming the
// generation's cold predecessor for this incarnation (the files the start
// check accepted for this session); absent, or anything else, is left for
// the start check to decide.
-func (r movesRecoveryOwners) refreshFilesGen(ctx context.Context, host string, h *hostclaim.Holder, placed recoveryPlacement, digest string, work hostwrite.Work, id hostwrite.Identity) error {
+func (r movesRecoveryOwners) refreshFilesGen(ctx context.Context, host string, h *hostclaim.Holder, placed recoveryPlacement, work hostwrite.Work, id hostwrite.Identity) error {
m := r.m
dir, _ := m.FilesDir(host)
return work.With(ctx, id, func() error {
- if err := r.verifyContent(host, placed, digest); err != nil {
+ relabel, err := r.vouchFiles(host, h, placed)
+ if err != nil || !relabel {
return err
}
- files, _, err := m.localMarkers(host, filepath.Join(dir, ".none"))
- if err != nil || files == nil || files.Incarnation != h.Incarnation || files.Op == h.Generation {
- return err
- }
- if pred, ok := coldPredecessor(h, h.Generation); !ok || pred == "" || files.Op != pred {
- return nil
- }
body, err := json.Marshal(moves.FilesGen{Op: h.Generation, Incarnation: h.Incarnation})
if err != nil {
return err
diff --git a/internal/host/movesrecoveryowners_test.go b/internal/host/movesrecoveryowners_test.go
index 470cb8532..ed1e62c80 100644
--- a/internal/host/movesrecoveryowners_test.go
+++ b/internal/host/movesrecoveryowners_test.go
@@ -727,18 +727,26 @@ func rcInOrder(t *testing.T, got []string, want ...string) {
// tree changed or replaced during the publication is refused and keeps its
// label.
func TestRecoveryPublicationVerifiesThePlacedTreeBeforeRelabelling(t *testing.T) {
+ changeAFile := func(t *testing.T, f *rcFixture) {
+ if err := os.WriteFile(filepath.Join(f.files, "a.txt"), []byte("changed after the placement"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
for _, tc := range []struct {
- name string
- during func(t *testing.T, f *rcFixture)
- ok bool
+ name string
+ before, during func(t *testing.T, f *rcFixture)
+ ok bool
}{
- {"the tree as placed", nil, true},
- {"the tree changes during the publication", func(t *testing.T, f *rcFixture) {
+ {"the tree as placed", nil, nil, true},
+ // Fix round 2: the relabel is vouched for by the journalled
+ // placement digest, never a digest sampled at the publication.
+ {"the tree changed after Materialize, before the publication", changeAFile, nil, false},
+ {"the tree changes during the publication", nil, func(t *testing.T, f *rcFixture) {
if err := os.WriteFile(filepath.Join(f.files, "a.txt"), []byte("changed under the publication"), 0o600); err != nil {
t.Fatal(err)
}
}, false},
- {"the tree is replaced during the publication", func(t *testing.T, f *rcFixture) {
+ {"the tree is replaced during the publication", nil, func(t *testing.T, f *rcFixture) {
label, err := os.ReadFile(filepath.Join(f.files, archive.MarkerName))
if err != nil {
t.Fatal(err)
@@ -782,6 +790,9 @@ func TestRecoveryPublicationVerifiesThePlacedTreeBeforeRelabelling(t *testing.T)
t.Fatal(err)
}
f.bucket.Put("data", "instances/"+poHost+"/g-fresh1/0009/"+ltx.FormatFilename(1, 51), f.ltxHeader(1, 51))
+ if tc.before != nil {
+ tc.before(t, f)
+ }
pub := ClaimPublication{Check: func(context.Context) error { return nil }, Sync: func(context.Context) error {
if tc.during != nil {
tc.during(t, f)