provision: the record's seed, protected from ordinary writers (Plan 2 Task 2)
SeedSource moves from run.go to seed.go with the spec's fields; the run series' Kind and State tags and Record.Seed stay. updateRecord and claimRawUpdateRecord refuse a changed Seed (deep copy taken before the mutation runs); updateRecordSeeding is the scaffold the seed transitions will use. Two run-series tests that set a seed through an ordinary writer now write it through the seed scaffold or the store.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+247
−18
internal/host/runexec_linux_test.go+7 −1internal/provision/provision.go+18 −4internal/provision/run.go+6 −11internal/provision/run_test.go+3 −2internal/provision/seed.go+86 −0internal/provision/seed_test.go+127 −0
CI passed — run details
diff --git a/internal/host/runexec_linux_test.go b/internal/host/runexec_linux_test.go| index 032b83237..7e3082b7a 100644 |
| --- a/internal/host/runexec_linux_test.go |
| +++ b/internal/host/runexec_linux_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| + "encoding/json" |
| "errors" |
| "io" |
| "os" |
| @@ -387,7 +388,12 @@ func TestRunRefusesAnImportUncommittedAtLaunch(t *testing.T) { |
| t.Fatal(err) |
| } |
| cur.Seed = &provision.SeedSource{Kind: provision.SeedKindImport, State: "placed"} |
| - if err := provision.UpdateRecord(context.Background(), s, *cur, etag); err != nil { |
| + // A seed is written by a seed transition, not UpdateRecord: put the body. |
| + body, err := json.Marshal(cur) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(context.Background(), provision.RecordKey(cur.Account, cur.App, cur.Host), body, etag); err != nil { |
| t.Fatal(err) |
| } |
| if res := runWith(t, s, &fakeLauncher{handle: &fakeHandle{}}, rec, req, newRunHandle()); res.Refused != runs.RefusedImport { |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 95eaa79db..56bac8d73 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -73,10 +73,11 @@ type Record struct { |
| // (run.go) write it. Additive and omitempty. |
| ActiveRun *RunState `json:"run,omitempty"` |
| - // Seed is the record's seed provenance (seeded-instances spec §3.2). |
| - // This series reads Kind and State only, to refuse runs on an import |
| - // that is not committed (run spec §5.1 step 9); the seeded-instances |
| - // plan adds the rest of SeedSource. Additive and omitempty. |
| + // 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 the named seed transitions (seed.go); |
| + // it stays on the record after placement as provenance. |
| Seed *SeedSource `json:"seed,omitempty"` |
| // --- shape fields (2026-08-10 route-records design §3) -------------- |
| @@ -902,6 +903,12 @@ func SetRecordRegion(ctx context.Context, s release.Store, acct, app, host, regi |
| // re-applying a narrow mutation on a lost race changes nothing about what |
| // it meant. Returns the record as written. |
| 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 |
| +// the seed transitions: 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) |
| @@ -917,12 +924,16 @@ func updateRecord(ctx context.Context, s release.Store, acct, app, host string, |
| } |
| before := rec |
| before.Maintenance = append(MaintenanceProjection(nil), rec.Maintenance...) |
| + before.Seed = cloneSeed(rec.Seed) // deep: mut edits rec.Seed through its pointer |
| 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 |
| @@ -1905,6 +1916,9 @@ func claimRawUpdateRecord(ctx context.Context, s release.Store, rec Record, etag |
| if !protectedFieldsEqual(*before, rec) { |
| return ErrMaintenanceProjection |
| } |
| + if !seedEqual(before.Seed, rec.Seed) { |
| + return ErrSeedProtected |
| + } |
| body, err := json.Marshal(rec) |
| if err != nil { |
| return err |
diff --git a/internal/provision/run.go b/internal/provision/run.go| index b1c53d8c0..4d146dcf9 100644 |
| --- a/internal/provision/run.go |
| +++ b/internal/provision/run.go |
| @@ -7,6 +7,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| ) |
| // RunState is a `carlos run` on the instance record (run spec §5.1). Box |
| @@ -39,24 +40,18 @@ func (r *RunState) Open() bool { |
| return r != nil && (r.State == RunQueued || r.State == RunPicked || r.State == RunCancel) |
| } |
| -// SeedSource is the part of the seeded-instances spec's SeedSource that |
| -// runs read (its §3.2). The seeded-instances plan extends this type; Kind |
| -// and State keep these json tags. |
| -type SeedSource struct { |
| - Kind string `json:"kind"` |
| - State string `json:"state"` |
| -} |
| - |
| +// The seed kind and state the run series reads; the values are the seeds |
| +// package's. |
| const ( |
| - SeedKindImport = "import" |
| - SeedCommitted = "committed" |
| + SeedKindImport = seeds.KindImport |
| + SeedCommitted = seeds.StateCommitted |
| ) |
| // ImportUncommitted reports whether the record's seed is an import that is |
| // not committed yet (seeded spec §5.4): a run is refused then, at the door |
| // and again at launch (run spec §5.1 step 9, codex R2-2). |
| func (r Record) ImportUncommitted() bool { |
| - return r.Seed != nil && r.Seed.Kind == SeedKindImport && r.Seed.State != SeedCommitted |
| + return r.Seed.BlocksRun() |
| } |
| var ( |
diff --git a/internal/provision/run_test.go b/internal/provision/run_test.go| index 5f8243a78..7c53a995f 100644 |
| --- a/internal/provision/run_test.go |
| +++ b/internal/provision/run_test.go |
| @@ -125,10 +125,11 @@ func TestRunAdmitRefusesAMoveAndAnUncommittedImport(t *testing.T) { |
| s2 := release.NewDirStore(t.TempDir()) |
| imp := runTestRecord(t, s2) |
| for state, refused := range map[string]bool{"swapped": true, "placed": true, "committed": false} { |
| - if _, err := updateRecord(context.Background(), s2, imp.Account, imp.App, imp.Host, func(r *Record) error { |
| + // Setting a seed is a seed transition's job; ordinary writers are refused. |
| + if _, err := updateRecordSeeding(context.Background(), s2, imp.Account, imp.App, imp.Host, func(r *Record) error { |
| r.Seed, r.ActiveRun = &SeedSource{Kind: SeedKindImport, State: state}, nil |
| return nil |
| - }); err != nil { |
| + }, true); err != nil { |
| t.Fatal(err) |
| } |
| _, err := setRun(t, s2, imp, RunAdmit(RunAdmission{ID: "run_" + state, Deadline: at})) |
diff --git a/internal/provision/seed.go b/internal/provision/seed.go| new file mode 100644 |
| index 000000000..64febc2b4 |
| --- /dev/null |
| +++ b/internal/provision/seed.go |
| @@ -0,0 +1,86 @@ |
| +package provision |
| + |
| +import ( |
| + "errors" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/seeds" |
| +) |
| + |
| +// SeedSource is a record's seed (seeded-instances spec §3.2). The run series |
| +// declared its Kind and State; those keep their json tags. |
| +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 "" |
| +} |
| + |
| +// cloneSeed is a deep copy: SeedSource holds no pointers, so a struct copy |
| +// is one. |
| +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 |
| +} |
diff --git a/internal/provision/seed_test.go b/internal/provision/seed_test.go| new file mode 100644 |
| index 000000000..9aad56346 |
| --- /dev/null |
| +++ b/internal/provision/seed_test.go |
| @@ -0,0 +1,127 @@ |
| +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.example.com", Slug: "canary", App: "shop", 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.example.com","slug":"acme","app":"shop",` + |
| + `"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) |
| + } |
| + } |
| +} |