// 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//. 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///. 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/// 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///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 }