carlos / platform Public
Seeded Instances, Plan 2: The Seed on the Record Implementation Plan
> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Give an instance record a seed: its identity, kind, state machine and provenance, writable only through guarded transitions, with the small shared vocabulary (internal/seeds) every later plan uses.
Architecture: A new pure package internal/seeds holds ids, bucket keys, states, the verify token and the registry column's encoding. provision.Record gains Seed *SeedSource (spec §3.2). Like Maintenance, the field is protected: updateRecord and claimRawUpdateRecord refuse any write that changes it, and only SetRecordSeed with one of the named transitions may. Typed writers already carry an unknown-to-them field through, because they decode the whole Record and re-marshal it, so every ordinary writer preserves Seed once the struct declares it.
Tech Stack: Go 1.25, the repository's release.Store (tests use release.NewDirStore(t.TempDir())), crypto/rand, crypto/sha256.
Spec: docs/superpowers/specs/2026-10-08-seeded-instances-design.md rev 7: §3.1 (keys scoped by seed ID, codex R4-1), §3.2 (the record and its states, codex 13), §5.1 (the verify token, hashed), §5.6 (abort and rolled-back), §5.8 (the move predicate, codex R4-2), run spec §5.1 step 9 (the run predicate, codex R2-2). Index: 2026-10-08-seeded-instances-0-index.md.
Global Constraints
- Every user-facing string (CLI output, errors, console messages, docs prose) is copy-reviewed by Paul before it is written into code; implementation tasks use only strings from the copy deck. Copy voice: no em dashes; full stops and new sentences instead.
- CI cross-compiles darwin and windows: linux-only code needs
//go:build linuxplus a!linuxstub. - Local test commands: targeted tests (
go test ./internal/<pkg>/ -run TestX), not the full suite in a loop. Inside the sandbox the default Go build cache may be read-only; if so useGOCACHE=$TMPDIR/gocachere-exported per command. Socket tests need a short TMPDIR (TMPDIR=/tmp/g). The local gate may needGOFLAGS=-buildvcs=false. - Heavy loops run under
nice; no -race full suite until the end of a plan. - Production use needs nested boxes; the claim-armed path is refused here and left to #250 (spec §3.4).
- Commits end with
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. - This plan adds no user-facing string. Errors from
provisionare sentinels the console maps to deck rows in later plans. - No new file in this series imports
internal/moves: the moves copy gate inventories every changed Go file that does (movesCodeFile,approved_moves_copy_test.go:984), and a seeded file there would need moves record rows. - Wire shapes are additive:
seedisomitempty, and a record without it decodes and re-encodes byte for byte as before.
Review Focus
- A mutation passed to
updateRecord(orUpdateRecord) that editsrec.Seedthrough the pointer it was handed must be refused, not silently written: the comparison needs a deep copy taken before the mutation runs. Test in Task 2 (TestOrdinaryWritersCannotChangeTheSeed). - A transition naming another seed
IDthan the record's must be refused even when the state matches, so a late step of seed A can never advance seed B on a recreated host. Test in Task 3 (TestSeedTransitionsAreScopedToTheirID). - An import must never go from
readystraight toplaced: placement of an import follows its swap. Test in Task 3 (TestSeedPlaceFollowsTheKind). - The verify token's hash must be compared in constant time, and the closed sentinel and an empty token must match nothing. Test in Task 1 (
TestVerifyToken). - A seed key built from a host and an ID must parse back to exactly that host and ID, and a key with an extra segment, a missing
snapshot.dbor a malformed ID must not parse. Test in Task 1 (TestSnapshotKeysRoundTrip).
---
File structure
| path | responsibility | |---|---| | internal/seeds/seeds.go | kinds, states, MaxBytes, ids, bucket keys | | internal/seeds/token.go | the verify token and ClosedGate | | internal/seeds/row.go | RowSeed, the registry column's encoding | | internal/provision/seed.go | SeedSource, its predicates, SetRecordSeed, the transitions, SeedColumn | | internal/provision/provision.go | Record.Seed; updateRecord and claimRawUpdateRecord refuse a seed change |
---
Task 1: The internal/seeds package
Files:
- Create:
internal/seeds/seeds.go,internal/seeds/token.go,internal/seeds/row.go - Test:
internal/seeds/seeds_test.go
Interfaces:
- Produces:
KindReplica,KindImport;StateReserving,StateAwaitingUpload,StateUploaded,StateReady,StateSwapped,StatePlaced,StateCommitted,StateRefused,StateAborted,StateRolledBack;MaxBytes int64;Prefix = "seeds/";NewID() (string, error);ValidID(string) bool;HostPrefix(host string) string;SnapshotPrefix(host, id string) string;SnapshotKey(host, id string) string;ParseSnapshotKey(key string) (host, id string, ok bool);NewVerifyToken() (token, hash string, err error);HashToken(token string) string;TokenMatches(hash, token string) bool;ClosedGate = "closed";type RowSeed struct{ID, Kind, SHA256 string; Bytes int64; State string};(RowSeed) Encode() string;ParseRowSeed(string) (*RowSeed, error);(RowSeed) Placeable() bool.
- [ ] Step 1: Write the failing tests
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.go.tito.io", id)
if key != "seeds/canary.go.tito.io/"+id+"/snapshot.db" {
t.Fatalf("key = %q", key)
}
if SnapshotPrefix("canary.go.tito.io", id) != "seeds/canary.go.tito.io/"+id+"/" {
t.Fatalf("prefix = %q", SnapshotPrefix("canary.go.tito.io", id))
}
if HostPrefix("canary.go.tito.io") != "seeds/canary.go.tito.io/" {
t.Fatalf("host prefix = %q", HostPrefix("canary.go.tito.io"))
}
host, got, ok := ParseSnapshotKey(key)
if !ok || host != "canary.go.tito.io" || got != id {
t.Fatalf("ParseSnapshotKey(%q) = %q, %q, %v", key, host, got, ok)
}
for _, bad := range []string{
"seeds/canary.go.tito.io/" + id + "/other.db",
"seeds/canary.go.tito.io/" + id + "/x/snapshot.db",
"seeds/canary.go.tito.io/not-an-id/snapshot.db",
"instances/canary.go.tito.io/" + id + "/snapshot.db",
"seeds//" + id + "/snapshot.db",
} {
if _, _, ok := ParseSnapshotKey(bad); ok {
t.Errorf("ParseSnapshotKey(%q) parsed", bad)
}
}
}
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")
}
}
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")
}
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)
}
}
}- [ ] Step 2: Run the tests to verify they fail
Run: go test ./internal/seeds/ -count=1 Expected: FAIL to compile (no non-test Go files or undefined: NewID).
- [ ] Step 3: Write the package
internal/seeds/seeds.go:
// 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"
"fmt"
"io"
"regexp"
"strings"
)
// 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"
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.
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.
func ValidID(id string) bool { return idShape.MatchString(id) }
// HostPrefix is every seed of host: seeds/<host>/.
func HostPrefix(host string) string { return Prefix + host + "/" }
// SnapshotPrefix is one seed's prefix: seeds/<host>/<id>/. Every delete of
// a snapshot names it (codex R4-1).
func SnapshotPrefix(host, id string) string { 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[0] == "" || !ValidID(parts[1]) || parts[2] != snapshotName {
return "", "", false
}
return parts[0], parts[1], true
}internal/seeds/token.go:
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
}internal/seeds/row.go:
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
}- [ ] Step 4: Run the tests to verify they pass
Run: go test ./internal/seeds/ -count=1 Expected: PASS.
- [ ] Step 5: Commit
git add internal/seeds/
git commit -m "seeds: ids, keys, states, the verify token and the row column (Plan 2 Task 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 2: SeedSource on the record, protected from ordinary writers
Files:
- Create:
internal/provision/seed.go - Modify:
internal/provision/provision.go:38-192(Record),:890-925(updateRecord),:1873-1898(claimRawUpdateRecord) - Test:
internal/provision/seed_test.go
If the run series (2026-10-08-carlos-run-*.md) has already merged a minimal SeedSource (with Kind and State), keep its declaration's place and json tags and add the missing fields there instead of declaring the type again; every field below must end up present with exactly these tags.
Interfaces:
- Consumes:
seeds.KindImport, theseeds.State*constants. - Produces:
Record.Seed *SeedSource(json:"seed,omitempty");type SeedSource struct{ID, Kind, From, TXID, SHA256 string; Bytes int64; State, Reason, By string; At time.Time; Replaces, VerifyHash string}with the spec's json tags;(*SeedSource).Import() bool;(*SeedSource).Abandoned() bool;(*SeedSource).BlocksRun() bool;(*SeedSource).MoveBlock() stringreturning"",SeedMoveUnplacedorSeedMoveUncommitted;ErrSeedProtected;cloneSeed,seedEqual(unexported).
- [ ] Step 1: Write the failing tests
internal/provision/seed_test.go:
package provision
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/seeds"
)
func seedTestRecord(t *testing.T, s release.Store, seed *SeedSource) Record {
t.Helper()
rec := Record{
Schema: 1, Host: "canary.go.tito.io", Slug: "canary", App: "titogo", Account: "bae",
Channel: "stable", CreatedAt: time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC), Seed: seed,
}
if err := CreateRecord(context.Background(), s, rec); err != nil {
t.Fatal(err)
}
return rec
}
func testSeed(t *testing.T, kind, state string) *SeedSource {
t.Helper()
id, err := seeds.NewID()
if err != nil {
t.Fatal(err)
}
return &SeedSource{ID: id, Kind: kind, State: state, By: "paul@rslw.com",
At: time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)}
}
func TestSeedWireShapeIsAdditive(t *testing.T) {
const legacy = `{"schema":1,"host":"acme.go.tito.io","slug":"acme","app":"tito",` +
`"account":"bab","channel":"stable","created_at":"2026-08-04T09:00:00Z"}`
var rec Record
if err := json.Unmarshal([]byte(legacy), &rec); err != nil {
t.Fatal(err)
}
if rec.Seed != nil {
t.Fatalf("Seed = %+v, want nil on a record without one", rec.Seed)
}
out, _ := json.Marshal(rec)
if strings.Contains(string(out), `"seed"`) {
t.Fatalf("a seedless record encodes a seed key: %s", out)
}
rec.Seed = &SeedSource{ID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: seeds.KindImport, State: seeds.StateAwaitingUpload,
Replaces: "127.0.0.1:8401", VerifyHash: strings.Repeat("b", 64)}
out, _ = json.Marshal(rec)
for _, want := range []string{`"seed":{"id":"sd_`, `"kind":"import"`, `"state":"awaiting-upload"`, `"replaces":"127.0.0.1:8401"`, `"verify_hash":"bbbb`} {
if !strings.Contains(string(out), want) {
t.Errorf("encoded record lacks %s: %s", want, out)
}
}
}
func TestOrdinaryWritersCannotChangeTheSeed(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
rec := seedTestRecord(t, s, testSeed(t, seeds.KindReplica, seeds.StateReady))
// Through the pointer the mutation is handed: the scaffold's copy must
// be deep, or this lands.
_, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error {
r.Seed.State = seeds.StatePlaced
return nil
})
if !errors.Is(err, ErrSeedProtected) {
t.Fatalf("an in-place seed edit through updateRecord: err = %v, want ErrSeedProtected", err)
}
_, err = updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error {
r.Seed = nil
return nil
})
if !errors.Is(err, ErrSeedProtected) {
t.Fatalf("dropping the seed through updateRecord: err = %v, want ErrSeedProtected", err)
}
// An ordinary write that leaves the seed alone carries it through.
if err := SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, "ie"); err != nil {
t.Fatal(err)
}
got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
if err != nil || got.Seed == nil || got.Seed.ID != rec.Seed.ID || got.Seed.State != seeds.StateReady {
t.Fatalf("after SetRecordRegion: seed = %+v, %v; want it carried through", got.Seed, err)
}
// The etag-guarded writer refuses too.
cur, etag, err := GetRecordETag(ctx, s, rec.Account, rec.App, rec.Host)
if err != nil {
t.Fatal(err)
}
cur.Seed.State = seeds.StateAborted
if err := claimRawUpdateRecord(ctx, s, *cur, etag); !errors.Is(err, ErrSeedProtected) {
t.Fatalf("claimRawUpdateRecord with a changed seed: err = %v, want ErrSeedProtected", err)
}
}
func TestSeedPredicates(t *testing.T) {
var none *SeedSource
if none.Import() || none.BlocksRun() || none.MoveBlock() != "" || none.Abandoned() {
t.Error("a nil seed must block nothing")
}
for _, c := range []struct {
kind, state string
blocksRun bool
moveBlock string
abandoned bool
}{
{seeds.KindReplica, seeds.StateReserving, false, SeedMoveUnplaced, false},
{seeds.KindReplica, seeds.StateReady, false, SeedMoveUnplaced, false},
{seeds.KindReplica, seeds.StatePlaced, false, "", false},
{seeds.KindReplica, seeds.StateAborted, false, SeedMoveUnplaced, true},
{seeds.KindImport, seeds.StateAwaitingUpload, true, SeedMoveUncommitted, false},
{seeds.KindImport, seeds.StateSwapped, true, SeedMoveUncommitted, false},
{seeds.KindImport, seeds.StatePlaced, true, SeedMoveUncommitted, false},
{seeds.KindImport, seeds.StateCommitted, false, "", false},
{seeds.KindImport, seeds.StateRolledBack, true, SeedMoveUncommitted, true},
} {
s := &SeedSource{Kind: c.kind, State: c.state}
if s.BlocksRun() != c.blocksRun || s.MoveBlock() != c.moveBlock || s.Abandoned() != c.abandoned {
t.Errorf("%s %s: BlocksRun %v MoveBlock %q Abandoned %v; want %v %q %v",
c.kind, c.state, s.BlocksRun(), s.MoveBlock(), s.Abandoned(), c.blocksRun, c.moveBlock, c.abandoned)
}
}
}- [ ] Step 2: Run the tests to verify they fail
Run: go test ./internal/provision/ -run 'TestSeed|TestOrdinaryWriters' -count=1 Expected: FAIL to compile: rec.Seed undefined, undefined: SeedSource, undefined: ErrSeedProtected.
- [ ] Step 3: Add the field, the type and the protection
In internal/provision/provision.go, inside Record, directly after LastMove:
// Seed is where this instance's database started (seeded-instances spec
// §3.2): a sibling's replica (--from) or an uploaded snapshot (import).
// Additive and omitempty. Like Maintenance, it is preserved by ordinary
// writers and changed only through SetRecordSeed's named transitions
// (seed.go); it stays on the record after placement as provenance.
Seed *SeedSource `json:"seed,omitempty"`internal/provision/seed.go:
package provision
import (
"errors"
"time"
"github.com/carlosframework/platform/internal/seeds"
)
// SeedSource is a record's seed (seeded-instances spec §3.2).
type SeedSource struct {
ID string `json:"id"` // random, minted with the seed; never reused
Kind string `json:"kind"` // seeds.KindReplica | seeds.KindImport
From string `json:"from,omitempty"` // replica: the source host
TXID string `json:"txid,omitempty"` // replica: the TXID actually restored
SHA256 string `json:"sha256"` // of snapshot.db
Bytes int64 `json:"bytes"`
State string `json:"state"` // seeds.State*
Reason string `json:"reason,omitempty"` // set with refused
By string `json:"by"` // the caller
At time.Time `json:"at"`
// import only:
Replaces string `json:"replaces,omitempty"` // the addr the swap replaces
VerifyHash string `json:"verify_hash,omitempty"` // sha256 of the verify token (§5.4)
}
// ErrSeedProtected is an ordinary record writer trying to change Seed.
var ErrSeedProtected = errors.New("provision: the seed changes only through a seed transition")
// The two reasons a seed refuses a move (spec §5.8). Plan 8 gives each its
// sentence.
const (
SeedMoveUnplaced = "unplaced"
SeedMoveUncommitted = "uncommitted"
)
// Import reports whether the seed is an import.
func (s *SeedSource) Import() bool { return s != nil && s.Kind == seeds.KindImport }
// Abandoned reports an aborted seed, before or after the box rolled it back.
func (s *SeedSource) Abandoned() bool {
return s != nil && (s.State == seeds.StateAborted || s.State == seeds.StateRolledBack)
}
// BlocksRun is run admission's predicate (run spec §5.1 step 9, codex
// R2-2): an import that is not committed refuses carlos run.
func (s *SeedSource) BlocksRun() bool { return s.Import() && s.State != seeds.StateCommitted }
// MoveBlock is the move doors' and workers' predicate (spec §5.8): a
// --from seed moves only once placed, an import only once committed.
func (s *SeedSource) MoveBlock() string {
switch {
case s == nil:
return ""
case s.Import():
if s.State != seeds.StateCommitted {
return SeedMoveUncommitted
}
case s.State != seeds.StatePlaced:
return SeedMoveUnplaced
}
return ""
}
func cloneSeed(s *SeedSource) *SeedSource {
if s == nil {
return nil
}
c := *s
return &c
}
func seedEqual(a, b *SeedSource) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
x, y := *a, *b
if !x.At.Equal(y.At) {
return false
}
x.At, y.At = time.Time{}, time.Time{}
return x == y
}In updateRecord (provision.go:890), split the scaffold so the seed transitions can share it:
func updateRecord(ctx context.Context, s release.Store, acct, app, host string, mut func(*Record) error) (*Record, error) {
return updateRecordSeeding(ctx, s, acct, app, host, mut, false)
}
// updateRecordSeeding is updateRecord's scaffold. seedWrite is true only for
// SetRecordSeed: every other caller is refused a change to Seed.
func updateRecordSeeding(ctx context.Context, s release.Store, acct, app, host string, mut func(*Record) error, seedWrite bool) (*Record, error) {
key := RecordKey(acct, app, host)
update := func() (*Record, error) {
raw, etag, err := s.Get(ctx, key)
if err != nil {
return nil, err
}
rec, err := DecodeRecord(raw)
if err != nil {
return nil, fmt.Errorf("provision: record %s/%s/%s: %w", acct, app, host, err)
}
if rec.Account != acct || rec.App != app || rec.Host != host {
return nil, ErrMaintenanceProjection
}
before := rec
before.Maintenance = append(MaintenanceProjection(nil), rec.Maintenance...)
before.Seed = cloneSeed(rec.Seed)
if err := mut(&rec); err != nil {
return nil, err
}
if !protectedFieldsEqual(before, rec) {
return nil, ErrMaintenanceProjection
}
if !seedWrite && !seedEqual(before.Seed, rec.Seed) {
return nil, ErrSeedProtected
}
body, err := json.Marshal(rec)
if err != nil {
return nil, err
}
if _, err := s.Put(ctx, key, body, etag); err != nil {
return nil, err
}
return &rec, nil
}
rec, err := update()
if errors.Is(err, release.ErrCASConflict) {
return update() // one retry on a lost race; a second loss surfaces
}
return rec, err
}In claimRawUpdateRecord (provision.go:1873), after the protectedFieldsEqual check:
if !seedEqual(before.Seed, rec.Seed) {
return ErrSeedProtected
}- [ ] Step 4: Run the tests to verify they pass
Run: go test ./internal/provision/ -run 'TestSeed|TestOrdinaryWriters|TestRecordWireShapeIsAdditive|TestCreateRecord|TestMove' -count=1 Expected: PASS.
- [ ] Step 5: Run the console's record writers' tests
The PATCH door, the gate door and the channel door all rewrite records through UpdateRecord. None touches Seed, so none changes.
Run: go test ./internal/console/ -run 'TestAPIInstanceUpdate|TestAppGate|TestInstanceChannel' -count=1 Expected: PASS.
- [ ] Step 6: Commit
git add internal/provision/seed.go internal/provision/seed_test.go internal/provision/provision.go
git commit -m "provision: the record's seed, protected from ordinary writers (Plan 2 Task 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 3: The seed transitions and the row column
Files:
- Modify:
internal/provision/seed.go - Test:
internal/provision/seed_test.go
Interfaces:
- Consumes:
updateRecordSeeding(Task 2),seeds.RowSeed. - Produces:
ErrSeedConflict;SetRecordSeed(ctx context.Context, s release.Store, acct, app, host string, mut func(*Record) error) (*Record, error); the transitionsSeedPublishReplica(id, txid, sha string, bytes int64),SeedUploaded(id string),SeedStopped(id string),SeedSwapped(id string),SeedPlace(id string),SeedCommit(id string),SeedRefuse(id, reason string),SeedAbort(id string),SeedRolledBack(id string),SeedRolledBackBeforeSwap(id string), eachfunc(*Record) error;SeedColumn(rec Record) string.
The transition table (spec §3.2, §5.6):
| transition | kind | from | to | |---|---|---|---| | SeedPublishReplica | replica | reserving | ready (sets TXID, SHA256, Bytes) | | SeedUploaded | import | awaiting-upload | uploaded | | SeedStopped | import | uploaded | ready | | SeedSwapped | import | ready | swapped | | SeedPlace | replica | ready | placed | | SeedPlace | import | swapped | placed | | SeedCommit | import | placed | committed | | SeedRefuse | import | ready | refused (sets Reason) | | SeedAbort | replica | reserving, ready | aborted | | SeedAbort | import | awaiting-upload, uploaded, ready, swapped, placed, refused | aborted | | SeedRolledBack | either | aborted | rolled-back | | SeedRolledBackBeforeSwap | import | awaiting-upload, uploaded, refused | rolled-back |
- [ ] Step 1: Write the failing tests
Append to internal/provision/seed_test.go:
func TestSeedImportWalksItsPath(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
seed := testSeed(t, seeds.KindImport, seeds.StateAwaitingUpload)
rec := seedTestRecord(t, s, seed)
for _, step := range []struct {
mut func(*Record) error
want string
}{
{SeedUploaded(seed.ID), seeds.StateUploaded},
{SeedStopped(seed.ID), seeds.StateReady},
{SeedSwapped(seed.ID), seeds.StateSwapped},
{SeedPlace(seed.ID), seeds.StatePlaced},
{SeedCommit(seed.ID), seeds.StateCommitted},
} {
got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, step.mut)
if err != nil || got.Seed.State != step.want {
t.Fatalf("to %s: %+v, %v", step.want, got, err)
}
}
// After commit, abort is refused (spec §5.6).
if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); !errors.Is(err, ErrSeedConflict) {
t.Fatalf("abort after commit: err = %v, want ErrSeedConflict", err)
}
}
func TestSeedReplicaPublishesWhatWasRestored(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
seed := testSeed(t, seeds.KindReplica, seeds.StateReserving)
seed.From = "acme.go.tito.io"
rec := seedTestRecord(t, s, seed)
sha := strings.Repeat("c", 64)
got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedPublishReplica(seed.ID, "4211", sha, 8192))
if err != nil || got.Seed.State != seeds.StateReady || got.Seed.TXID != "4211" || got.Seed.SHA256 != sha || got.Seed.Bytes != 8192 {
t.Fatalf("publish: %+v, %v", got.Seed, err)
}
if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedCommit(seed.ID)); !errors.Is(err, ErrSeedConflict) {
t.Fatalf("commit of a replica seed: err = %v, want ErrSeedConflict", err)
}
}
func TestSeedPlaceFollowsTheKind(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
seed := testSeed(t, seeds.KindImport, seeds.StateReady)
rec := seedTestRecord(t, s, seed)
if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedPlace(seed.ID)); !errors.Is(err, ErrSeedConflict) {
t.Fatalf("an import placed from ready: err = %v, want ErrSeedConflict", err)
}
}
func TestSeedTransitionsAreScopedToTheirID(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
seed := testSeed(t, seeds.KindImport, seeds.StateAborted)
rec := seedTestRecord(t, s, seed)
other, _ := seeds.NewID()
if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(other)); !errors.Is(err, ErrSeedConflict) {
t.Fatalf("another id's rollback: err = %v, want ErrSeedConflict", err)
}
if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(seed.ID)); err != nil {
t.Fatalf("its own rollback: %v", err)
}
}
func TestSeedAbortAndRollbackBeforeSwap(t *testing.T) {
ctx := context.Background()
for _, state := range []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused} {
s := release.NewDirStore(t.TempDir())
seed := testSeed(t, seeds.KindImport, state)
rec := seedTestRecord(t, s, seed)
got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBackBeforeSwap(seed.ID))
if err != nil || got.Seed.State != seeds.StateRolledBack {
t.Fatalf("%s: %+v, %v", state, got, err)
}
}
for _, state := range []string{seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced} {
s := release.NewDirStore(t.TempDir())
seed := testSeed(t, seeds.KindImport, state)
rec := seedTestRecord(t, s, seed)
if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBackBeforeSwap(seed.ID)); !errors.Is(err, ErrSeedConflict) {
t.Fatalf("%s straight to rolled-back: err = %v, want ErrSeedConflict", state, err)
}
if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); err != nil {
t.Fatalf("%s abort: %v", state, err)
}
}
}
func TestSeedRefuseRecordsItsReason(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
seed := testSeed(t, seeds.KindImport, seeds.StateReady)
rec := seedTestRecord(t, s, seed)
got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRefuse(seed.ID, "the route belongs to app kass"))
if err != nil || got.Seed.State != seeds.StateRefused || got.Seed.Reason != "the route belongs to app kass" {
t.Fatalf("refuse: %+v, %v", got.Seed, err)
}
}
func TestSeedColumn(t *testing.T) {
if SeedColumn(Record{}) != "" {
t.Error("a seedless record has a column value")
}
seed := testSeed(t, seeds.KindReplica, seeds.StateReady)
seed.SHA256, seed.Bytes = strings.Repeat("d", 64), 12
got, err := seeds.ParseRowSeed(SeedColumn(Record{Seed: seed}))
if err != nil || got == nil || got.ID != seed.ID || got.Kind != seeds.KindReplica || got.SHA256 != seed.SHA256 || got.Bytes != 12 || got.State != seeds.StateReady {
t.Fatalf("column = %+v, %v", got, err)
}
}- [ ] Step 2: Run the tests to verify they fail
Run: go test ./internal/provision/ -run 'TestSeed' -count=1 Expected: FAIL to compile: undefined: SetRecordSeed, undefined: SeedUploaded, and so on.
- [ ] Step 3: Write the transitions
Append to internal/provision/seed.go (add "context", "fmt", "slices" and "github.com/carlosframework/platform/internal/release" to its imports):
// ErrSeedConflict is a seed transition the record's seed does not allow:
// another seed's id, the wrong kind, or the wrong state. It is the answer,
// not a race (a race is retried once by the scaffold, which re-reads and
// re-applies the transition).
var ErrSeedConflict = errors.New("provision: seed transition refused")
// SetRecordSeed applies one seed transition to the record by CAS on its
// etag, retrying once on a lost race (updateRecord's scaffold). It is the
// only writer of Record.Seed after creation.
func SetRecordSeed(ctx context.Context, s release.Store, acct, app, host string, mut func(*Record) error) (*Record, error) {
return updateRecordSeeding(ctx, s, acct, app, host, mut, true)
}
// seedStep is one row of the transition table: the record's seed must be
// id, of kind (any kind when ""), in one of from.
func seedStep(id, kind string, from []string, to string, also func(*SeedSource)) func(*Record) error {
return func(rec *Record) error {
switch {
case rec.Seed == nil || rec.Seed.ID != id:
return fmt.Errorf("%w: the record's seed is not %s", ErrSeedConflict, id)
case kind != "" && rec.Seed.Kind != kind:
return fmt.Errorf("%w: seed %s is a %s seed", ErrSeedConflict, id, rec.Seed.Kind)
case !slices.Contains(from, rec.Seed.State):
return fmt.Errorf("%w: seed %s is %s", ErrSeedConflict, id, rec.Seed.State)
}
rec.Seed.State = to
if also != nil {
also(rec.Seed)
}
return nil
}
}
// SeedPublishReplica is --from's publish (spec §4 step 7.3): reserving to
// ready, with what the restore actually produced.
func SeedPublishReplica(id, txid, sha string, bytes int64) func(*Record) error {
return seedStep(id, seeds.KindReplica, []string{seeds.StateReserving}, seeds.StateReady, func(s *SeedSource) {
s.TXID, s.SHA256, s.Bytes = txid, sha, bytes
})
}
// SeedUploaded is the upload's last step (spec §5.2 step 5).
func SeedUploaded(id string) func(*Record) error {
return seedStep(id, seeds.KindImport, []string{seeds.StateAwaitingUpload}, seeds.StateUploaded, nil)
}
// SeedStopped is the app saying its own process is down (spec §5.3).
func SeedStopped(id string) func(*Record) error {
return seedStep(id, seeds.KindImport, []string{seeds.StateUploaded}, seeds.StateReady, nil)
}
// SeedSwapped is the box's CAS after its swap transaction (spec §5.4).
func SeedSwapped(id string) func(*Record) error {
return seedStep(id, seeds.KindImport, []string{seeds.StateReady}, seeds.StateSwapped, nil)
}
// SeedPlace is the box's CAS after the first upload of the host's own
// replica (spec §3.4 step 5): a --from seed from ready, an import from
// swapped.
func SeedPlace(id string) func(*Record) error {
return func(rec *Record) error {
if rec.Seed == nil {
return fmt.Errorf("%w: the record has no seed", ErrSeedConflict)
}
from := seeds.StateReady
if rec.Seed.Import() {
from = seeds.StateSwapped
}
return seedStep(id, "", []string{from}, seeds.StatePlaced, nil)(rec)
}
}
// SeedCommit is the app's verdict that the instance serves (spec §5.4):
// refused before placed, because nothing has been verified yet.
func SeedCommit(id string) func(*Record) error {
return seedStep(id, seeds.KindImport, []string{seeds.StatePlaced}, seeds.StateCommitted, nil)
}
// SeedRefuse is the box declining the swap, naming why (spec §5.4).
func SeedRefuse(id, reason string) func(*Record) error {
return seedStep(id, seeds.KindImport, []string{seeds.StateReady}, seeds.StateRefused, func(s *SeedSource) {
s.Reason = reason
})
}
// SeedAbort abandons a seed (spec §5.6, §4 step 7): an import from any
// state before committed, a --from seed while reserving or ready.
func SeedAbort(id string) func(*Record) error {
return func(rec *Record) error {
if rec.Seed == nil {
return fmt.Errorf("%w: the record has no seed", ErrSeedConflict)
}
from := []string{seeds.StateReserving, seeds.StateReady}
if rec.Seed.Import() {
from = []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateReady,
seeds.StateSwapped, seeds.StatePlaced, seeds.StateRefused}
}
return seedStep(id, "", from, seeds.StateAborted, nil)(rec)
}
}
// SeedRolledBack is the box's last word on an aborted seed (spec §5.6 step
// 8, and §3.2's no-journal path). The console finishes from here.
func SeedRolledBack(id string) func(*Record) error {
return seedStep(id, "", []string{seeds.StateAborted}, seeds.StateRolledBack, nil)
}
// SeedRolledBackBeforeSwap is the delete door's shortcut for an import no
// box has swapped (spec §5.6, codex R2-7).
func SeedRolledBackBeforeSwap(id string) func(*Record) error {
return seedStep(id, seeds.KindImport, []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused},
seeds.StateRolledBack, nil)
}
// SeedColumn is the registry column a record's row carries (seed_source):
// "" for a seedless record.
func SeedColumn(rec Record) string {
if rec.Seed == nil {
return ""
}
return seeds.RowSeed{ID: rec.Seed.ID, Kind: rec.Seed.Kind, SHA256: rec.Seed.SHA256,
Bytes: rec.Seed.Bytes, State: rec.Seed.State}.Encode()
}- [ ] Step 4: Run the tests to verify they pass
Run: go test ./internal/provision/ -run 'TestSeed|TestOrdinaryWriters' -count=1 Expected: PASS.
- [ ] Step 5: Run the package and vet
Run: go vet ./internal/provision/ ./internal/seeds/ && nice go test ./internal/provision/ ./internal/seeds/ -count=1 Expected: PASS.
- [ ] Step 6: Commit
git add internal/provision/seed.go internal/provision/seed_test.go
git commit -m "provision: the seed transitions and the row column (Plan 2 Task 3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 4: Plan gate
- [ ] Step 1: Cross-compile and run the touched packages under -race
Run:
GOOS=darwin go build ./... && GOOS=windows go build ./... && \
nice go test -race ./internal/seeds/ ./internal/provision/ ./internal/console/ -count=1Expected: builds clean; tests PASS.
Nothing to commit: the gate runs on the plan's last commit.
---
Self-review
- Spec coverage: §3.2 record and every state (Tasks 2, 3); the transition table matches §3.2's paths, §4 step 7's abort of a failed
--from, and §5.6's abort, rolled-back and delete shortcut; §3.1's seed-ID-scoped keys (Task 1); §5.1's hashed token (Task 1); §5.8 and run §5.1 step 9 predicates (Task 2). Reconcile's dispatch table (§3.2) is Plan 4 and Plan 7. SetRecordSeedIfMatchis not needed: the scaffold's one retry re-reads the record and re-applies the transition, which then refuses on the state the winner wrote, so a commit and an abort racing on the etag still end with exactly one winner (spec §5.6).- Types:
SeedSource's tags match spec §3.2 exactly;RowSeedis Plan 4's column.