moves: the seal by version; lease: streamed create-only put; provision: the cut's revert reasons (§7.3, §7.4, §8.3)
moves.Seal (N, replica/files/timers versions, src_covered, from_db, sealed_at, lease_until), canonical Encode, strict DecodeSeal, Bound, ReachesN. lease.FilePutIfAbsent streams the sealed archive into the source's move-snapshots/ without holding it in memory. MoveRevert accepts three more recorded reasons (MV-N163..165).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+365
−0
approved_moves_copy_test.go+3 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+3 −0internal/lease/versioned.go+38 −0internal/lease/versioned_test.go+35 −0internal/moves/seal.go+150 −0internal/moves/seal_test.go+114 −0internal/provision/move_test.go+8 −0internal/provision/provision.go+14 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 0e480faf4..7d56b56be 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -66,6 +66,7 @@ var movesCopyIDs = []string{ |
| "MV-N145", "MV-N146", "MV-N147", "MV-N148", "MV-N149", "MV-N150", |
| "MV-N151", "MV-N152", "MV-N153", "MV-N154", "MV-N155", "MV-N156", |
| "MV-N157", "MV-N158", "MV-N159", "MV-N160", "MV-N161", "MV-N162", |
| + "MV-N163", "MV-N164", "MV-N165", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
| @@ -573,6 +574,7 @@ var movesScanFiles = []string{ |
| "internal/lease/lockstate.go", |
| "internal/lease/mirror.go", |
| "internal/lease/staging.go", |
| + "internal/lease/versioned.go", |
| "internal/moves/archive/archive.go", |
| "internal/moves/archive/extract.go", |
| "internal/moves/archive/limits.go", |
| @@ -593,6 +595,7 @@ var movesScanFiles = []string{ |
| "internal/moves/lease.go", |
| "internal/moves/ltxbuild.go", |
| "internal/moves/readiness.go", |
| + "internal/moves/seal.go", |
| "internal/moves/sealedclient.go", |
| "internal/moves/source.go", |
| "internal/moves/staging.go", |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 58de9a907..fe62d556b 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -202,6 +202,9 @@ Columns: id, file, text, status. |
| | MV-N160 | internal/stopevidence/reconstruct_linux.go | pid <pid> in the agent's cgroup is not the agent | pending | |
| | MV-N161 | internal/stopevidence/reconstruct_linux.go | <count> process(es) hold the database open | pending | |
| | MV-N162 | internal/stopevidence/reconstruct_linux.go | lock.json still held | pending | |
| +| MV-N163 | internal/provision/provision.go | the cut ran past its deadline | pending | |
| +| MV-N164 | internal/provision/provision.go | the final copy kept finding changes at the source | pending | |
| +| MV-N165 | internal/provision/provision.go | the stopped files could not be archived | pending | |
| ## Plan 2 |
diff --git a/internal/lease/versioned.go b/internal/lease/versioned.go| index c07bd037e..82dbb82fe 100644 |
| --- a/internal/lease/versioned.go |
| +++ b/internal/lease/versioned.go |
| @@ -8,6 +8,7 @@ import ( |
| "io" |
| "net/http" |
| "net/url" |
| + "os" |
| "sort" |
| "strings" |
| "time" |
| @@ -290,6 +291,43 @@ func (s *versionedS3) PutIfAbsent(ctx context.Context, bucket, key string, body |
| return aws.ToString(out.VersionId), nil |
| } |
| +// FilePutIfAbsent is PutIfAbsent streaming a file from disk, for objects |
| +// too large to hold in memory: a move's sealed archive, written create-only |
| +// into the source's own move-snapshots/ (moves spec §8.3). The S3 store |
| +// implements it; an existing current object is ErrExists. |
| +type FilePutIfAbsent interface { |
| + PutFileIfAbsent(ctx context.Context, bucket, key, path string) (versionID string, err error) |
| +} |
| + |
| +func (s *versionedS3) PutFileIfAbsent(ctx context.Context, bucket, key, path string) (string, error) { |
| + c, err := s.client(bucket) |
| + if err != nil { |
| + return "", err |
| + } |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer f.Close() |
| + st, err := f.Stat() |
| + if err != nil { |
| + return "", err |
| + } |
| + // An *os.File is seekable, so the SDK hashes the payload by reading it |
| + // and seeking back, never by buffering it. |
| + out, err := c.PutObject(ctx, &awss3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(key), |
| + Body: f, ContentLength: aws.Int64(st.Size()), IfNoneMatch: aws.String("*")}) |
| + if err != nil { |
| + if code, status := apiCode(err); code == "PreconditionFailed" || status == http.StatusPreconditionFailed { |
| + return "", ErrExists |
| + } |
| + return "", fmt.Errorf("lease: put-if-absent %s/%s: %w", bucket, key, err) |
| + } |
| + return aws.ToString(out.VersionId), nil |
| +} |
| + |
| var _ VersionedStore = (*versionedS3)(nil) |
| +var _ FilePutIfAbsent = (*versionedS3)(nil) |
| + |
| var _ VersionRangeOpener = (*versionedS3)(nil) |
diff --git a/internal/lease/versioned_test.go b/internal/lease/versioned_test.go| index 2d7ad8f1f..b63da53f2 100644 |
| --- a/internal/lease/versioned_test.go |
| +++ b/internal/lease/versioned_test.go |
| @@ -2,9 +2,12 @@ package lease_test |
| import ( |
| "context" |
| + "errors" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| "testing" |
| "github.com/aws/aws-sdk-go-v2/aws" |
| @@ -146,3 +149,35 @@ func TestVersionedS3RangeWire(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// PutFileIfAbsent streams a file from disk create-only: the object lands |
| +// with the file's bytes and a version id, and a second put is ErrExists |
| +// with the first object untouched. |
| +func TestVersionedS3PutFileIfAbsent(t *testing.T) { |
| + ctx := context.Background() |
| + st, m, _ := wireStore(t) |
| + fp, ok := st.(lease.FilePutIfAbsent) |
| + if !ok { |
| + t.Fatal("the S3 store cannot put a file create-only") |
| + } |
| + path := filepath.Join(t.TempDir(), "sealed") |
| + if err := os.WriteFile(path, []byte("sealed bytes"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + v, err := fp.PutFileIfAbsent(ctx, "data", "move-snapshots/h/op/files-ab.sealed", path) |
| + if err != nil || v == "" { |
| + t.Fatalf("first put = %q, %v", v, err) |
| + } |
| + if cur, ok := m.Current("data", "move-snapshots/h/op/files-ab.sealed"); !ok || string(cur) != "sealed bytes" { |
| + t.Fatalf("stored %q", cur) |
| + } |
| + if err := os.WriteFile(path, []byte("other bytes"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fp.PutFileIfAbsent(ctx, "data", "move-snapshots/h/op/files-ab.sealed", path); !errors.Is(err, lease.ErrExists) { |
| + t.Fatalf("second put = %v, want ErrExists", err) |
| + } |
| + if cur, _ := m.Current("data", "move-snapshots/h/op/files-ab.sealed"); string(cur) != "sealed bytes" { |
| + t.Fatalf("the second put overwrote: %q", cur) |
| + } |
| +} |
diff --git a/internal/moves/seal.go b/internal/moves/seal.go| new file mode 100644 |
| index 000000000..6e4d1abc4 |
| --- /dev/null |
| +++ b/internal/moves/seal.go |
| @@ -0,0 +1,150 @@ |
| +package moves |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "sort" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/superfly/ltx" |
| +) |
| + |
| +// The seal (§7.3 "seal by version", §7.4 staged): the exact versions of the |
| +// staging lane's replica/, files/ and timers/ objects the cut wrote, the |
| +// required final TXID N, and the two facts a recovery reads, in the claim's |
| +// op journal under hostclaim.JournalSeal. Landing copies these versions and |
| +// nothing else, so a stale round's later write (a newer version, or a |
| +// delete marker) is never installed. |
| +// |
| +// It is written twice under one op: at the end of `stopped` with the replica |
| +// and the timers (the archive does not exist yet), and at the end of |
| +// `staged` with the archive's two objects added. Files is empty until then, |
| +// and the cut never flips on a seal without them (HasFiles). |
| +type Seal struct { |
| + V int `json:"v"` |
| + N uint64 `json:"n"` |
| + Replica []SealedObject `json:"replica"` |
| + Files []SealedObject `json:"files,omitempty"` |
| + Timers []SealedObject `json:"timers,omitempty"` |
| + // SrcCovered is whether the source's own replica of holder.generation |
| + // reached the stopped database's position with no pending write (§7.4, |
| + // rev 7.11): it decides a rollback's clean return (§7.8). |
| + SrcCovered bool `json:"src_covered"` |
| + // FromDB is whether the seal was built from the stopped database itself |
| + // (§5.1 step 9): false for a source that had no local database. |
| + FromDB bool `json:"from_db"` |
| + SealedAt time.Time `json:"sealed_at"` |
| + // LeaseUntil is the latest moment a request signed by a staging writer |
| + // of this lane can still land: the last guarded signature's time plus |
| + // SignatureLife plus the skew (ruling G7). Bound returns it. |
| + LeaseUntil time.Time `json:"lease_until"` |
| +} |
| + |
| +// SealVersion is the only seal format this code reads or writes. |
| +const SealVersion = 1 |
| + |
| +// ErrBadSeal is a seal that pins nothing usable: refused on write and on |
| +// read, never repaired. |
| +var ErrBadSeal = errors.New("moves: the seal is not usable") |
| + |
| +// Bound is the time after which no staging writer that held the lease can |
| +// still write: the staging park's evidence_at (Plan 5). |
| +func (s Seal) Bound() time.Time { return s.LeaseUntil } |
| + |
| +// HasFiles reports whether the seal names the archive: `archived` and the |
| +// flip require it. |
| +func (s Seal) HasFiles() bool { return len(s.Files) > 0 } |
| + |
| +// ReachesN reports whether the sealed replica ends exactly at N: its newest |
| +// level file's max TXID is N. A set that stops short cannot restore to N, |
| +// and one that runs past it is not this stopped database's. |
| +func (s Seal) ReachesN() bool { |
| + var newest ltx.TXID |
| + for _, o := range s.Replica { |
| + pos, err := sealedPosition(o.Key) |
| + if err != nil { |
| + return false |
| + } |
| + newest = max(newest, pos.max) |
| + } |
| + return s.N > 0 && uint64(newest) == s.N |
| +} |
| + |
| +// Validate refuses a seal that pins nothing: another version, no position, |
| +// no replica, any object without a key or a usable version id (G27: "" and |
| +// "null" pin nothing), a key named twice, a negative size, a replica object |
| +// that is not a level file or is below an LTX header (Task 8's |
| +// NewSealedClient refuses those), no seal time, and no bound or a bound |
| +// before the seal. |
| +func (s Seal) Validate() error { |
| + if s.V != SealVersion { |
| + return fmt.Errorf("%w: version %d", ErrBadSeal, s.V) |
| + } |
| + if s.N == 0 || len(s.Replica) == 0 { |
| + return fmt.Errorf("%w: no position or no replica", ErrBadSeal) |
| + } |
| + if s.SealedAt.IsZero() || s.LeaseUntil.IsZero() || s.LeaseUntil.Before(s.SealedAt) { |
| + return fmt.Errorf("%w: no seal time or no writers' bound", ErrBadSeal) |
| + } |
| + seen := map[string]bool{} |
| + for _, class := range [][]SealedObject{s.Replica, s.Files, s.Timers} { |
| + for _, o := range class { |
| + if o.Key == "" || !lease.UsableVersionID(o.VersionID) || o.Size < 0 || seen[o.Key] { |
| + return fmt.Errorf("%w: object %q version %q size %d", ErrBadSeal, o.Key, o.VersionID, o.Size) |
| + } |
| + seen[o.Key] = true |
| + } |
| + } |
| + for _, o := range s.Replica { |
| + if _, err := sealedPosition(o.Key); err != nil { |
| + return fmt.Errorf("%w: %v", ErrBadSeal, err) |
| + } |
| + if o.Size < ltx.HeaderSize { |
| + return fmt.Errorf("%w: replica object %s is below an LTX header", ErrBadSeal, o.Key) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func sortObjects(objs []SealedObject) []SealedObject { |
| + out := append([]SealedObject(nil), objs...) |
| + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) |
| + return out |
| +} |
| + |
| +// Encode is the seal as the claim journal carries it: JSON with every |
| +// object list sorted by key, so one seal always encodes to one string. |
| +func (s Seal) Encode() string { |
| + c := s |
| + c.Replica, c.Files, c.Timers = sortObjects(s.Replica), sortObjects(s.Files), sortObjects(s.Timers) |
| + c.SealedAt, c.LeaseUntil = s.SealedAt.UTC(), s.LeaseUntil.UTC() |
| + b, err := json.Marshal(c) |
| + if err != nil { |
| + // Every field is a plain value: Marshal cannot fail. |
| + panic(err) |
| + } |
| + return string(b) |
| +} |
| + |
| +// DecodeSeal reads a journalled seal strictly (no unknown field, no |
| +// trailing data) and validates it. Anything else is ErrBadSeal. |
| +func DecodeSeal(raw string) (Seal, error) { |
| + dec := json.NewDecoder(bytes.NewReader([]byte(raw))) |
| + dec.DisallowUnknownFields() |
| + var s Seal |
| + if err := dec.Decode(&s); err != nil { |
| + return Seal{}, fmt.Errorf("%w: %v", ErrBadSeal, err) |
| + } |
| + if _, err := dec.Token(); err != io.EOF { |
| + return Seal{}, fmt.Errorf("%w: trailing data", ErrBadSeal) |
| + } |
| + if err := s.Validate(); err != nil { |
| + return Seal{}, err |
| + } |
| + s.Replica, s.Files, s.Timers = sortObjects(s.Replica), sortObjects(s.Files), sortObjects(s.Timers) |
| + return s, nil |
| +} |
diff --git a/internal/moves/seal_test.go b/internal/moves/seal_test.go| new file mode 100644 |
| index 000000000..459cd1e6e |
| --- /dev/null |
| +++ b/internal/moves/seal_test.go |
| @@ -0,0 +1,114 @@ |
| +package moves |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/superfly/ltx" |
| +) |
| + |
| +func sealForTest() Seal { |
| + at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC) |
| + return Seal{V: 1, N: 7, |
| + Replica: []SealedObject{ |
| + {Key: "moves/h/mv/replica/0001/0000000000000001-0000000000000007.ltx", VersionID: "v2", Size: ltx.HeaderSize + 10}, |
| + {Key: "moves/h/mv/replica/0000/0000000000000007-0000000000000007.ltx", VersionID: "v1", Size: ltx.HeaderSize}, |
| + }, |
| + Timers: []SealedObject{{Key: "moves/h/mv/timers/timers.json", VersionID: "v3", Size: 40}}, |
| + Files: []SealedObject{{Key: "moves/h/mv/files/files-ab.sealed", VersionID: "v4", Size: 100}, {Key: "moves/h/mv/files/files-ab.json", VersionID: "v5", Size: 50}}, |
| + FromDB: true, |
| + SealedAt: at, LeaseUntil: at.Add(16 * time.Minute)} |
| +} |
| + |
| +// The seal round-trips through the claim journal's string, in one canonical |
| +// form: objects sorted by key, so two encodings of one seal are equal. |
| +func TestSealEncodeDecodeRoundTrip(t *testing.T) { |
| + s := sealForTest() |
| + enc := s.Encode() |
| + got, err := DecodeSeal(enc) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.N != 7 || len(got.Replica) != 2 || len(got.Files) != 2 || len(got.Timers) != 1 || !got.FromDB || got.SrcCovered { |
| + t.Fatalf("decoded %+v", got) |
| + } |
| + if got.Replica[0].Key > got.Replica[1].Key { |
| + t.Fatalf("replica not sorted: %+v", got.Replica) |
| + } |
| + if again := got.Encode(); again != enc { |
| + t.Fatalf("not canonical:\n%s\n%s", enc, again) |
| + } |
| + if !got.Bound().Equal(s.LeaseUntil) { |
| + t.Fatalf("Bound %v, want LeaseUntil %v", got.Bound(), s.LeaseUntil) |
| + } |
| + if !got.HasFiles() { |
| + t.Fatal("HasFiles false on a seal that names the archive") |
| + } |
| + got.Files = nil |
| + if got.HasFiles() { |
| + t.Fatal("HasFiles true with no files") |
| + } |
| +} |
| + |
| +// A seal that pins nothing is refused, on encode-side validation and on |
| +// decode: a missing or "null" version, an empty key, a key twice, a replica |
| +// object below an LTX header, no position, no bound, another version. |
| +func TestSealRefusesWhatPinsNothing(t *testing.T) { |
| + for name, edit := range map[string]func(*Seal){ |
| + "version 2": func(s *Seal) { s.V = 2 }, |
| + "no position": func(s *Seal) { s.N = 0 }, |
| + "no replica": func(s *Seal) { s.Replica = nil }, |
| + "null version": func(s *Seal) { s.Replica[0].VersionID = "null" }, |
| + "empty version": func(s *Seal) { s.Timers[0].VersionID = "" }, |
| + "empty key": func(s *Seal) { s.Files[0].Key = "" }, |
| + "duplicate key": func(s *Seal) { s.Files[1].Key = s.Files[0].Key }, |
| + "short replica": func(s *Seal) { s.Replica[1].Size = ltx.HeaderSize - 1 }, |
| + "negative size": func(s *Seal) { s.Files[0].Size = -1 }, |
| + "no bound": func(s *Seal) { s.LeaseUntil = time.Time{} }, |
| + "not sealed": func(s *Seal) { s.SealedAt = time.Time{} }, |
| + "replica not an ltx": func(s *Seal) { s.Replica[0].Key = "moves/h/mv/replica/lock.json" }, |
| + "bound before sealed": func(s *Seal) { s.LeaseUntil = s.SealedAt.Add(-time.Second) }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + s := sealForTest() |
| + edit(&s) |
| + if err := s.Validate(); !errors.Is(err, ErrBadSeal) { |
| + t.Fatalf("Validate = %v, want ErrBadSeal", err) |
| + } |
| + if _, err := DecodeSeal(s.Encode()); !errors.Is(err, ErrBadSeal) { |
| + t.Fatalf("DecodeSeal = %v, want ErrBadSeal", err) |
| + } |
| + }) |
| + } |
| + for name, raw := range map[string]string{ |
| + "empty": "", |
| + "not json": "{", |
| + "unknown field": strings.Replace(sealForTest().Encode(), `"n":7`, `"n":7,"extra":1`, 1), |
| + "trailing": sealForTest().Encode() + "{}", |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if _, err := DecodeSeal(raw); !errors.Is(err, ErrBadSeal) { |
| + t.Fatalf("DecodeSeal(%q) = %v, want ErrBadSeal", raw, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The replica reaches N: some sealed level file ends at N, and none ends |
| +// after it. |
| +func TestSealReachesN(t *testing.T) { |
| + s := sealForTest() |
| + if !s.ReachesN() { |
| + t.Fatal("a seal whose newest file ends at N does not reach N") |
| + } |
| + s.N = 8 |
| + if s.ReachesN() { |
| + t.Fatal("a seal ending at 7 reaches N = 8") |
| + } |
| + s.N = 6 |
| + if s.ReachesN() { |
| + t.Fatal("a seal holding a file past N reaches N = 6") |
| + } |
| +} |
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go| index 9610093ce..22afd4eda 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -406,6 +406,14 @@ func TestBackToReadyAndRevert(t *testing.T) { |
| if err := MoveRevert(g, "some error text")(rec4); err == nil || errors.Is(err, ErrMoveConflict) || rec4.Move.State != MoveCutting || rec4.Move.Reason != "" { |
| t.Fatalf("an unrecorded reason: %v %+v", err, rec4.Move) |
| } |
| + // Every recorded reason is accepted and written as itself (Task 5's |
| + // reverts: the deadline, the deferred final rounds, the archive). |
| + for _, reason := range []string{RevertStopEvidenceBudget, RevertCutDeadline, RevertDeferredRounds, RevertArchiveRefused} { |
| + rec5 := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, CutPhase: CutStaged}} |
| + if err := MoveRevert(g, reason)(rec5); err != nil || rec5.Move.State != MoveReverting || rec5.Move.Reason != reason { |
| + t.Fatalf("revert for %q: %v %+v", reason, err, rec5.Move) |
| + } |
| + } |
| // Revert is a cut's recovery only. |
| rec3 := &Record{Region: "uk", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveLanding, CutPhase: CutFlipped}} |
| if err := MoveRevert(g, "x")(rec3); !errors.Is(err, ErrMoveConflict) { |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index e3283abe2..f7f72e0c7 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1459,6 +1459,14 @@ func MoveLandingPhase(g MoveGuard, from, to string, stamp func(*MoveState)) func |
| // reason adds a constant, a case below and a row in the copy record. |
| const ( |
| RevertStopEvidenceBudget = "stop evidence budget" |
| + // RevertCutDeadline: a phase after stopping found cut_deadline passed. |
| + RevertCutDeadline = "the cut ran past its deadline" |
| + // RevertDeferredRounds: three final mirror rounds found the source |
| + // changing under them, or moved more than the priced bar. |
| + RevertDeferredRounds = "the final copy kept finding changes at the source" |
| + // RevertArchiveRefused: the stopped files directory cannot be archived |
| + // (a special file, over the cap, too many entries, a path too long). |
| + RevertArchiveRefused = "the stopped files could not be archived" |
| ) |
| // MoveRevert starts the pre-flip recovery (§7.4 reverting): from a cut |
| @@ -1481,6 +1489,12 @@ func MoveRevert(g MoveGuard, reason string) func(*Record) error { |
| switch reason { |
| case RevertStopEvidenceBudget: |
| rec.Move.Reason = RevertStopEvidenceBudget |
| + case RevertCutDeadline: |
| + rec.Move.Reason = RevertCutDeadline |
| + case RevertDeferredRounds: |
| + rec.Move.Reason = RevertDeferredRounds |
| + case RevertArchiveRefused: |
| + rec.Move.Reason = RevertArchiveRefused |
| default: |
| return fmt.Errorf("provision: revert reason %q has no recorded sentence", reason) |
| } |