carlos / platform Public

seeds: ids, keys, states, the verify token and the row column (Plan 2 Task 1)

Key builders refuse an invalid host or id by returning a key under
seeds/!invalid/ (nothing lives there); Check returns ErrBadHost/ErrBadID
for doors that want a sentinel. Hosts use the repo's host-shape validator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 46c828a71e39e7954dd0dd5fbf4fc445f8d515cd parent 164375e
4 files changed, +396 −0
  • internal/seeds/row.go +48 −0
  • internal/seeds/seeds.go +136 −0
  • internal/seeds/seeds_test.go +171 −0
  • internal/seeds/token.go +41 −0

CI passed — run details

diff --git a/internal/seeds/row.go b/internal/seeds/row.go
new file mode 100644
index 000000000..fecb874db
--- /dev/null
+++ b/internal/seeds/row.go
@@ -0,0 +1,48 @@
+package seeds
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+// RowSeed is what a registry row carries of its record's seed (column
+// seed_source): enough for the activator to place the snapshot on first
+// wake (spec §3.4) without reading the record.
+type RowSeed struct {
+ ID string `json:"id"`
+ Kind string `json:"kind"`
+ SHA256 string `json:"sha256,omitempty"`
+ Bytes int64 `json:"bytes,omitempty"`
+ State string `json:"state"`
+}
+
+// Encode is the column's value: compact JSON in field order.
+func (r RowSeed) Encode() string {
+ b, _ := json.Marshal(r) // strings and an int64 cannot fail to marshal
+ return string(b)
+}
+
+// ParseRowSeed reads a seed_source column. "" is no seed: (nil, nil).
+func ParseRowSeed(s string) (*RowSeed, error) {
+ if s == "" {
+ return nil, nil
+ }
+ var r RowSeed
+ if err := json.Unmarshal([]byte(s), &r); err != nil {
+ return nil, fmt.Errorf("seeds: seed_source column: %w", err)
+ }
+ if !ValidID(r.ID) {
+ return nil, fmt.Errorf("seeds: seed_source column: id %q is not a seed id", r.ID)
+ }
+ return &r, nil
+}
+
+// Placeable reports whether the restore fallback applies (spec §3.4: a row
+// whose record seed is ready, swapped or placed).
+func (r RowSeed) Placeable() bool {
+ switch r.State {
+ case StateReady, StateSwapped, StatePlaced:
+ return true
+ }
+ return false
+}
diff --git a/internal/seeds/seeds.go b/internal/seeds/seeds.go
new file mode 100644
index 000000000..06874ee0f
--- /dev/null
+++ b/internal/seeds/seeds.go
@@ -0,0 +1,136 @@
+// Package seeds is the shared vocabulary of instances that start from a
+// database (#260 --from, #261 import): seed ids, the snapshot's bucket keys,
+// the states a record's seed moves through, the verify token, and the
+// registry column the box reads. It holds no I/O. Spec:
+// docs/superpowers/specs/2026-10-08-seeded-instances-design.md.
+//
+// It must never import internal/moves (the moves copy gate inventories every
+// changed file that does).
+package seeds
+
+import (
+ "crypto/rand"
+ "encoding/base32"
+ "errors"
+ "fmt"
+ "io"
+ "regexp"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/controlname"
+)
+
+// The two kinds of seed (spec §3.2).
+const (
+ KindReplica = "replica" // --from: a sibling's replica
+ KindImport = "import" // an uploaded snapshot
+)
+
+// The states of spec §3.2.
+const (
+ StateReserving = "reserving"
+ StateAwaitingUpload = "awaiting-upload"
+ StateUploaded = "uploaded"
+ StateReady = "ready"
+ StateSwapped = "swapped"
+ StatePlaced = "placed"
+ StateCommitted = "committed"
+ StateRefused = "refused"
+ StateAborted = "aborted"
+ StateRolledBack = "rolled-back"
+)
+
+// MaxBytes is the largest database either door accepts (spec §4 step 5,
+// §5.1): 4 GiB, under S3's 5 GB single-PUT limit.
+const MaxBytes int64 = 4 << 30
+
+// Prefix is the snapshots' prefix in a region's data bucket, beside
+// instances/ and moves/ (spec §3.1).
+const Prefix = "seeds/"
+
+const snapshotName = "snapshot.db"
+
+// invalidHost stands in for a host or id that failed validation. It is not a
+// host shape (the "!"), so no real seed lives under it: a read of such a key
+// finds nothing and a delete of its prefix removes nothing.
+const invalidHost = "!invalid"
+
+// ErrBadHost and ErrBadID are what Check returns. A door that takes a host
+// or id from a request or a record calls Check first and refuses with them.
+var (
+ ErrBadHost = errors.New("seeds: not a host")
+ ErrBadID = errors.New("seeds: not a seed id")
+)
+
+var idShape = regexp.MustCompile(`^sd_[a-z2-7]{26}$`)
+
+var lowerBase32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
+
+// randReader is crypto/rand's reader, a variable so a test can fail it.
+var randReader io.Reader = rand.Reader
+
+// NewID mints a seed id: "sd_" and 128 random bits in lowercase base32. An
+// id is never reused (spec §3.2): every cleanup is scoped to one. Ids come
+// only from crypto/rand.
+func NewID() (string, error) {
+ var b [16]byte
+ if _, err := io.ReadFull(randReader, b[:]); err != nil {
+ return "", fmt.Errorf("seeds: mint an id: %w", err)
+ }
+ return "sd_" + lowerBase32.EncodeToString(b[:]), nil
+}
+
+// ValidID reports whether id has NewID's shape. Every id that passes is 29
+// bytes of [a-z2-7_] and so is one clean path element: no slash, backslash
+// or dot.
+func ValidID(id string) bool { return idShape.MatchString(id) }
+
+// Check reports ErrBadHost if host is not a host shape (lowercase DNS labels,
+// so one clean path element with no slash or "..") and ErrBadID if id is not
+// a seed id.
+func Check(host, id string) error {
+ if !controlname.ValidHostShape(host) {
+ return ErrBadHost
+ }
+ if !ValidID(id) {
+ return ErrBadID
+ }
+ return nil
+}
+
+// HostPrefix is every seed of host: seeds/<host>/. A host that is not a host
+// shape yields seeds/!invalid/, which names nothing.
+func HostPrefix(host string) string {
+ if !controlname.ValidHostShape(host) {
+ host = invalidHost
+ }
+ return Prefix + host + "/"
+}
+
+// SnapshotPrefix is one seed's prefix: seeds/<host>/<id>/. Every delete of
+// a snapshot names it (codex R4-1). A host or id that fails Check yields
+// seeds/!invalid/invalid/, so no key built here leaves seeds/<host>/<id>/ and
+// no prefix built here covers more than one seed.
+func SnapshotPrefix(host, id string) string {
+ if Check(host, id) != nil {
+ return HostPrefix("") + "invalid/"
+ }
+ return HostPrefix(host) + id + "/"
+}
+
+// SnapshotKey is one seed's snapshot: seeds/<host>/<id>/snapshot.db.
+func SnapshotKey(host, id string) string { return SnapshotPrefix(host, id) + snapshotName }
+
+// ParseSnapshotKey is SnapshotKey's inverse. Anything else under seeds/ is
+// not a snapshot and does not parse.
+func ParseSnapshotKey(key string) (host, id string, ok bool) {
+ rest, found := strings.CutPrefix(key, Prefix)
+ if !found {
+ return "", "", false
+ }
+ parts := strings.Split(rest, "/")
+ if len(parts) != 3 || parts[2] != snapshotName || Check(parts[0], parts[1]) != nil {
+ return "", "", false
+ }
+ return parts[0], parts[1], true
+}
diff --git a/internal/seeds/seeds_test.go b/internal/seeds/seeds_test.go
new file mode 100644
index 000000000..c33836e54
--- /dev/null
+++ b/internal/seeds/seeds_test.go
@@ -0,0 +1,171 @@
+package seeds
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestNewIDShape(t *testing.T) {
+ a, err := NewID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _ := NewID()
+ if !ValidID(a) || !ValidID(b) || a == b {
+ t.Fatalf("ids %q and %q: want two distinct valid ids", a, b)
+ }
+ if !strings.HasPrefix(a, "sd_") || len(a) != 29 {
+ t.Fatalf("id %q: want sd_ and 26 characters", a)
+ }
+ for _, bad := range []string{"", "sd_", "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", "sd_AAAAAAAAAAAAAAAAAAAAAAAAAA", "sd_aaaaaaaaaaaaaaaaaaaaaaaaa1", "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa/x"} {
+ if ValidID(bad) {
+ t.Errorf("ValidID(%q) = true", bad)
+ }
+ }
+ old := randReader
+ randReader = failingReader{}
+ defer func() { randReader = old }()
+ if _, err := NewID(); err == nil {
+ t.Error("NewID with a failing random source: want an error")
+ }
+}
+
+type failingReader struct{}
+
+func (failingReader) Read([]byte) (int, error) { return 0, errors.New("no entropy") }
+
+func TestSnapshotKeysRoundTrip(t *testing.T) {
+ id, _ := NewID()
+ key := SnapshotKey("canary.shop.example.com", id)
+ if key != "seeds/canary.shop.example.com/"+id+"/snapshot.db" {
+ t.Fatalf("key = %q", key)
+ }
+ if SnapshotPrefix("canary.shop.example.com", id) != "seeds/canary.shop.example.com/"+id+"/" {
+ t.Fatalf("prefix = %q", SnapshotPrefix("canary.shop.example.com", id))
+ }
+ if HostPrefix("canary.shop.example.com") != "seeds/canary.shop.example.com/" {
+ t.Fatalf("host prefix = %q", HostPrefix("canary.shop.example.com"))
+ }
+ host, got, ok := ParseSnapshotKey(key)
+ if !ok || host != "canary.shop.example.com" || got != id {
+ t.Fatalf("ParseSnapshotKey(%q) = %q, %q, %v", key, host, got, ok)
+ }
+ for _, bad := range []string{
+ "seeds/canary.shop.example.com/" + id + "/other.db",
+ "seeds/canary.shop.example.com/" + id + "/x/snapshot.db",
+ "seeds/canary.shop.example.com/not-an-id/snapshot.db",
+ "instances/canary.shop.example.com/" + id + "/snapshot.db",
+ "seeds//" + id + "/snapshot.db",
+ "seeds/../" + id + "/snapshot.db",
+ "seeds/Shop.Example.com/" + id + "/snapshot.db",
+ "seeds/" + id + "/snapshot.db",
+ "",
+ } {
+ if _, _, ok := ParseSnapshotKey(bad); ok {
+ t.Errorf("ParseSnapshotKey(%q) parsed", bad)
+ }
+ }
+}
+
+// A key builder is handed a host or id from a request or a record. An
+// invalid one must never produce a key that leaves seeds/<host>/<id>/, or a
+// prefix that names more than one seed.
+func TestKeyBuildersContainHostileInput(t *testing.T) {
+ id, _ := NewID()
+ const host = "shop.example.com"
+ if err := Check(host, id); err != nil {
+ t.Fatalf("Check of a valid pair: %v", err)
+ }
+ hosts := []string{"", "..", "../x", "a/b", "a/../b", "a\\b", "Shop.example.com", "shop.example.com/", ".", "a..b", "shop.example.com\x00", "*", "a b"}
+ ids := []string{"", ".", "..", "../" + id, id + "/..", id + "/", "sd_x", strings.ToUpper(id)}
+ for _, h := range hosts {
+ if !errors.Is(Check(h, id), ErrBadHost) {
+ t.Errorf("Check(%q, id) = %v; want ErrBadHost", h, Check(h, id))
+ }
+ for _, k := range []string{HostPrefix(h), SnapshotPrefix(h, id), SnapshotKey(h, id)} {
+ assertContained(t, k)
+ }
+ }
+ for _, i := range ids {
+ if !errors.Is(Check(host, i), ErrBadID) {
+ t.Errorf("Check(host, %q) = %v; want ErrBadID", i, Check(host, i))
+ }
+ for _, k := range []string{SnapshotPrefix(host, i), SnapshotKey(host, i)} {
+ assertContained(t, k)
+ }
+ }
+}
+
+// assertContained: the key is under seeds/, carries no dot-dot or backslash,
+// names no real host or seed, and does not parse as a snapshot.
+func assertContained(t *testing.T, k string) {
+ t.Helper()
+ if !strings.HasPrefix(k, Prefix+invalidHost+"/") {
+ t.Errorf("key %q: want a fail-closed key under %s%s/", k, Prefix, invalidHost)
+ }
+ if strings.Contains(k, "..") || strings.Contains(k, "\\") || strings.Contains(k, "\x00") {
+ t.Errorf("key %q: has a traversal or control character", k)
+ }
+ if _, _, ok := ParseSnapshotKey(k); ok {
+ t.Errorf("key %q parsed as a snapshot", k)
+ }
+}
+
+func TestVerifyToken(t *testing.T) {
+ tok, hash, err := NewVerifyToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(tok) != 43 || len(hash) != 64 {
+ t.Fatalf("token %d chars, hash %d chars: want 43 and 64", len(tok), len(hash))
+ }
+ if HashToken(tok) != hash || !TokenMatches(hash, tok) {
+ t.Fatal("the token does not match its own hash")
+ }
+ if TokenMatches(hash, tok+"x") || TokenMatches(hash, "") {
+ t.Error("a wrong or empty token matched")
+ }
+ if TokenMatches(ClosedGate, tok) || TokenMatches(ClosedGate, ClosedGate) || TokenMatches("", "") {
+ t.Error("the closed gate or an empty hash matched a token")
+ }
+ // An uppercase spelling of the hash is not the hash.
+ if TokenMatches(strings.ToUpper(hash), tok) {
+ t.Error("an uppercase hash matched")
+ }
+ // Two tokens differ.
+ tok2, hash2, _ := NewVerifyToken()
+ if tok == tok2 || hash == hash2 || TokenMatches(hash, tok2) {
+ t.Error("two minted tokens collided or cross-matched")
+ }
+ old := randReader
+ randReader = failingReader{}
+ defer func() { randReader = old }()
+ if _, _, err := NewVerifyToken(); err == nil {
+ t.Error("NewVerifyToken with a failing random source: want an error")
+ }
+}
+
+func TestRowSeedEncoding(t *testing.T) {
+ id, _ := NewID()
+ r := RowSeed{ID: id, Kind: KindReplica, SHA256: strings.Repeat("a", 64), Bytes: 4096, State: StateReady}
+ got, err := ParseRowSeed(r.Encode())
+ if err != nil || got == nil || *got != r {
+ t.Fatalf("round trip = %+v, %v", got, err)
+ }
+ if got, err := ParseRowSeed(""); got != nil || err != nil {
+ t.Fatalf("empty column = %+v, %v; want nil, nil", got, err)
+ }
+ if _, err := ParseRowSeed(`{"id":"nope","state":"ready"}`); err == nil {
+ t.Error("a malformed id parsed")
+ }
+ if _, err := ParseRowSeed(`not json`); err == nil {
+ t.Error("malformed json parsed")
+ }
+ for state, want := range map[string]bool{StateReady: true, StateSwapped: true, StatePlaced: true,
+ StateReserving: false, StateCommitted: false, StateAborted: false, StateRefused: false} {
+ if (RowSeed{State: state}).Placeable() != want {
+ t.Errorf("Placeable(%s) != %v", state, want)
+ }
+ }
+}
diff --git a/internal/seeds/token.go b/internal/seeds/token.go
new file mode 100644
index 000000000..067d52a81
--- /dev/null
+++ b/internal/seeds/token.go
@@ -0,0 +1,41 @@
+package seeds
+
+import (
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "io"
+)
+
+// ClosedGate is the import gate a rollback installs (spec §5.6 step 1): a
+// value no token's hash can equal, so the edge refuses even verify requests.
+const ClosedGate = "closed"
+
+// NewVerifyToken mints an import's verify token (spec §5.1): 32 random
+// bytes, base64url without padding. The record and the box keep only hash.
+func NewVerifyToken() (token, hash string, err error) {
+ var b [32]byte
+ if _, err := io.ReadFull(randReader, b[:]); err != nil {
+ return "", "", fmt.Errorf("seeds: mint a verify token: %w", err)
+ }
+ token = base64.RawURLEncoding.EncodeToString(b[:])
+ return token, HashToken(token), nil
+}
+
+// HashToken is the hex sha256 the record's verify_hash and the row's
+// import_gate hold.
+func HashToken(token string) string {
+ sum := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(sum[:])
+}
+
+// TokenMatches reports whether token's hash is hash, in constant time. An
+// empty token, an empty hash and ClosedGate match nothing.
+func TokenMatches(hash, token string) bool {
+ if token == "" || len(hash) != sha256.Size*2 {
+ return false
+ }
+ return subtle.ConstantTimeCompare([]byte(HashToken(token)), []byte(hash)) == 1
+}