carlos / platform Public

offline-move: create-only writer for the immutable seal record

putOfflineCreateOnly writes a key only if it is absent and otherwise
returns what is stored, unwritten: the sealer writes seal.json once
(moves.OfflineSealKey, added on Task 6's branch) and a re-run compares
instead of overwriting. The stub contracts for Tasks 4 and 5 name it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev df9df6378c5dad714c0511af60641b452ccb20e4 parent c03598e
4 files changed, +57 −0
  • internal/host/movesoffline.go +25 −0
  • internal/host/movesoffline_land.go +4 −0
  • internal/host/movesoffline_seal.go +6 −0
  • internal/host/movesoffline_test.go +22 −0
diff --git a/internal/host/movesoffline.go b/internal/host/movesoffline.go
index 47bb7e23c..1fef1c720 100644
--- a/internal/host/movesoffline.go
+++ b/internal/host/movesoffline.go
@@ -317,6 +317,31 @@ func writeOfflineReport(ctx context.Context, s release.Store, r moves.OfflineRep
return moves.PutReplace(ctx, s, key, body)
}
+// putOfflineCreateOnly writes body at key only if nothing is there yet:
+// the immutable seal record (control/offline-moves/<host>/<id>/seal.json,
+// moves.OfflineSealKey, written once by the sealer and never rewritten).
+// It is an ensure: when the key already exists it writes nothing and
+// returns what is stored, with created false, so a sealer re-running after
+// a crash compares rather than overwrites, and a reader (flip, the landing)
+// always sees the first write. key must be non-empty.
+func putOfflineCreateOnly(ctx context.Context, s release.Store, key string, body []byte) (stored []byte, created bool, err error) {
+ if key == "" {
+ return nil, false, errors.New("offline move: no key to write")
+ }
+ _, err = s.Put(ctx, key, body, "")
+ if err == nil {
+ return body, true, nil
+ }
+ if !errors.Is(err, release.ErrCASConflict) {
+ return nil, false, fmt.Errorf("write %s: %w", key, err)
+ }
+ stored, _, err = s.Get(ctx, key)
+ if err != nil {
+ return nil, false, fmt.Errorf("read %s after a create-only write found it: %w", key, err)
+ }
+ return stored, false, nil
+}
+
// readOfflineReports reads every report of one role for one move, keyed by
// box. A report that does not validate is an error, never skipped.
func readOfflineReports(ctx context.Context, s release.Store, host, op, role string) (map[string]moves.OfflineReport, error) {
diff --git a/internal/host/movesoffline_land.go b/internal/host/movesoffline_land.go
index b4c2687ce..d3ea217f5 100644
--- a/internal/host/movesoffline_land.go
+++ b/internal/host/movesoffline_land.go
@@ -20,6 +20,10 @@ import (
// ensure; an error is logged by the pass, throttled per host, and the next
// pass runs the step again.
//
+// The landing compares against the immutable seal record
+// (moves.OfflineSealKey(host, id), added on Task 6's branch), read with
+// s.Get; never against the sealer's per-pass report, which keeps changing.
+//
// Task 3 leaves it a no-op.
func (w *movesWorker) offlineTargetStep(ctx context.Context, s release.Store, rec provision.Record) error {
return nil
diff --git a/internal/host/movesoffline_seal.go b/internal/host/movesoffline_seal.go
index 2e3b2d6af..e13489c57 100644
--- a/internal/host/movesoffline_seal.go
+++ b/internal/host/movesoffline_seal.go
@@ -16,6 +16,12 @@ import (
// Every step must be an ensure; an error is logged by the pass, throttled
// per host, and the next pass runs the step again.
//
+// The immutable seal record (moves.OfflineSealKey(host, id), added on
+// Task 6's branch) is written once through putOfflineCreateOnly, never
+// rewritten: on a re-run it returns the stored body with created false, and
+// the step compares instead of overwriting. The per-pass sealer report goes
+// through writeOfflineReport, which replaces.
+//
// Task 3 leaves it a no-op.
func (w *movesWorker) offlineSealerStep(ctx context.Context, s release.Store, rec provision.Record) error {
return nil
diff --git a/internal/host/movesoffline_test.go b/internal/host/movesoffline_test.go
index 6b767b38f..191a73fe8 100644
--- a/internal/host/movesoffline_test.go
+++ b/internal/host/movesoffline_test.go
@@ -1012,6 +1012,28 @@ func TestOfflineDormantIsMain(t *testing.T) {
}
}
+// The seal record's writer is create-only: the first body stays, a re-run
+// gets it back and writes nothing, and the report writer beside it replaces.
+func TestOfflineCreateOnlyKeepsTheFirstWrite(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ key := moves.OfflineReportPrefix(offHost, offID) + "seal.json"
+ got, created, err := putOfflineCreateOnly(ctx, s, key, []byte(`{"n":1}`))
+ if err != nil || !created || string(got) != `{"n":1}` {
+ t.Fatalf("first write: %q %v %v", got, created, err)
+ }
+ got, created, err = putOfflineCreateOnly(ctx, s, key, []byte(`{"n":2}`))
+ if err != nil || created || string(got) != `{"n":1}` {
+ t.Fatalf("second write: %q %v %v, want the first body back, nothing written", got, created, err)
+ }
+ if body, _, err := s.Get(ctx, key); err != nil || string(body) != `{"n":1}` {
+ t.Fatalf("stored: %q %v", body, err)
+ }
+ if _, _, err := putOfflineCreateOnly(ctx, s, "", nil); err == nil {
+ t.Fatal("an empty key was written")
+ }
+}
+
// keep's table: the source keeps until finishing, the target until the
// move is cleared, a third region never.
func TestOfflineKeep(t *testing.T) {