carlos / platform Public
Seeded Instances, Plan 3: The Seeds Floor 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: Make sure no box can read a seeded record as an ordinary new instance and mint it empty: a seeds protocol floor that boxes arm and never recover below, and a console check that writes no seed state until every box in the home region has armed it, runs nested and has no claim control.
Architecture: The seeds floor reuses the moves floor's mechanism (internal/host/movesfloor.go) beside it rather than inside it: its own object (control/seeds-protocol.json), its own two local files in the same .moves directory under the same floor.lock, its own verdict on carlos.prev (/opt/carlos/carlos.prev.seeds-allowed), a second clause in both system units, and a second check in the activation rule. The box publishes three new edges-file fields. The console's seedRegionReady reads the published floor, the region's edges files and the region's status heartbeats (the layout report and the claim flag), and answers a refusal or nil. seeds.ProtocolVersion starts at 0, so no box built from this plan can satisfy the console's >= 1; Plan 7 raises it.
Tech Stack: Go 1.25, release.Store, the host package's movesDir (Linux; movesfloor_other.go stubs it elsewhere), status.LoadBoxes, /bin/sh for the unit test.
Spec: docs/superpowers/specs/2026-10-08-seeded-instances-design.md rev 7: §3.3 (codex 16), §6.3 (layout and claims at the console), §10 steps 1 and 2. Index: 2026-10-08-seeded-instances-0-index.md. Copy deck rows SD-N1 to SD-N13, SD-N112 and SD-N113.
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>. - No new file in this series imports
internal/moves(the moves copy gate inventories every changed file that does,approved_moves_copy_test.go:984).internal/seedskeeps its ownFloorandReleaseAtOrAbove. - New sentences go in new files (
internal/host/seedsfloor.go,internal/console/seedfloor.go,system_seeds.go), never in a file listed inmovesScanFiles(approved_moves_copy_test.go:541): the moves gate'sTestMovesLegacyExemptionsAreLegacyrefuses a new sentence in its legacy-exempt files. TestClaimAuthoritySourceCensus(internal/console/claim_authority_census_test.go) inventories every console call named in itsselectedset (Open,NewS3Store,WithAppMutation, and others); add each new call site this plan writes ininternal/consoleorcmd/consoletointernal/console/testdata/claim-authority-sites.txtin the format the failing test prints.- When a task wires a deck row, append its id to
seededWiredIDsinapproved_seeded_copy_test.goin the same commit. - Write each deck sentence as exactly one string literal or one format string holding the row's text, with
%sor%dat each<placeholder>and nothing else: no prefix, no trailing\n, no leading spaces. The gate's matcher (movesRowSpoken,approved_moves_copy_test.go:811) anchors both ends. Print withfmt.Fprintln(w, sentence)or a" %s\n"format around a separately built sentence.
Review Focus
- A box whose
seeds-floor.min-releasewas written but whoseseeds-floor.jsonwas not (a crash between the two writes) must not let the units pick acarlos.prevthe Go check would refuse. The write order is min-release first, JSON last, as for the moves floor. Test in Task 2 (TestSeedsFloorFilesWriteOrder). - A deployment that never publishes the seeds floor must update and recover exactly as today: no file, no verdict, and the units' new clause is true. Test in Task 3 (
TestSystemUnitsSelectPrevByTheSeedsFloor, case "no seeds floor"). - A box present in the region's edges files but missing from the status heartbeats (or stale there) must count as unconfirmed, never as nested. Test in Task 5 (
TestSeedRegionReadyRefusesAnUnconfirmedBox). - A box in another region that is below the floor, flat or claim-armed must not block a door in this region. Test in Task 5 (
TestSeedRegionReadyIgnoresOtherRegions). - Publishing a lower seeds floor must be refused, and publishing the same one again must succeed without change. Test in Task 1 (
TestPublishFloorOnlyRises).
---
File structure
| path | responsibility | |---|---| | internal/seeds/floor.go | ProtocolVersion, RequiredProtocol, FloorKey, local file names, Floor, ReadFloor, PublishFloor, ReleaseAtOrAbove | | internal/host/seedsfloor.go | the box's local seeds floor, its verdict, the activation refusal, armSeedsFloor, the edges fields | | internal/host/movesfloor.go | movesFloorRefresh and rootJudgePrev also do the seeds floor | | internal/host/systemapply.go | armFloor, notePrev, floorArmed, activationRule, activationGuard, floorAllowsForward call the seeds half | | internal/host/units/carlos-system-recover.service, carlos-system-update.service | the second clause | | internal/host/edgesfile.go | three fields; the loop calls withSeedsFields | | internal/console/instancesplacement.go | the reader's three fields | | internal/console/seedfloor.go | seedRegionReady, handleAPISystemSeedsFloor | | internal/console/authcli.go | the route | | system.go, system_seeds.go | carlos system seeds-floor publish | | docs/site/cli.md | SD-N111 under ### carlos system |
---
Task 1: The floor object
Files:
- Create:
internal/seeds/floor.go - Test:
internal/seeds/floor_test.go
Interfaces:
- Produces:
ProtocolVersion = 0(this build's protocol; Plan 7 raises it to 1);RequiredProtocol = 1(what the console requires of every box);FloorKey = "control/seeds-protocol.json";FloorFile = ".moves/seeds-floor.json";FloorMinReleaseFile = ".moves/seeds-floor.min-release";PrevAllowedFile = "carlos.prev.seeds-allowed";type Floor struct{ Min int; MinRelease string };ReadFloor(ctx, s release.Store) (*Floor, error);ReleaseAtOrAbove(version, minRelease string) (bool, error);type FloorLowerError struct{ Current string };PublishFloor(ctx, s release.Store, f Floor) error.
- [ ] Step 1: Write the failing tests
package seeds
import (
"context"
"errors"
"testing"
"github.com/carlosframework/platform/internal/release"
)
func TestReadFloorAbsentIsNil(t *testing.T) {
s := release.NewDirStore(t.TempDir())
f, err := ReadFloor(context.Background(), s)
if f != nil || err != nil {
t.Fatalf("absent floor = %+v, %v; want nil, nil", f, err)
}
if _, err := s.Put(context.Background(), FloorKey, []byte("{not json"), ""); err != nil {
t.Fatal(err)
}
if _, err := ReadFloor(context.Background(), s); err == nil {
t.Fatal("garbage at the floor key read as no floor")
}
}
func TestPublishFloorOnlyRises(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.27.0"}); err != nil {
t.Fatal(err)
}
if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.27.0"}); err != nil {
t.Fatalf("the same floor again: %v", err)
}
var low *FloorLowerError
if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.26.9"}); !errors.As(err, &low) || low.Current != "v0.27.0" {
t.Fatalf("a lower floor: err = %v, want FloorLowerError naming v0.27.0", err)
}
if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.28.0"}); err != nil {
t.Fatalf("a higher floor: %v", err)
}
f, err := ReadFloor(ctx, s)
if err != nil || f == nil || f.MinRelease != "v0.28.0" || f.Min != RequiredProtocol {
t.Fatalf("floor = %+v, %v", f, err)
}
if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.29.0-rc.1"}); err == nil {
t.Fatal("a prerelease floor published")
}
}
func TestReleaseAtOrAbove(t *testing.T) {
for _, c := range []struct {
v, min string
want bool
err bool
}{
{"v0.27.0", "v0.27.0", true, false},
{"v0.27.1", "v0.27.0", true, false},
{"v0.26.9", "v0.27.0", false, false},
{"v0.28.0-rc.1", "v0.27.0", false, true},
{"v0.27.0", "garbage", false, true},
} {
got, err := ReleaseAtOrAbove(c.v, c.min)
if got != c.want || (err != nil) != c.err {
t.Errorf("ReleaseAtOrAbove(%s, %s) = %v, %v", c.v, c.min, got, err)
}
}
}
func TestProtocolStartsBelowWhatTheConsoleRequires(t *testing.T) {
// Plan 3 ships the floor dark: no box built from it can satisfy the
// console's check. Plan 7 Task 7 raises ProtocolVersion and rewrites
// this test.
if ProtocolVersion >= RequiredProtocol {
t.Fatalf("ProtocolVersion = %d, RequiredProtocol = %d", ProtocolVersion, RequiredProtocol)
}
}- [ ] Step 2: Run the tests to verify they fail
Run: go test ./internal/seeds/ -run 'TestReadFloor|TestPublishFloor|TestReleaseAtOrAbove|TestProtocol' -count=1 Expected: FAIL to compile: undefined: ReadFloor.
- [ ] Step 3: Write
internal/seeds/floor.go
package seeds
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/carlosframework/platform/internal/release"
)
// ProtocolVersion is the seeds protocol this build speaks. 0 until the box
// can place, swap and roll back a seed (Plan 7 raises it to 1): a box that
// reports 0 can never satisfy the console's RequiredProtocol.
const ProtocolVersion = 0
// RequiredProtocol is what the console requires every box in the region to
// report before it writes any seed state (spec §3.3).
const RequiredProtocol = 1
// FloorKey is the operator-published seeds floor (spec §3.3).
const FloorKey = "control/seeds-protocol.json"
// The box's local copy of the floor and its verdict on carlos.prev, beside
// the moves floor's files under the same floor.lock
// (internal/host/movesfloor.go). The two system units read the second and
// third by fixed path.
const (
FloorFile = ".moves/seeds-floor.json" // under the box's DataDir
FloorMinReleaseFile = ".moves/seeds-floor.min-release" // under the box's DataDir
PrevAllowedFile = "carlos.prev.seeds-allowed" // beside carlos.prev in the opt dir
)
// Floor is the seeds floor: the protocol every box must speak, and the
// first release that speaks it.
type Floor struct {
Min int `json:"min"`
MinRelease string `json:"min_release"`
}
// ReadFloor reads the published floor. Absent is (nil, nil); present but
// unreadable is an error, never a silent "no floor".
func ReadFloor(ctx context.Context, s release.Store) (*Floor, error) {
f, _, err := readFloorETag(ctx, s)
return f, err
}
func readFloorETag(ctx context.Context, s release.Store) (*Floor, string, error) {
body, etag, err := s.Get(ctx, FloorKey)
if errors.Is(err, os.ErrNotExist) {
return nil, "", nil
}
if err != nil {
return nil, "", fmt.Errorf("seeds: read floor: %w", err)
}
var f Floor
if err := json.Unmarshal(body, &f); err != nil {
return nil, "", fmt.Errorf("seeds: parse floor: %w", err)
}
return &f, etag, nil
}
// ReleaseAtOrAbove reports whether version is at or above minRelease, both
// vMAJOR.MINOR.PATCH. It fails closed: a prerelease or a malformed value is
// false with an error.
func ReleaseAtOrAbove(version, minRelease string) (bool, error) {
min, ok := release.ParseSemVer(strings.TrimPrefix(minRelease, "v"))
if !ok {
return false, fmt.Errorf("seeds: malformed minimum release %q", minRelease)
}
v, ok := release.ParseSemVer(strings.TrimPrefix(version, "v"))
if !ok {
return false, fmt.Errorf("seeds: unparseable release %q", version)
}
return v.Compare(min) >= 0, nil
}
// FloorLowerError is PublishFloor refusing to lower the floor.
type FloorLowerError struct{ Current string }
func (e *FloorLowerError) Error() string {
return "seeds: the published floor is already " + e.Current
}
// PublishFloor writes the floor by CAS. A floor only rises: a lower
// min_release is a *FloorLowerError; the same one is a no-op success.
func PublishFloor(ctx context.Context, s release.Store, f Floor) error {
if ok, err := ReleaseAtOrAbove(f.MinRelease, f.MinRelease); !ok || err != nil {
return fmt.Errorf("seeds: %q is not a release", f.MinRelease)
}
cur, etag, err := readFloorETag(ctx, s)
if err != nil {
return err
}
if cur != nil {
if ok, _ := ReleaseAtOrAbove(f.MinRelease, cur.MinRelease); !ok {
return &FloorLowerError{Current: cur.MinRelease}
}
if *cur == f {
return nil
}
}
body, err := json.Marshal(f)
if err != nil {
return err
}
_, err = s.Put(ctx, FloorKey, body, etag)
return err
}release.ParseSemVer and SemVer.Compare are the ones internal/moves/floor.go:88 uses.
- [ ] 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/floor.go internal/seeds/floor_test.go
git commit -m "seeds: the floor object, published by CAS and only rising (Plan 3 Task 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 2: The box's local seeds floor and its verdict on carlos.prev
Files:
- Create:
internal/host/seedsfloor.go - Modify:
internal/host/movesfloor.go:447-475(rootJudgePrev,movesFloorRefresh) - Test:
internal/host/seedsfloor_test.go,internal/host/seedsfloor_linux_test.go(//go:build linux)
Interfaces:
- Consumes:
withFloorLock(dataDir string, create bool, fn func(d *movesDir) error) error,(*movesDir).WriteDurable(name string, data []byte, mode os.FileMode) error,openBeneath,readBeneathLine,readLine,writeFileDurable,removeDurable,errFloorWrite,prevVersionPath,systemPrevPath,withPrevLock,release.OptDir(),dataDir()(all ininternal/host). - Produces:
readLocalSeedsFloor(dataDir string) (*seeds.Floor, error);writeSeedsFloorFiles(d *movesDir, f seeds.Floor) error;syncLocalSeedsFloor(ctx, s release.Store, dataDir string) (*seeds.Floor, error);seedsPrevAllowedPath(optDir string) string;judgeSeedsPrev(optDir, dataDir string) error;seedsFloorArmed(dataDir string) bool.
- [ ] Step 1: Write the failing tests
internal/host/seedsfloor_linux_test.go (the floor files are written through movesDir, which is Linux-only):
//go:build linux
package host
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/seeds"
)
func publishSeedsFloor(t *testing.T, s release.Store, rel string) {
t.Helper()
if err := seeds.PublishFloor(context.Background(), s, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: rel}); err != nil {
t.Fatal(err)
}
}
func TestSyncLocalSeedsFloorArmsAndOnlyRises(t *testing.T) {
ctx := context.Background()
dd := t.TempDir()
s := release.NewDirStore(t.TempDir())
if f, err := syncLocalSeedsFloor(ctx, s, dd); f != nil || err != nil {
t.Fatalf("nothing published: %+v, %v", f, err)
}
if seedsFloorArmed(dd) {
t.Fatal("armed with nothing published")
}
publishSeedsFloor(t, s, "v0.27.0")
f, err := syncLocalSeedsFloor(ctx, s, dd)
if err != nil || f == nil || f.MinRelease != "v0.27.0" || !seedsFloorArmed(dd) {
t.Fatalf("after publish: %+v, %v", f, err)
}
if line, ok := readBeneathLine(dd, seeds.FloorMinReleaseFile); !ok || line != "v0.27.0" {
t.Fatalf("min-release file = %q, %v", line, ok)
}
// Once armed, armed: an object that disappears leaves the files.
if err := s.Delete(ctx, seeds.FloorKey); err != nil {
t.Fatal(err)
}
if f, _ := syncLocalSeedsFloor(ctx, s, dd); f == nil || f.MinRelease != "v0.27.0" {
t.Fatalf("after the object went: %+v", f)
}
}
func TestSeedsFloorFilesWriteOrder(t *testing.T) {
dd := t.TempDir()
// A crash after the min-release file and before the JSON: the Go
// readers see no floor, and the units' line is AHEAD of every verdict.
if err := os.MkdirAll(filepath.Join(dd, ".moves"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dd, seeds.FloorMinReleaseFile), []byte("v0.27.0\n"), 0o644); err != nil {
t.Fatal(err)
}
if f, err := readLocalSeedsFloor(dd); f != nil || err != nil {
t.Fatalf("readers trust only the JSON: %+v, %v", f, err)
}
// And the writer writes the line first, the JSON last.
var order []string
writeHook = func(name string) { order = append(order, name) }
defer func() { writeHook = nil }()
if err := withFloorLock(dd, true, func(d *movesDir) error {
return writeSeedsFloorFiles(d, seeds.Floor{Min: 1, MinRelease: "v0.28.0"})
}); err != nil {
t.Fatal(err)
}
if len(order) != 2 || order[0] != filepath.Base(seeds.FloorMinReleaseFile) || order[1] != filepath.Base(seeds.FloorFile) {
t.Fatalf("write order = %q", order)
}
}
func TestJudgeSeedsPrev(t *testing.T) {
opt, dd := t.TempDir(), t.TempDir()
verdict := seedsPrevAllowedPath(opt)
// No carlos.prev: no verdict.
if err := judgeSeedsPrev(opt, dd); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(verdict); !os.IsNotExist(err) {
t.Fatal("a verdict with no carlos.prev")
}
if err := os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := writePrevVersion(opt, "v0.27.2"); err != nil {
t.Fatal(err)
}
// No seeds floor: no verdict (the units do not read one then).
if err := judgeSeedsPrev(opt, dd); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(verdict); !os.IsNotExist(err) {
t.Fatal("a verdict with no seeds floor")
}
s := release.NewDirStore(t.TempDir())
publishSeedsFloor(t, s, "v0.27.0")
if _, err := syncLocalSeedsFloor(context.Background(), s, dd); err != nil {
t.Fatal(err)
}
if err := judgeSeedsPrev(opt, dd); err != nil {
t.Fatal(err)
}
if line, ok := readLine(verdict); !ok || line != "v0.27.0" {
t.Fatalf("verdict = %q, %v; want the floor's min_release", line, ok)
}
// carlos.prev below the floor: the verdict goes.
if err := writePrevVersion(opt, "v0.26.9"); err != nil {
t.Fatal(err)
}
if err := judgeSeedsPrev(opt, dd); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(verdict); !os.IsNotExist(err) {
t.Fatal("a verdict for a carlos.prev below the floor")
}
}writeHook is a test seam this task adds to (*movesDir).WriteDurable in movesfloor_linux.go: a package variable var writeHook func(name string) called with the file name at the top of WriteDurable when non-nil. If movesfloor_linux_test.go already has an equivalent seam for TestFloorReadersTrustOnlyTheJSON, use that one instead and drop this addition.
- [ ] Step 2: Run the tests to verify they fail
Run: go test ./internal/host/ -run 'TestSyncLocalSeedsFloor|TestSeedsFloorFilesWriteOrder|TestJudgeSeedsPrev' -count=1 Expected: FAIL to compile: undefined: syncLocalSeedsFloor.
- [ ] Step 3: Write
internal/host/seedsfloor.go
package host
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/seeds"
)
// The seeds protocol floor on a box (seeded-instances spec §3.3, codex 16):
// the moves floor's mechanism (movesfloor.go), beside it. Its two local
// files live in the same .moves directory and are written under the same
// floor.lock; its verdict on carlos.prev sits beside the moves verdict; the
// two system units carry a second clause for it; the activation rule
// checks it as well. A deployment that never publishes it has none of the
// files, and every path here then does what it did before.
func seedsPrevAllowedPath(optDir string) string { return filepath.Join(optDir, seeds.PrevAllowedFile) }
// readLocalSeedsFloor reads <dataDir>/.moves/seeds-floor.json without
// following a symlink below dataDir. (nil, nil): not armed. A file that is
// there but unusable is an error, which callers treat as armed and unknown.
func readLocalSeedsFloor(dataDir string) (*seeds.Floor, error) {
f, err := openBeneath(dataDir, filepath.Dir(seeds.FloorFile), filepath.Base(seeds.FloorFile))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, err
}
if !st.Mode().IsRegular() || st.Size() > movesFloorMaxBytes {
return nil, fmt.Errorf("%s is not a regular file under %d bytes", f.Name(), movesFloorMaxBytes)
}
var fl seeds.Floor
if err := json.NewDecoder(io.LimitReader(f, movesFloorMaxBytes)).Decode(&fl); err != nil {
return nil, fmt.Errorf("%s: %w", f.Name(), err)
}
if ok, err := seeds.ReleaseAtOrAbove(fl.MinRelease, fl.MinRelease); !ok || err != nil {
return nil, fmt.Errorf("%s: min_release %q is not a release", f.Name(), fl.MinRelease)
}
return &fl, nil
}
// seedsFloorArmed: a local seeds floor that reads, or one that is there and
// cannot be read.
func seedsFloorArmed(dataDir string) bool {
f, err := readLocalSeedsFloor(dataDir)
return err != nil || f != nil
}
// writeSeedsFloorFiles writes the min-release line FIRST and the JSON LAST,
// for writeFloorFiles' reason: after a crash between them the line can only
// be ahead of the JSON, so the units never pick a carlos.prev the Go checks
// would refuse.
func writeSeedsFloorFiles(d *movesDir, f seeds.Floor) error {
body, err := json.Marshal(f)
if err != nil {
return err
}
if err := d.WriteDurable(filepath.Base(seeds.FloorMinReleaseFile), []byte(f.MinRelease+"\n"), 0o644); err != nil {
return err
}
return d.WriteDurable(filepath.Base(seeds.FloorFile), append(body, '\n'), 0o644)
}
// syncLocalSeedsFloor is syncLocalFloor's rule for the seeds floor: a
// published, usable floor that is not lower is written under the floor
// lock; absent leaves what is on disk; a lower one is refused; an
// unreadable store leaves what is on disk and returns the error.
func syncLocalSeedsFloor(ctx context.Context, s release.Store, dataDir string) (*seeds.Floor, error) {
if s == nil {
local, lerr := readLocalSeedsFloor(dataDir)
return local, errors.Join(errors.New("no deployment store to read the seeds floor from"), lerr)
}
remote, rerr := seeds.ReadFloor(ctx, s)
switch {
case rerr != nil:
local, lerr := readLocalSeedsFloor(dataDir)
return local, errors.Join(rerr, lerr)
case remote == nil:
return readLocalSeedsFloor(dataDir)
}
if ok, err := seeds.ReleaseAtOrAbove(remote.MinRelease, remote.MinRelease); !ok || err != nil {
local, lerr := readLocalSeedsFloor(dataDir)
return local, errors.Join(fmt.Errorf("the published seeds min_release %q is not a release; the seeds floor on this box stays as it was", remote.MinRelease), lerr)
}
var out *seeds.Floor
ran := false
err := withFloorLock(dataDir, true, func(d *movesDir) error {
ran = true
local, lerr := readLocalSeedsFloor(dataDir)
if lerr == nil && local != nil {
if remote.Min < local.Min {
out = local
return fmt.Errorf("the published seeds floor's protocol minimum %d is below this box's %d", remote.Min, local.Min)
}
if ok, _ := seeds.ReleaseAtOrAbove(remote.MinRelease, local.MinRelease); !ok {
out = local
return fmt.Errorf("the published seeds min_release %q is below this box's %s", remote.MinRelease, local.MinRelease)
}
if *local == *remote {
if line, ok := readBeneathLine(dataDir, seeds.FloorMinReleaseFile); ok && line == remote.MinRelease {
out = local
return nil
}
}
}
if err := writeSeedsFloorFiles(d, *remote); err != nil {
out, _ = readLocalSeedsFloor(dataDir)
return fmt.Errorf("%w: %v", errFloorWrite, err)
}
out = remote
return nil
})
if !ran {
out, _ = readLocalSeedsFloor(dataDir)
return out, fmt.Errorf("%w: %v", errFloorWrite, err)
}
return out, err
}
// judgeSeedsPrev writes the seeds verdict when carlos.prev is at or above
// the armed seeds floor, and removes it otherwise (no carlos.prev, no
// floor, no version, below). Unlike the moves verdict it does not read the
// registry: a seed has no hold rows.
func judgeSeedsPrev(optDir, dataDir string) error {
deny := func(cause error) error {
if err := removeDurable(seedsPrevAllowedPath(optDir)); err != nil {
return errors.Join(cause, fmt.Errorf("remove the seeds verdict on carlos.prev: %w", err))
}
return cause
}
if _, err := os.Stat(systemPrevPath(optDir)); err != nil {
return deny(nil)
}
ran := false
err := withFloorLock(dataDir, false, func(*movesDir) error {
ran = true
floor, err := readLocalSeedsFloor(dataDir)
if err != nil {
return deny(fmt.Errorf("seeds protocol floor: the floor on this box cannot be read: %v", err))
}
if floor == nil {
return deny(nil)
}
version, ok := readLine(prevVersionPath(optDir))
if !ok {
return deny(nil)
}
if at, _ := seeds.ReleaseAtOrAbove(version, floor.MinRelease); !at {
return deny(nil)
}
return writeFileDurable(seedsPrevAllowedPath(optDir), []byte(floor.MinRelease+"\n"), 0o644)
})
if !ran {
return deny(fmt.Errorf("seeds protocol floor: the floor on this box cannot be read: %v", err))
}
return err
}
// rootJudgeSeedsPrev is the root adopt pass's call: nothing to do on a box
// that has neither a seeds floor nor a seeds verdict.
func rootJudgeSeedsPrev() error {
optDir, dd := release.OptDir(), dataDir()
if !seedsFloorArmed(dd) {
if _, err := os.Stat(seedsPrevAllowedPath(optDir)); errors.Is(err, os.ErrNotExist) {
return nil
}
}
return withPrevLock(optDir, func() error { return judgeSeedsPrev(optDir, dd) })
}
// seedsFloorRefresh is the agent's hourly refresh of the seeds floor.
func seedsFloorRefresh(ctx context.Context, s release.Store, dataDir string, logf func(string, ...any)) {
if _, err := syncLocalSeedsFloor(ctx, s, dataDir); err != nil {
logf("agent: seeds protocol floor refresh: %v", err)
}
}- [ ] Step 4: Call the seeds half from the moves floor's loop and the adopt pass
In internal/host/movesfloor.go, movesFloorRefresh (:469) gains one line after its own sync:
seedsFloorRefresh(ctx, s, dataDir, logf)and rootJudgePrev (:447) returns both judgements. Replace its body's two return paths so the seeds judgement always runs:
func rootJudgePrev(reg *registry.Registry) error {
return errors.Join(rootJudgeMovesPrev(reg), rootJudgeSeedsPrev())
}
// rootJudgeMovesPrev is the moves half, rootJudgePrev's body before the
// seeds floor existed, unchanged.
func rootJudgeMovesPrev(reg *registry.Registry) error {
optDir, dd := release.OptDir(), dataDir()
// ... the existing body, verbatim
}- [ ] Step 5: Run the tests to verify they pass
Run: go test ./internal/host/ -run 'TestSyncLocalSeedsFloor|TestSeedsFloorFilesWriteOrder|TestJudgeSeedsPrev|TestJudgePrev|TestSyncLocalFloor|TestFloor' -count=1 Expected: PASS.
- [ ] Step 6: Commit
git add internal/host/seedsfloor.go internal/host/seedsfloor_linux_test.go internal/host/movesfloor.go internal/host/movesfloor_linux.go
git commit -m "host: the box's local seeds floor and its verdict on carlos.prev (Plan 3 Task 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 3: The system units and the activation rule
Files:
- Modify:
internal/host/units/carlos-system-recover.service:29,internal/host/units/carlos-system-update.service:35and both files' header comments - Modify:
internal/host/systemapply.go(armFloor:833,notePrev:784,floorArmed:808,activationRule:1010,activationGuard:933,floorAllowsForward:1041) - Modify:
internal/host/seedsfloor.go(addarmSeedsFloor,seedsActivationRefusal) - Test:
internal/host/seedsfloor_test.go,internal/host/host_test.go(TestSystemUnitsAreShapedLikeTheStageRelay's want lists)
Interfaces:
- Consumes: Task 2's functions;
refuse(format string, args ...any) error,errSystemDeferred,(a *systemApplier).ops.logf(allsystemapply.go). - Produces:
seedsActivationRefusal(version, dataDir string) error(anactivationRefused, SD-N1);(a *systemApplier) armSeedsFloor(ctx) error.
- [ ] Step 1: Write the failing tests
internal/host/seedsfloor_test.go (not Linux-only: it runs /bin/sh, which every CI unit runner has; skip on Windows):
package host
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/seeds"
)
func TestSystemUnitsSelectPrevByTheSeedsFloor(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no /bin/sh")
}
for _, unit := range []string{"carlos-system-recover.service", "carlos-system-update.service"} {
t.Run(unit, func(t *testing.T) {
b, err := unitsFS.ReadFile("units/" + unit)
if err != nil {
t.Fatal(err)
}
var line string
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "ExecStart=") {
line = l
}
}
if strings.ContainsAny(line, "%$") {
t.Fatalf("ExecStart carries a %% or a $: %s", line)
}
for _, path := range []string{"/data/" + seeds.FloorMinReleaseFile, "/opt/carlos/" + seeds.PrevAllowedFile} {
if !strings.Contains(line, path) {
t.Fatalf("ExecStart does not read %s: %s", path, line)
}
}
body := strings.TrimSuffix(strings.TrimPrefix(line, "ExecStart=/bin/sh -c '"), "'")
for _, tc := range []struct {
name string
movesFloor, movesOK string
seedsFloor, seedsOK string
want string
}{
{"no seeds floor", "", "", "", "", "carlos.prev"},
{"seeds floor and a verdict naming it", "", "", "v0.27.0\n", "v0.27.0\n", "carlos.prev"},
{"seeds floor and no verdict", "", "", "v0.27.0\n", "", "carlos"},
{"a seeds verdict for an older floor", "", "", "v0.27.0\n", "v0.26.0\n", "carlos"},
{"both floors, both verdicts", "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "v0.27.0\n", "carlos.prev"},
{"both floors, only the moves verdict", "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "", "carlos"},
{"both floors, only the seeds verdict", "v0.30.0\n", "", "v0.27.0\n", "v0.27.0\n", "carlos"},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
opt, data, ran := filepath.Join(root, "opt"), filepath.Join(root, "data"), filepath.Join(root, "ran")
os.MkdirAll(filepath.Join(opt, "system", "router"), 0o755)
os.MkdirAll(filepath.Join(data, ".moves"), 0o755)
for _, name := range []string{"carlos", "carlos.prev"} {
os.WriteFile(filepath.Join(opt, name), []byte("#!/bin/sh\necho "+name+" > "+ran+"\n"), 0o755)
}
os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0o600)
put := func(path, body string) {
if body != "" {
os.WriteFile(path, []byte(body), 0o644)
}
}
put(filepath.Join(data, moves.FloorMinReleaseFile), tc.movesFloor)
put(filepath.Join(opt, moves.PrevAllowedFile), tc.movesOK)
put(filepath.Join(data, seeds.FloorMinReleaseFile), tc.seedsFloor)
put(filepath.Join(opt, seeds.PrevAllowedFile), tc.seedsOK)
script := strings.ReplaceAll(strings.ReplaceAll(body, "/data/", data+"/"), "/opt/carlos/", opt+"/")
if out, err := exec.Command("/bin/sh", "-c", script).CombinedOutput(); err != nil {
t.Fatalf("%v: %s", err, out)
}
got, _ := os.ReadFile(ran)
if strings.TrimSpace(string(got)) != tc.want {
t.Fatalf("ran %q, want %s", strings.TrimSpace(string(got)), tc.want)
}
})
}
})
}
}
func TestSeedsActivationRefusal(t *testing.T) {
dd := t.TempDir()
if err := seedsActivationRefusal("v0.20.0", dd); err != nil {
t.Fatalf("no seeds floor: %v", err)
}
os.MkdirAll(filepath.Join(dd, ".moves"), 0o755)
os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{"min":1,"min_release":"v0.27.0"}`), 0o644)
if err := seedsActivationRefusal("v0.27.0", dd); err != nil {
t.Fatalf("at the floor: %v", err)
}
err := seedsActivationRefusal("v0.26.9", dd)
var refused activationRefused
if !errors.As(err, &refused) || err.Error() != "seeds protocol floor: v0.26.9 is below v0.27.0" {
t.Fatalf("below the floor: %v", err)
}
os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{garbage`), 0o644)
if err := seedsActivationRefusal("v0.30.0", dd); !errors.As(err, &refused) {
t.Fatalf("an unreadable seeds floor: %v, want a refusal", err)
}
}Add "floor.min-release"'s seeds sibling to the want lists in TestSystemUnitsAreShapedLikeTheStageRelay (host_test.go:1300): append "seeds-floor.min-release", "carlos.prev.seeds-allowed" to both the update unit's and the recover unit's lists.
- [ ] Step 2: Run the tests to verify they fail
Run: go test ./internal/host/ -run 'TestSystemUnitsSelectPrevByTheSeedsFloor|TestSeedsActivationRefusal|TestSystemUnitsAreShapedLikeTheStageRelay' -count=1 Expected: FAIL: the units lack the seeds paths; undefined: seedsActivationRefusal.
- [ ] Step 3: Add the units' second clause
In both unit files, replace the ExecStart= line. Recover unit:
ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; } && { ! [ -s /data/.moves/seeds-floor.min-release ] || cmp -s /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover'Update unit: the same two { … } clauses joined with &&, keeping its own system apply arguments exactly as they are today. Extend each file's header comment by one paragraph:
# The seeds floor (seeded-instances spec §3.3) adds the same test for its
# own files: once /data/.moves/seeds-floor.min-release is there and not
# empty, carlos.prev runs only when /opt/carlos/carlos.prev.seeds-allowed
# names that same floor, byte for byte.- [ ] Step 4: Add the activation refusal and the apply's arming to
seedsfloor.go
// seedsActivationRefusal is the seeds half of the activation rule: version
// below the armed seeds floor is refused (SD-N1). The floor is read from
// the local file alone (boot recovery runs before the network). Unarmed:
// nil.
func seedsActivationRefusal(version, dataDir string) error {
floor, err := readLocalSeedsFloor(dataDir)
if err != nil {
return refuse("seeds protocol floor: the floor on this box cannot be read: %v", err)
}
if floor == nil {
return nil
}
if ok, _ := seeds.ReleaseAtOrAbove(version, floor.MinRelease); !ok {
return refuse("seeds protocol floor: %s is below %s", version, floor.MinRelease)
}
return nil
}
// armSeedsFloor is armFloor's seeds half, with the same rule: a published
// floor this box could not write down defers the apply; anything else is a
// log line.
func (a *systemApplier) armSeedsFloor(ctx context.Context) error {
f, err := syncLocalSeedsFloor(ctx, a.store, a.dataDir)
if err == nil {
return nil
}
if errors.Is(err, errFloorWrite) {
return fmt.Errorf("%w: seeds protocol floor: %v", errSystemDeferred, err)
}
onDisk := "none"
if f != nil {
onDisk = f.MinRelease
}
a.ops.logf("system apply: seeds protocol floor: %v (the seeds floor on this box stays %s)", err, onDisk)
return nil
}refuse("seeds protocol floor: %s is below %s", …) is SD-N1's approved text. The version == "" case (a carlos.prev with no recorded version) is below any floor: ReleaseAtOrAbove("", …) is false.
- [ ] Step 5: Wire the seeds half into
systemapply.go
armFloor(:833), first statement:if err := a.armSeedsFloor(ctx); err != nil { return err }.notePrev(:784): in thereplacebranch, addremoveDurable(seedsPrevAllowedPath(a.optDir))to theerrors.Joinwith the moves verdict and sidecar; replace the finalreturn a.floorStep(judgePrevWith(…))withreturn a.floorStep(errors.Join(judgePrevWith(a.optDir, a.dataDir, a.freezeHolds, func() string { return a.prevBuild(ctx) }), judgeSeedsPrev(a.optDir, a.dataDir))).floorArmed(:808):return err != nil || f != nil || seedsFloorArmed(a.dataDir).activationRule(:1010), first statement:if err := seedsActivationRefusal(version, a.dataDir); err != nil { return err }.activationGuard(:933), inside thewithFloorLockclosure, beforefloor, err := readLocalFloor(a.dataDir):if err := seedsActivationRefusal(target, a.dataDir); err != nil { return err }.floorAllowsForward(:1041), first statement:if err := seedsActivationRefusal(version, a.dataDir); err != nil { return err }.
- [ ] Step 6: Run the tests to verify they pass
Run: go test ./internal/host/ -run 'TestSystemUnits|TestSeedsActivationRefusal|TestActivation|TestSystemApply|TestNotePrev|TestDecidePrev|TestFloor' -count=1 Expected: PASS. The moves floor's existing tests are unchanged: with no seeds files, every new call is a no-op.
- [ ] Step 7: Wire SD-N1 and commit
Append "SD-N1" to seededWiredIDs in approved_seeded_copy_test.go. Run go test . -run TestSeededWiredCopyIsApplied -count=1 (PASS: the format string in seedsfloor.go matches the row).
git add internal/host/units/ internal/host/systemapply.go internal/host/seedsfloor.go internal/host/seedsfloor_test.go internal/host/host_test.go approved_seeded_copy_test.go
git commit -m "host: the seeds floor in the system units and the activation rule (Plan 3 Task 3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 4: The edges file reports the seeds floor
Files:
- Modify:
internal/host/edgesfile.go:295-305(struct),:817(the loop) - Modify:
internal/host/seedsfloor.go(addwithSeedsFields) - Modify:
internal/console/instancesplacement.go:83-86(reader) - Test:
internal/host/seedsfloor_linux_test.go
Interfaces:
- Produces:
edgesFile.SeedsProtocol int(json:"seeds_protocol", not omitempty),edgesFile.SeedsFloorMinRelease string(json:"seeds_floor_min_release,omitempty"),edgesFile.PrevSeedsAllowedFor string(json:"prev_seeds_allowed_for,omitempty"); the same three onedgesFilePlacementView;withSeedsFields(f edgesFile, dataDir, optDir string) edgesFile.
- [ ] Step 1: Write the failing test
Append to internal/host/seedsfloor_linux_test.go:
func TestWithSeedsFields(t *testing.T) {
dd, opt := t.TempDir(), t.TempDir()
f := withSeedsFields(edgesFile{Box: "kass-1"}, dd, opt)
if f.SeedsProtocol != seeds.ProtocolVersion || f.SeedsFloorMinRelease != "" || f.PrevSeedsAllowedFor != "" {
t.Fatalf("unarmed = %+v", f)
}
s := release.NewDirStore(t.TempDir())
publishSeedsFloor(t, s, "v0.27.0")
if _, err := syncLocalSeedsFloor(context.Background(), s, dd); err != nil {
t.Fatal(err)
}
os.WriteFile(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644)
f = withSeedsFields(f, dd, opt)
if f.SeedsFloorMinRelease != "v0.27.0" || f.PrevSeedsAllowedFor != "v0.27.0" {
t.Fatalf("armed = %+v", f)
}
body, _ := json.Marshal(f)
if !strings.Contains(string(body), `"seeds_protocol":0`) {
t.Fatalf("seeds_protocol must be on the wire even at 0: %s", body)
}
}(add "encoding/json" and "strings" to that file's imports).
- [ ] Step 2: Run it to verify it fails
Run: go test ./internal/host/ -run TestWithSeedsFields -count=1 Expected: FAIL to compile: undefined: withSeedsFields.
- [ ] Step 3: Add the fields and the writer
In edgesFile, after PrevAllowedFor:
// The seeds protocol (seeded-instances spec §3.3), read by the console's
// seedRegionReady. SeedsProtocol is not omitempty: 0 is a real answer
// (a build that cannot place a seed). SeedsFloorMinRelease is the
// armed seeds floor's min_release (absent: not armed), and
// PrevSeedsAllowedFor the floor carlos.prev was judged at or above.
SeedsProtocol int `json:"seeds_protocol"`
SeedsFloorMinRelease string `json:"seeds_floor_min_release,omitempty"`
PrevSeedsAllowedFor string `json:"prev_seeds_allowed_for,omitempty"`In seedsfloor.go:
// withSeedsFields sets the edges file's seeds fields from this box's own
// files, read at each write, as withMovesFields does for the moves floor.
func withSeedsFields(f edgesFile, dataDir, optDir string) edgesFile {
f.SeedsProtocol = seeds.ProtocolVersion
f.SeedsFloorMinRelease = ""
if fl, err := readLocalSeedsFloor(dataDir); err == nil && fl != nil {
f.SeedsFloorMinRelease = fl.MinRelease
}
f.PrevSeedsAllowedFor, _ = readLine(seedsPrevAllowedPath(optDir))
return f
}In the loop (edgesfile.go:817), after ident = withMovesFields(ident, dataDir, optDir): ident = withSeedsFields(ident, dataDir, optDir).
In internal/console/instancesplacement.go, after PrevAllowedFor in edgesFilePlacementView:
// The seeds floor (seeded-instances spec §3.3), read only by
// seedRegionReady.
SeedsProtocol int `json:"seeds_protocol"`
SeedsFloorMinRelease string `json:"seeds_floor_min_release"`
PrevSeedsAllowedFor string `json:"prev_seeds_allowed_for"`- [ ] Step 4: Run the tests to verify they pass
Run: go test ./internal/host/ -run 'TestWithSeedsFields|TestEdgesFile|TestWithMovesFields' -count=1 && go test ./internal/console/ -run 'TestEdges|TestPlacement|TestSelectMoveBoxes' -count=1 Expected: PASS.
- [ ] Step 5: Commit
git add internal/host/edgesfile.go internal/host/seedsfloor.go internal/host/seedsfloor_linux_test.go internal/console/instancesplacement.go
git commit -m "edges file: the seeds protocol, floor and verdict (Plan 3 Task 4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 5: The console's region readiness check
Files:
- Create:
internal/console/seedfloor.go - Test:
internal/console/seedfloor_test.go
Interfaces:
- Consumes:
s.liveEdges(ctx) (edgesSnapshot, error),edgesSnapshot.complete(),s.homeRegionIn(string) string,s.homeRegionOut(string) string,s.systemBoxes(ctx, strict bool) ([]status.Box, bool, error),status.BoxStale(b, now, status.SnapshotPeriod),doorRefusal{Status int; Msg string},seeds.ReadFloor,seeds.RequiredProtocol. - Produces:
(s *Server) seedRegionReady(ctx context.Context, region string, oneBox bool) *doorRefusal; nil means every box inregion(the record vocabulary:""is the home region) is ready. Plans 5 and 6 call it before writing any seed state; the import door passesoneBoxtrue (see below).
The order of checks, each a refusal naming the boxes it found (sorted, comma-joined):
s.cfg.HostClaimon this console: 409, SD-N8.- The published floor: read error 503 SD-N4; absent 409 SD-N3.
- The edges snapshot: read error or
!snap.complete()503 SD-N5; no edges file for the region 503 SD-N5; withoneBox, more than one edges file for the region 409 SD-N113. - Every edges file in the region:
SeedsProtocol < seeds.RequiredProtocol, orSeedsFloorMinRelease != floor.MinRelease, or neitherPrevRelease == "none"norPrevSeedsAllowedFor == floor.MinRelease: 409 SD-N2. - The heartbeats (
systemBoxes, joined by label: an edges file'sboxismetricsBoxLabel, the samestatus.Labelthe heartbeat carries): a region box with no heartbeat, a stale one, or one with noMetrics.Layout: 503 SD-N5.Metrics.Layout.Mode != "nested": 409 SD-N6.Metrics.HostClaimEnabled: 409 SD-N7.
oneBox is for the import door. The swap, its journal and the rollback live on the one box that holds the legacy row (Plan 7), and §3.2's no-journal finish of an aborted seed would run on every other box of a multi-box region, where it could CAS an aborted import to rolled-back (and let the console delete the record, freeing the hostname) while the swapping box is still rolling back. The spec does not address multi-box regions; Tito's conversion runs on one box. Until a multi-box design exists, the import door refuses a region with more than one box.
- [ ] Step 1: Write the failing tests
package console
import (
"context"
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/carlosframework/platform/internal/seeds"
"github.com/carlosframework/platform/internal/status"
)
func seedReadyBox(t *testing.T, fx *consoleFixture, label, placement string, protocol int, floor, prevAllowed, mode string, claim bool) {
t.Helper()
putEdges(t, fx, map[string]string{label: `{"v":1,"box":"` + label + `","eip":"1.2.3.4","placement":"` + placement +
`","hibernation_configured":true,"hosts":{},"seeds_protocol":` + strconv.Itoa(protocol) +
`,"seeds_floor_min_release":"` + floor + `","prev_release":"v0.27.1","prev_seeds_allowed_for":"` + prevAllowed + `"}`})
var layout *status.LayoutReport
if mode != "" {
layout = &status.LayoutReport{Mode: mode}
}
b := status.CollectBox(nil, status.Probes{Layout: layout, HostClaimEnabled: claim}, time.Now())
b.Label = label
if err := status.WriteBox(context.Background(), fx.rel, b); err != nil {
t.Fatal(err)
}
}
func publishSeedsFloorFx(t *testing.T, fx *consoleFixture, rel string) {
t.Helper()
if err := seeds.PublishFloor(context.Background(), fx.rel, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: rel}); err != nil {
t.Fatal(err)
}
}
func TestSeedRegionReady(t *testing.T) {
fx := newConsoleFixture(t)
ctx := context.Background()
if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusConflict || !strings.Contains(ref.Msg, "no seeds floor yet") {
t.Fatalf("no floor: %+v", ref)
}
publishSeedsFloorFx(t, fx, "v0.27.0")
seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false)
if ref := fx.srv.seedRegionReady(ctx, "", false); ref != nil {
t.Fatalf("a ready region: %+v", ref)
}
seedReadyBox(t, fx, "kass-2", "", 0, "v0.27.0", "v0.27.0", "nested", false)
if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusConflict || !strings.Contains(ref.Msg, "kass-2") || !strings.Contains(ref.Msg, "below the seeds floor") {
t.Fatalf("a box on protocol 0: %+v", ref)
}
seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "", "nested", false)
if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "below the seeds floor") {
t.Fatalf("a carlos.prev without a seeds verdict: %+v", ref)
}
seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "flat", false)
if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "nested") {
t.Fatalf("a flat box: %+v", ref)
}
seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "nested", true)
if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "claim control") {
t.Fatalf("a claim-armed box: %+v", ref)
}
fx.srv.cfg.HostClaim = true
if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "This console runs with claim control on") {
t.Fatalf("a claim console: %+v", ref)
}
}
func TestSeedRegionReadyOneBoxForImports(t *testing.T) {
fx := newConsoleFixture(t)
publishSeedsFloorFx(t, fx, "v0.27.0")
seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false)
seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "nested", false)
if ref := fx.srv.seedRegionReady(context.Background(), "", false); ref != nil {
t.Fatalf("two ready boxes, --from: %+v", ref)
}
if ref := fx.srv.seedRegionReady(context.Background(), "", true); ref == nil || ref.Status != http.StatusConflict || !strings.Contains(ref.Msg, "more than one box") {
t.Fatalf("two ready boxes, import: %+v", ref)
}
}
func TestSeedRegionReadyRefusesAnUnconfirmedBox(t *testing.T) {
fx := newConsoleFixture(t)
publishSeedsFloorFx(t, fx, "v0.27.0")
seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "", false) // no layout report
if ref := fx.srv.seedRegionReady(context.Background(), "", false); ref == nil || ref.Status != http.StatusServiceUnavailable {
t.Fatalf("a box with no layout report: %+v", ref)
}
}
func TestSeedRegionReadyIgnoresOtherRegions(t *testing.T) {
fx := newConsoleFixture(t)
publishSeedsFloorFx(t, fx, "v0.27.0")
seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false)
seedReadyBox(t, fx, "uk-1", "uk", 0, "", "", "flat", true)
if ref := fx.srv.seedRegionReady(context.Background(), "", false); ref != nil {
t.Fatalf("a bad box in uk blocked the home region: %+v", ref)
}
if ref := fx.srv.seedRegionReady(context.Background(), "uk", false); ref == nil {
t.Fatal("uk's own bad box did not block uk")
}
}The fixture's home region: newConsoleFixture leaves cfg.HomeRegion empty, so homeRegionIn("") is "" and the box with placement "" is in the home region. If the fixture sets a home region, give the home boxes that placement instead.
- [ ] Step 2: Run the tests to verify they fail
Run: go test ./internal/console/ -run 'TestSeedRegionReady' -count=1 Expected: FAIL to compile: fx.srv.seedRegionReady undefined.
- [ ] Step 3: Write
internal/console/seedfloor.go
package console
import (
"context"
"fmt"
"log"
"net/http"
"sort"
"strings"
"time"
"github.com/carlosframework/platform/internal/seeds"
"github.com/carlosframework/platform/internal/status"
)
// seedRegionReady is the check both seed doors make before writing any seed
// state, even awaiting-upload (seeded-instances spec §3.3, §6.3): this
// console runs without claim control, the seeds floor is published, and
// every box in region has armed it, excluded or vouched for its
// carlos.prev, runs nested, and has no claim control. region is the record
// vocabulary ("" is the home region). nil means go ahead.
func (s *Server) seedRegionReady(ctx context.Context, region string, oneBox bool) *doorRefusal {
if s.cfg.HostClaim {
return &doorRefusal{http.StatusConflict, "This console runs with claim control on. Seeded instances are not supported here yet."}
}
floor, err := seeds.ReadFloor(ctx, s.cfg.Releases)
if err != nil {
log.Printf("console: seeds floor: %v", err)
return &doorRefusal{http.StatusServiceUnavailable, "could not read the seeds floor. Try again."}
}
if floor == nil {
return &doorRefusal{http.StatusConflict, "This deployment has no seeds floor yet. An operator publishes it with carlos system seeds-floor publish."}
}
name := s.homeRegionOut(region)
if name == "" {
name = "the home region"
}
unconfirmed := &doorRefusal{http.StatusServiceUnavailable, fmt.Sprintf("could not confirm every box in %s. Try again in a minute.", name)}
snap, err := s.liveEdges(ctx)
if err != nil || !snap.complete() {
return unconfirmed
}
var inRegion, below []string
for _, f := range snap.Files {
if s.homeRegionIn(f.Placement) != region {
continue
}
inRegion = append(inRegion, f.Box)
prevOK := f.PrevRelease == "none" || f.PrevSeedsAllowedFor == floor.MinRelease
if f.SeedsProtocol < seeds.RequiredProtocol || f.SeedsFloorMinRelease != floor.MinRelease || !prevOK {
below = append(below, f.Box)
}
}
if len(inRegion) == 0 {
return unconfirmed
}
if oneBox && len(inRegion) > 1 {
return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s has more than one box. An import needs a region with one box for now.", name)}
}
if len(below) > 0 {
return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s in %s are below the seeds floor. Roll the platform there, then publish the seeds floor again.", boxList(below), name)}
}
boxes, _, err := s.systemBoxes(ctx, false)
if err != nil {
log.Printf("console: seeds readiness: boxes: %v", err)
return unconfirmed
}
byLabel := map[string]status.Box{}
for _, b := range boxes {
byLabel[b.Label] = b
}
now := time.Now()
var flat, claimed []string
for _, label := range inRegion {
b, ok := byLabel[label]
if !ok || status.BoxStale(b, now, status.SnapshotPeriod) || b.Metrics == nil || b.Metrics.Layout == nil {
return unconfirmed
}
if b.Metrics.Layout.Mode != "nested" {
flat = append(flat, label)
}
if b.Metrics.HostClaimEnabled {
claimed = append(claimed, label)
}
}
if len(flat) > 0 {
return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s in %s do not run instances nested yet. A seeded instance needs every box there nested.", boxList(flat), name)}
}
if len(claimed) > 0 {
return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s in %s run with claim control on. Seeded instances are not supported there yet.", boxList(claimed), name)}
}
return nil
}
func boxList(boxes []string) string {
sort.Strings(boxes)
return strings.Join(boxes, ", ")
}"the home region" matches dbRegionName's existing wording (apidb.go), which the deck reuses for <region>.
- [ ] Step 4: Run the tests to verify they pass
Run: go test ./internal/console/ -run 'TestSeedRegionReady' -count=1 Expected: PASS.
- [ ] Step 5: Wire SD-N2 to SD-N8 and commit
Append "SD-N2", "SD-N3", "SD-N4", "SD-N5", "SD-N6", "SD-N7", "SD-N8", "SD-N113" to seededWiredIDs; run go test . -run TestSeededWiredCopyIsApplied -count=1 (PASS).
git add internal/console/seedfloor.go internal/console/seedfloor_test.go approved_seeded_copy_test.go
git commit -m "console: seedRegionReady, the floor, layout and claim check before any seed state (Plan 3 Task 5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 6: carlos system seeds-floor publish
Files:
- Modify:
internal/console/seedfloor.go(addhandleAPISystemSeedsFloor) - Modify:
internal/console/authcli.go:932(one route, beside the geoip doors) - Create:
system_seeds.go,system_seeds_test.go - Modify:
system.go:52(systemVerb),:58(runSystem),:107(systemUsage) - Modify:
docs/site/cli.md(under### carlos system,:1863) - Test:
internal/console/seedfloor_test.go
Interfaces:
- Consumes:
s.apiSystemRoller(w, r) (addr string, owner, ok bool),s.auditSystem(addr, action, detail string),readJSON,jsonError,writeJSON,seeds.PublishFloor,seeds.FloorLowerError; CLIconsoleOption,requireDeployAPISession,printUsing,sess.postJSON,apiErrorFromBody. - Produces:
POST /api/cli/system/seeds-floor{"min_release": "vX.Y.Z"}answering 200{"min_release": "vX.Y.Z"};runSystemSeedsFloorVerb(rest []string).
- [ ] Step 1: Write the failing tests
Append to internal/console/seedfloor_test.go:
func TestSeedsFloorDoorIsForSystemOperators(t *testing.T) {
fx := newConsoleFixture(t)
if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil {
t.Fatal(err)
}
member := cliToken(t, fx, "member@keymail.dev", "code-seeds-member")
_, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member)
if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]string{"min_release": "v0.27.0"}, member); st != http.StatusNotFound || body != want {
t.Fatalf("a member = %d %q, want the bare 404", st, body)
}
owner := cliToken(t, fx, "paul@keymail.dev", "code-seeds-owner")
if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]any{"min_release": "latest"}, owner); st != http.StatusBadRequest {
t.Fatalf("a bad release = %d %v, want 400", st, out)
}
if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]any{"min_release": "v0.27.0"}, owner); st != http.StatusOK || out["min_release"] != "v0.27.0" {
t.Fatalf("publish = %d %v", st, out)
}
st, out := postJSON(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]any{"min_release": "v0.26.0"}, owner)
if st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "already v0.27.0") {
t.Fatalf("a lower floor = %d %v, want 409 naming v0.27.0", st, out)
}
f, err := seeds.ReadFloor(context.Background(), fx.rel)
if err != nil || f == nil || f.MinRelease != "v0.27.0" || f.Min != seeds.RequiredProtocol {
t.Fatalf("floor = %+v, %v", f, err)
}
}system_seeds_test.go:
package main
import "testing"
func TestSystemVerbKnowsSeedsFloor(t *testing.T) {
verb, rest, _ := systemVerb([]string{"seeds-floor", "publish", "--min-release", "v0.27.0"})
if verb != "seeds-floor" || len(rest) != 3 || rest[0] != "publish" {
t.Fatalf("systemVerb = %q %q", verb, rest)
}
}- [ ] Step 2: Run the tests to verify they fail
Run: go test ./internal/console/ -run TestSeedsFloorDoorIsForSystemOperators -count=1; go test . -run TestSystemVerbKnowsSeedsFloor -count=1 Expected: FAIL: the route 404s for the owner too; systemVerb returns "".
- [ ] Step 3: Write the door and register it
Append to internal/console/seedfloor.go (add "errors" to its imports):
// handleAPISystemSeedsFloor publishes the seeds floor (spec §3.3, §10 step
// 2). The update doors' authority: an ops account owner or an address on
// CARLOS_SYSTEM_OPERATORS (apiSystemRoller).
func (s *Server) handleAPISystemSeedsFloor(w http.ResponseWriter, r *http.Request) {
addr, _, ok := s.apiSystemRoller(w, r)
if !ok {
return
}
var req struct {
MinRelease string `json:"min_release"`
}
if !readJSON(w, r, &req) {
return
}
if ok, err := seeds.ReleaseAtOrAbove(req.MinRelease, req.MinRelease); !ok || err != nil {
jsonError(w, http.StatusBadRequest, "min_release must be a release such as v0.27.0")
return
}
err := seeds.PublishFloor(r.Context(), s.cfg.Releases, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: req.MinRelease})
var lower *seeds.FloorLowerError
switch {
case errors.As(err, &lower):
jsonError(w, http.StatusConflict, fmt.Sprintf("the seeds floor is already %s. A floor only rises.", lower.Current))
return
case err != nil:
log.Printf("console: publish seeds floor: %v", err)
jsonError(w, http.StatusBadGateway, "could not publish the seeds floor. Try again.")
return
}
s.auditSystem(addr, "seeds-floor-publish", fmt.Sprintf("published the seeds floor at %s", req.MinRelease))
writeJSON(w, http.StatusOK, map[string]string{"min_release": req.MinRelease})
}In authcli.go, after the three geoip routes:
// The seeds floor (seeded-instances spec §3.3): apiSystemRoller.
s.cliRoute("POST /api/cli/system/seeds-floor", svcrole.Set{}, s.handleAPISystemSeedsFloor)- [ ] Step 4: Write the CLI verb
system_seeds.go:
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
)
// runSystemSeedsFloorVerb is `carlos system seeds-floor publish`
// (seeded-instances spec §3.3, §10 step 2).
func runSystemSeedsFloorVerb(rest []string) {
if len(rest) == 0 || rest[0] != "publish" {
systemUsage()
os.Exit(2)
}
fs := flag.NewFlagSet("system seeds-floor publish", flag.ExitOnError)
consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)")
minRelease := fs.String("min-release", "", "the first release whose boxes can place a seed, such as v0.27.0")
fs.Parse(rest[1:])
if *minRelease == "" || fs.NArg() != 0 {
fs.Usage()
os.Exit(2)
}
sess := requireDeployAPISession(*consoleFlag)
printUsing(stdout, sess)
status, body, err := sess.postJSON(context.Background(), "/api/cli/system/seeds-floor", map[string]string{"min_release": *minRelease})
if err != nil {
log.Fatalf("carlos system seeds-floor publish: %v", err)
}
if status != http.StatusOK {
log.Fatalf("carlos system seeds-floor publish: %v", apiErrorFromBody(status, body))
}
fmt.Fprintln(stdout, fmt.Sprintf("published the seeds floor at %s. Every box arms it within the hour.", *minRelease))
}In system.go: add "seeds-floor" to systemVerb's case list (:52); add case "seeds-floor": runSystemSeedsFloorVerb(rest) to runSystem's switch beside case "geoip"; in systemUsage, add carlos system seeds-floor publish --min-release <release> to the usage lines at the top, and under "The platform's own binary (ops account owners and CARLOSSYSTEMOPERATORS):" add:
seeds-floor publish --min-release <release>
the first release whose boxes can place a seed, such as v0.27.0In docs/site/cli.md, under ### carlos system, add a #### The seeds floor subsection whose body is SD-N111's approved text, followed by the example:
````markdown
carlos system seeds-floor publish --min-release v0.27.0````
- [ ] Step 5: Run the tests to verify they pass
Run: go test ./internal/console/ -run 'TestSeedsFloorDoor|TestSystemDoors' -count=1 && go test . -run 'TestSystemVerb|TestSeeded' -count=1 Expected: PASS.
- [ ] Step 6: Wire SD-N9 to SD-N13, SD-N111, SD-N112 and commit
Append "SD-N9", "SD-N10", "SD-N11", "SD-N12", "SD-N13", "SD-N111", "SD-N112" to seededWiredIDs; run go test . -run TestSeededWiredCopyIsApplied -count=1 (PASS).
git add internal/console/seedfloor.go internal/console/seedfloor_test.go internal/console/authcli.go system.go system_seeds.go system_seeds_test.go docs/site/cli.md approved_seeded_copy_test.go
git commit -m "carlos system seeds-floor publish (Plan 3 Task 6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 7: Plan gate
- [ ] Step 1: Cross-compile and run the touched packages under -race
Run:
GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./... && \
nice go test -race ./internal/seeds/ ./internal/host/ ./internal/console/ . -count=1Expected: builds clean; tests PASS. The moves copy gate (go test . -run TestMoves) passes: no new file imports internal/moves, and the changed moves-scanned files (movesfloor.go, systemapply.go, edgesfile.go, instancesplacement.go, authcli.go) gained no sink.
---
Self-review
- Spec coverage: §3.3 the floor object (Task 1), the box arming and the
carlos.prevverdict (Task 2), recovery refusing below the floor in the units and in Go (Task 3), the console writing no seed state until every box reports armed and the verdict (Task 5, used by Plans 5 and 6); §6.3 console half, nested and not claim-armed (Task 5); §10 step 2's publish (Task 6). - Dark launch:
ProtocolVersion0 againstRequiredProtocol1 (Task 1's test pins it until Plan 7). - Review Focus: line 1 Task 2, line 2 Task 3, lines 3 and 4 Task 5, line 5 Task 1.
- Deviation:
oneBox(SD-N113) is not in the spec; it closes a multi-box gap the spec does not address (Task 5's note).