carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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 linux plus a !linux stub.
  • 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 use GOCACHE=$TMPDIR/gocache re-exported per command. Socket tests need a short TMPDIR (TMPDIR=/tmp/g). The local gate may need GOFLAGS=-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 provision are 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: seed is omitempty, and a record without it decodes and re-encodes byte for byte as before.

Review Focus

  1. A mutation passed to updateRecord (or UpdateRecord) that edits rec.Seed through 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).
  2. A transition naming another seed ID than 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).
  3. An import must never go from ready straight to placed: placement of an import follows its swap. Test in Task 3 (TestSeedPlaceFollowsTheKind).
  4. 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).
  5. 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.db or 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, the seeds.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() string returning "", SeedMoveUnplaced or SeedMoveUncommitted; 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 transitions SeedPublishReplica(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), each func(*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=1

Expected: 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.
  • SetRecordSeedIfMatch is 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; RowSeed is Plan 4's column.