moves: operation ids, the Check result type and the protocol floor types (spec §3, §11)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
6 files changed,
+263
−0
internal/moves/check.go+10 −0internal/moves/check_test.go+37 −0internal/moves/floor.go+60 −0internal/moves/floor_test.go+62 −0internal/moves/ids.go+53 −0internal/moves/ids_test.go+41 −0
diff --git a/internal/moves/check.go b/internal/moves/check.go| new file mode 100644 |
| index 000000000..7a0666872 |
| --- /dev/null |
| +++ b/internal/moves/check.go |
| @@ -0,0 +1,10 @@ |
| +package moves |
| + |
| +// Check is the shared result of a yes/no precondition with a reason. The |
| +// zero value is NOT ok: a check that was never run, or that returned |
| +// without deciding, fails closed. It is serialised into reports, so the |
| +// wire names are fixed here. |
| +type Check struct { |
| + OK bool `json:"ok"` |
| + Reason string `json:"reason"` |
| +} |
diff --git a/internal/moves/check_test.go b/internal/moves/check_test.go| new file mode 100644 |
| index 000000000..8991b1521 |
| --- /dev/null |
| +++ b/internal/moves/check_test.go |
| @@ -0,0 +1,37 @@ |
| +package moves |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| +) |
| + |
| +func TestCheckZeroValueFails(t *testing.T) { |
| + var c Check |
| + if c.OK { |
| + t.Fatal("a zero Check must not be OK") |
| + } |
| + if c.Reason != "" { |
| + t.Fatalf("zero reason %q", c.Reason) |
| + } |
| +} |
| + |
| +func TestCheckJSONWireNames(t *testing.T) { |
| + b, err := json.Marshal(Check{OK: true, Reason: "r"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(b) != `{"ok":true,"reason":"r"}` { |
| + t.Fatalf("wire form %s", b) |
| + } |
| + var back Check |
| + if err := json.Unmarshal([]byte(`{"ok":false,"reason":"why"}`), &back); err != nil { |
| + t.Fatal(err) |
| + } |
| + if back.OK || back.Reason != "why" { |
| + t.Fatalf("round trip %+v", back) |
| + } |
| + var zero Check |
| + if err := json.Unmarshal([]byte(`{}`), &zero); err != nil || zero.OK { |
| + t.Fatalf("empty object read as %+v, %v", zero, err) |
| + } |
| +} |
diff --git a/internal/moves/floor.go b/internal/moves/floor.go| new file mode 100644 |
| index 000000000..8963ce4a5 |
| --- /dev/null |
| +++ b/internal/moves/floor.go |
| @@ -0,0 +1,60 @@ |
| +package moves |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ProtocolVersion is the move protocol version this build speaks. |
| +const ProtocolVersion = 1 |
| + |
| +// FloorKey is where the operator-published protocol floor lives in the |
| +// release bucket. |
| +const FloorKey = "control/moves-protocol.json" |
| + |
| +// Floor is the minimum protocol version, and the first release that speaks |
| +// it, that the door requires of a host before a move may involve it. |
| +type Floor struct { |
| + Min int `json:"min"` |
| + MinRelease string `json:"min_release"` |
| +} |
| + |
| +// ReadFloor reads the floor. An absent object is (nil, nil): no floor has |
| +// been published. A present but unreadable one is an error, never a |
| +// silent "no floor". |
| +func ReadFloor(ctx context.Context, s release.Store) (*Floor, error) { |
| + body, _, err := s.Get(ctx, FloorKey) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, fmt.Errorf("moves: read floor: %w", err) |
| + } |
| + var f Floor |
| + if err := json.Unmarshal(body, &f); err != nil { |
| + return nil, fmt.Errorf("moves: parse floor: %w", err) |
| + } |
| + return &f, nil |
| +} |
| + |
| +// ReleaseAtOrAbove reports whether version is at or above minRelease, both |
| +// "vMAJOR.MINOR.PATCH". It fails closed: a version that does not parse |
| +// (prereleases included) or a malformed minRelease is false with a non-nil |
| +// error. |
| +func ReleaseAtOrAbove(version, minRelease string) (bool, error) { |
| + min, ok := release.ParseSemVer(strings.TrimPrefix(minRelease, "v")) |
| + if !ok { |
| + return false, fmt.Errorf("moves: malformed minimum release %q", minRelease) |
| + } |
| + v, ok := release.ParseSemVer(strings.TrimPrefix(version, "v")) |
| + if !ok { |
| + return false, fmt.Errorf("moves: unparseable release %q", version) |
| + } |
| + return v.Compare(min) >= 0, nil |
| +} |
diff --git a/internal/moves/floor_test.go b/internal/moves/floor_test.go| new file mode 100644 |
| index 000000000..f008a522c |
| --- /dev/null |
| +++ b/internal/moves/floor_test.go |
| @@ -0,0 +1,62 @@ |
| +package moves |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestFloorReadAndCompare(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + |
| + f, err := ReadFloor(ctx, s) |
| + if err != nil || f != nil { |
| + t.Fatalf("absent floor read as %+v, %v", f, err) |
| + } |
| + |
| + body, _ := json.Marshal(Floor{Min: ProtocolVersion, MinRelease: "v0.23.0"}) |
| + if _, err := s.Put(ctx, FloorKey, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err = ReadFloor(ctx, s) |
| + if err != nil || f == nil || f.Min != ProtocolVersion || f.MinRelease != "v0.23.0" { |
| + t.Fatalf("written floor read as %+v, %v", f, err) |
| + } |
| + |
| + for _, tc := range []struct { |
| + version, min string |
| + want bool |
| + wantErr bool |
| + }{ |
| + {"v0.23.0", "v0.23.0", true, false}, |
| + {"v0.24.1", "v0.23.0", true, false}, |
| + {"v0.22.9", "v0.23.0", false, false}, |
| + {"v1.0.0", "v0.23.0", true, false}, |
| + {"v0.30.0-rc.1", "v0.23.0", false, true}, // prerelease: fail closed |
| + {"v0.30.0-rc.1", "v0.1.0", false, true}, |
| + {"", "v0.23.0", false, true}, |
| + {"junk", "v0.23.0", false, true}, |
| + {"v0.24.1", "", false, true}, // malformed floor: fail closed |
| + {"v0.24.1", "v0.23", false, true}, |
| + {"v0.24.1", "v0.23.0-rc.1", false, true}, |
| + } { |
| + got, err := ReleaseAtOrAbove(tc.version, tc.min) |
| + if got != tc.want || (err != nil) != tc.wantErr { |
| + t.Errorf("ReleaseAtOrAbove(%q, %q) = %v, %v; want %v, err %v", tc.version, tc.min, got, err, tc.want, tc.wantErr) |
| + } |
| + } |
| +} |
| + |
| +func TestReadFloorGarbageIsAnError(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := s.Put(ctx, FloorKey, []byte("not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if f, err := ReadFloor(ctx, s); err == nil || f != nil { |
| + t.Fatalf("garbage floor read as %+v, %v", f, err) |
| + } |
| +} |
diff --git a/internal/moves/ids.go b/internal/moves/ids.go| new file mode 100644 |
| index 000000000..cd6ce678f |
| --- /dev/null |
| +++ b/internal/moves/ids.go |
| @@ -0,0 +1,53 @@ |
| +// Package moves holds the move protocol's data shapes and arithmetic |
| +// (move-any-app design rev 7.18). It imports no activator, registry |
| +// writer or console: everything here is pure so the box, the console and |
| +// the CLI agree on one definition. |
| +package moves |
| + |
| +import ( |
| + "encoding/base32" |
| + "fmt" |
| + "io" |
| + "regexp" |
| +) |
| + |
| +// opIDShape is §3: a role prefix and 26 lowercase base32 characters (16 |
| +// random bytes, no padding). |
| +// |
| +// Twin of provision.moveIDShape: the copy is forced, because moves imports |
| +// provision, so provision cannot import moves. |
| +var opIDShape = regexp.MustCompile(`^(mv|rb)_[a-z2-7]{26}$`) |
| + |
| +var lowerBase32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding) |
| + |
| +func newID(prefix string, r io.Reader) (string, error) { |
| + var b [16]byte |
| + if _, err := io.ReadFull(r, b[:]); err != nil { |
| + return "", fmt.Errorf("moves: mint %s id: %w", prefix, err) |
| + } |
| + return prefix + "_" + lowerBase32.EncodeToString(b[:]), nil |
| +} |
| + |
| +// NewMoveID mints a move id. The console mints it at the request door and |
| +// it names the operation in the claim, the record and every staging key. |
| +func NewMoveID(r io.Reader) (string, error) { return newID("mv", r) } |
| + |
| +// NewRollbackID mints a rollback id, minted by whichever actor wins the |
| +// rollback CAS (§7.8). |
| +func NewRollbackID(r io.Reader) (string, error) { return newID("rb", r) } |
| + |
| +// ValidOpID reports whether id has §3's shape. Op ids are opaque beyond |
| +// this: they are never compared for order (claim rev 6.1 Q1). |
| +func ValidOpID(id string) bool { return opIDShape.MatchString(id) } |
| + |
| +// OpIDKind is "move", "rollback" or "" for an id of neither shape. |
| +func OpIDKind(id string) string { |
| + switch { |
| + case !ValidOpID(id): |
| + return "" |
| + case id[:3] == "mv_": |
| + return "move" |
| + default: |
| + return "rollback" |
| + } |
| +} |
diff --git a/internal/moves/ids_test.go b/internal/moves/ids_test.go| new file mode 100644 |
| index 000000000..5b0159987 |
| --- /dev/null |
| +++ b/internal/moves/ids_test.go |
| @@ -0,0 +1,41 @@ |
| +package moves |
| + |
| +import ( |
| + "crypto/rand" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestOpIDsAreShapedBySpec(t *testing.T) { |
| + // §3: mv_ or rb_ plus 26 lowercase base32 characters. |
| + shape := regexp.MustCompile(`^(mv|rb)_[a-z2-7]{26}$`) |
| + mv, err := NewMoveID(rand.Reader) |
| + if err != nil || !shape.MatchString(mv) { |
| + t.Fatalf("move id %q, err %v", mv, err) |
| + } |
| + rb, err := NewRollbackID(rand.Reader) |
| + if err != nil || !shape.MatchString(rb) { |
| + t.Fatalf("rollback id %q, err %v", rb, err) |
| + } |
| + if !ValidOpID(mv) || !ValidOpID(rb) || OpIDKind(mv) != "move" || OpIDKind(rb) != "rollback" { |
| + t.Fatal("valid ids refused or misclassified") |
| + } |
| + for _, bad := range []string{"", "mv_", "MV_abcdefghijklmnopqrstuvwxyz", "mv_abcdefghijklmnopqrstuvwxy1", "mv_abcdefghijklmnopqrstuvwxyz/", "xx_abcdefghijklmnopqrstuvwxyz"} { |
| + if ValidOpID(bad) || OpIDKind(bad) != "" { |
| + t.Fatalf("%q accepted", bad) |
| + } |
| + } |
| + if a, _ := NewMoveID(rand.Reader); a == mv { |
| + t.Fatal("two ids equal") |
| + } |
| +} |
| + |
| +func TestOpIDMintFailsOnShortRandomSource(t *testing.T) { |
| + if id, err := NewMoveID(strings.NewReader("short")); err == nil || id != "" { |
| + t.Fatalf("short reader gave %q, %v", id, err) |
| + } |
| + if id, err := NewRollbackID(strings.NewReader("")); err == nil || id != "" { |
| + t.Fatalf("empty reader gave %q, %v", id, err) |
| + } |
| +} |