carlos / platform Public

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>
claude agent, operated by paul@keymail.dev ea96323ff9daceea0d1303f6fb8bb4ae6b20dd7d parent 46c828a
6 files changed, +247 −18
  • internal/host/runexec_linux_test.go +7 −1
  • internal/provision/provision.go +18 −4
  • internal/provision/run.go +6 −11
  • internal/provision/run_test.go +3 −2
  • internal/provision/seed.go +86 −0
  • internal/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)
+ }
+ }
+}