carlos / platform Public

moves: active.json's per-box reservation table, the sentinel and the legacy projection

OfflineActive gains V and Reservations (keyed by box id; "*" is the
sentinel reserving every box); the legacy fields become the projection of
the oldest move entry (of any entry when no move holds). ParseOfflineActive
reads a legacy lock as the sentinel entry and refuses any malformed entry,
an unknown version or a table without V as ErrOfflineActiveMalformed.
Self-serve moves plan 2, Task 3.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 51ee1bab8fcf4a3c95958930b24852307fa27a73 parent f8e34f3
2 files changed, +205 −8
  • internal/moves/offline.go +121 −8
  • internal/moves/offline_reserve_test.go +84 −0
diff --git a/internal/moves/offline.go b/internal/moves/offline.go
index ff8ab90d9..f7eaff645 100644
--- a/internal/moves/offline.go
+++ b/internal/moves/offline.go
@@ -3,6 +3,8 @@ package moves
import (
"crypto/sha256"
"encoding/hex"
+ "encoding/json"
+ "errors"
"fmt"
"path/filepath"
"time"
@@ -451,17 +453,128 @@ func OfflineStager(boxes []string) string {
return stager
}
-// OfflineActiveKey is the one lock of the offline move: while it names a
-// host whose record still carries that move, no other offline move starts.
+// OfflineActiveKey is the offline moves' reservation table (self-serve
+// moves plan 2 Task 3; spec §7.1): one entry per participating box of every
+// operation from its reservation to its release. Before the table form it
+// was one deployment-wide lock, and its legacy fields still carry that
+// lock's shape as the table's projection.
const OfflineActiveKey = "control/offline-moves/active.json"
-// OfflineActive is the lock's body.
+// OfflineActive is active.json's body. Reservations is the table, keyed by
+// box id (OfflineSentinel for an operation that reserves every box). Host,
+// Op, Account, App and At are the projection: the oldest move entry's host,
+// id, account, app and reserved_at (the oldest entry of any kind when no
+// move holds a box), so a console that predates the table reads one lock
+// it believes while young and whose record it checks when old. A body with
+// no Reservations and V 0 is a legacy lock (ParseOfflineActive).
type OfflineActive struct {
- Host string `json:"host"`
- Op string `json:"op"`
- Account string `json:"account"`
- App string `json:"app"`
- At time.Time `json:"at"`
+ V int `json:"v,omitempty"`
+ Host string `json:"host"`
+ Op string `json:"op"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ At time.Time `json:"at"`
+ Reservations map[string]OfflineReservation `json:"reservations,omitempty"`
+}
+
+// OfflineActiveVersion is the table form's V.
+const OfflineActiveVersion = 1
+
+// The reservation kinds, and the sentinel key.
+const (
+ // OfflineKindMove is a self-serve or operator move; ID is the move id.
+ OfflineKindMove = "move"
+ // OfflineKindCleanup is a box's cleanup of a retired host (plan 4);
+ // ID is its retired.json's id.
+ OfflineKindCleanup = "cleanup"
+ // OfflineSentinel is the key of an operation that reserves every box:
+ // a selection with a participant whose InstanceID is empty, and a
+ // legacy lock. It conflicts with every other acquisition, and every
+ // acquisition conflicts with it.
+ OfflineSentinel = "*"
+)
+
+// OfflineReservation is one box's entry. ExpectSeq and ExpectIncarnation
+// are the record the reserver observed before reserving, the sequence and
+// lifetime its first record write is bound to (provision.SetRecordMoveAt).
+type OfflineReservation struct {
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+ Host string `json:"host"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ ReservedAt time.Time `json:"reserved_at"`
+ ExpectSeq uint64 `json:"expect_seq,omitempty"`
+ ExpectIncarnation uint64 `json:"expect_incarnation,omitempty"`
+}
+
+// valid: a known kind, an id, a host and a time.
+func (r OfflineReservation) valid() bool {
+ return (r.Kind == OfflineKindMove || r.Kind == OfflineKindCleanup) && r.ID != "" && r.Host != "" && !r.ReservedAt.IsZero()
+}
+
+// ErrOfflineActiveMalformed is a body that is not a table and not a legacy
+// lock, or holds any malformed entry: the whole object is unreadable until an
+// operator repairs it, and no reservation is acquired, reclaimed or released
+// against it.
+var ErrOfflineActiveMalformed = errors.New("moves: active.json is malformed")
+
+// ParseOfflineActive reads active.json's body as its reservation table. A
+// legacy lock (no Reservations, V 0) reads as the one sentinel entry
+// {move, Op, Host, Account, App, At}. An unknown version, a table without
+// V, an empty key, or an entry with an unknown kind, an empty id or host, or
+// a zero time is ErrOfflineActiveMalformed.
+func ParseOfflineActive(raw []byte) (map[string]OfflineReservation, error) {
+ var a OfflineActive
+ if err := json.Unmarshal(raw, &a); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrOfflineActiveMalformed, err)
+ }
+ if a.V == 0 && a.Reservations == nil {
+ e := OfflineReservation{Kind: OfflineKindMove, ID: a.Op, Host: a.Host, Account: a.Account, App: a.App, ReservedAt: a.At}
+ if !e.valid() {
+ return nil, fmt.Errorf("%w: a legacy lock without a host, an op or a time", ErrOfflineActiveMalformed)
+ }
+ return map[string]OfflineReservation{OfflineSentinel: e}, nil
+ }
+ if a.V != OfflineActiveVersion {
+ return nil, fmt.Errorf("%w: version %d", ErrOfflineActiveMalformed, a.V)
+ }
+ table := make(map[string]OfflineReservation, len(a.Reservations))
+ for k, e := range a.Reservations {
+ if k == "" || !e.valid() {
+ return nil, fmt.Errorf("%w: entry %q", ErrOfflineActiveMalformed, k)
+ }
+ table[k] = e
+ }
+ return table, nil
+}
+
+// OfflineActiveBody is the table form of table: V, the table, and the
+// projection (OfflineActive's doc). Ties in reserved_at go to the lower key,
+// so two writers project one table identically.
+func OfflineActiveBody(table map[string]OfflineReservation) OfflineActive {
+ a := OfflineActive{V: OfflineActiveVersion, Reservations: table}
+ var pick string
+ better := func(k string) bool {
+ if pick == "" {
+ return true
+ }
+ e, p := table[k], table[pick]
+ if (e.Kind == OfflineKindMove) != (p.Kind == OfflineKindMove) {
+ return e.Kind == OfflineKindMove
+ }
+ return e.ReservedAt.Before(p.ReservedAt) || (e.ReservedAt.Equal(p.ReservedAt) && k < pick)
+ }
+ for k := range table {
+ if better(k) {
+ pick = k
+ }
+ }
+ if pick != "" {
+ e := table[pick]
+ a.Host, a.Op, a.Account, a.App, a.At = e.Host, e.ID, e.Account, e.App, e.ReservedAt
+ }
+ return a
}
// OfflineArchivePrefix is where the move's sealed files archive and its
diff --git a/internal/moves/offline_reserve_test.go b/internal/moves/offline_reserve_test.go
new file mode 100644
index 000000000..df473e60a
--- /dev/null
+++ b/internal/moves/offline_reserve_test.go
@@ -0,0 +1,84 @@
+package moves
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+)
+
+// active.json's table form (self-serve moves plan 2 Task 3): per-box
+// reservations keyed by box id, the sentinel "*", a legacy lock read as the
+// sentinel, any malformed entry making the whole object unreadable, and the
+// legacy fields written as the projection an old console reads.
+func TestOfflineActiveTable(t *testing.T) {
+ at := time.Date(2026, 10, 22, 11, 0, 0, 0, time.UTC)
+ move := func(id, host string, when time.Time) OfflineReservation {
+ return OfflineReservation{Kind: OfflineKindMove, ID: id, Host: host, Account: "bab", App: "shop", ReservedAt: when}
+ }
+
+ // A legacy lock reserves every box: it reads as the sentinel entry.
+ legacy, _ := json.Marshal(OfflineActive{Host: "shop.example.com", Op: "mv_a", Account: "bab", App: "shop", At: at})
+ table, err := ParseOfflineActive(legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(table) != 1 || table[OfflineSentinel] != move("mv_a", "shop.example.com", at) {
+ t.Fatalf("legacy lock read as %+v", table)
+ }
+
+ // The table form round-trips, and its projection is the oldest move.
+ cleanup := OfflineReservation{Kind: OfflineKindCleanup, ID: "rt_1", Host: "old.shop.example.com", ReservedAt: at.Add(-time.Hour)}
+ body := OfflineActiveBody(map[string]OfflineReservation{
+ "i-0a": move("mv_b", "b.shop.example.com", at.Add(time.Minute)),
+ "i-0c": move("mv_b", "b.shop.example.com", at.Add(time.Minute)),
+ "i-0d": move("mv_c", "c.shop.example.com", at),
+ "i-0e": cleanup,
+ })
+ if body.V != OfflineActiveVersion || body.Host != "c.shop.example.com" || body.Op != "mv_c" || body.Account != "bab" || body.App != "shop" || !body.At.Equal(at) {
+ t.Fatalf("projection = %+v", body)
+ }
+ raw, _ := json.Marshal(body)
+ back, err := ParseOfflineActive(raw)
+ if err != nil || len(back) != 4 || back["i-0e"] != cleanup {
+ t.Fatalf("round trip = %+v %v", back, err)
+ }
+ // With no move the projection is the oldest entry of any kind.
+ only := OfflineActiveBody(map[string]OfflineReservation{"i-0e": cleanup})
+ if only.Host != cleanup.Host || only.Op != cleanup.ID || !only.At.Equal(cleanup.ReservedAt) {
+ t.Fatalf("cleanup projection = %+v", only)
+ }
+ // An old console's reader sees the projection as its lock.
+ var old struct {
+ Host, Op, Account, App string
+ At time.Time
+ }
+ if err := json.Unmarshal(raw, &old); err != nil || old.Host != "c.shop.example.com" || old.Op != "mv_c" {
+ t.Fatalf("an old reader sees %+v %v", old, err)
+ }
+
+ // Anything malformed is unreadable as a whole.
+ bad := map[string]string{
+ "not json": `{`,
+ "no host": `{"op":"mv_a","at":"2026-10-22T11:00:00Z"}`,
+ "legacy without op": `{"host":"shop.example.com","at":"2026-10-22T11:00:00Z"}`,
+ "legacy zero time": `{"host":"shop.example.com","op":"mv_a"}`,
+ "unknown version": `{"v":2,"reservations":{"i-0a":{"kind":"move","id":"mv_a","host":"a","reserved_at":"2026-10-22T11:00:00Z"}}}`,
+ "table without v": `{"reservations":{"i-0a":{"kind":"move","id":"mv_a","host":"a","reserved_at":"2026-10-22T11:00:00Z"}}}`,
+ "unknown kind": `{"v":1,"reservations":{"i-0a":{"kind":"lease","id":"mv_a","host":"a","reserved_at":"2026-10-22T11:00:00Z"}}}`,
+ "empty id": `{"v":1,"reservations":{"i-0a":{"kind":"move","id":"","host":"a","reserved_at":"2026-10-22T11:00:00Z"}}}`,
+ "id-less sentinel": `{"v":1,"reservations":{"*":{"kind":"move","host":"a","reserved_at":"2026-10-22T11:00:00Z"}}}`,
+ "empty host": `{"v":1,"reservations":{"i-0a":{"kind":"move","id":"mv_a","reserved_at":"2026-10-22T11:00:00Z"}}}`,
+ "zero time": `{"v":1,"reservations":{"i-0a":{"kind":"move","id":"mv_a","host":"a"}}}`,
+ "empty key": `{"v":1,"reservations":{"":{"kind":"move","id":"mv_a","host":"a","reserved_at":"2026-10-22T11:00:00Z"}}}`,
+ }
+ for name, b := range bad {
+ if got, err := ParseOfflineActive([]byte(b)); !errors.Is(err, ErrOfflineActiveMalformed) {
+ t.Errorf("%s: parsed as %+v %v", name, got, err)
+ }
+ }
+ // A table with no entries is empty (it is deleted rather than written).
+ if got, err := ParseOfflineActive([]byte(`{"v":1}`)); err != nil || len(got) != 0 {
+ t.Fatalf("an empty table = %+v %v", got, err)
+ }
+}