carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 59f612f26925ae189dd1d3cd0319117909006987 parent b8c547f
6 files changed, +263 −0
  • internal/moves/check.go +10 −0
  • internal/moves/check_test.go +37 −0
  • internal/moves/floor.go +60 −0
  • internal/moves/floor_test.go +62 −0
  • internal/moves/ids.go +53 −0
  • internal/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)
+ }
+}