carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Seeded Instances, Plan 3: The Seeds Floor Implementation Plan
2
3> **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.
4
5**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.
6
7**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.
8
9**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.
10
11**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.
12
13## Global Constraints
14
15- 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.
16- CI cross-compiles darwin and windows: linux-only code needs `//go:build linux` plus a `!linux` stub.
17- 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`.
18- Heavy loops run under `nice`; no -race full suite until the end of a plan.
19- Production use needs nested boxes; the claim-armed path is refused here and left to #250 (spec §3.4).
20- Commits end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
21- 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/seeds` keeps its own `Floor` and `ReleaseAtOrAbove`.
22- New sentences go in new files (`internal/host/seedsfloor.go`, `internal/console/seedfloor.go`, `system_seeds.go`), never in a file listed in `movesScanFiles` (`approved_moves_copy_test.go:541`): the moves gate's `TestMovesLegacyExemptionsAreLegacy` refuses a new sentence in its legacy-exempt files.
23- `TestClaimAuthoritySourceCensus` (`internal/console/claim_authority_census_test.go`) inventories every console call named in its `selected` set (`Open`, `NewS3Store`, `WithAppMutation`, and others); add each new call site this plan writes in `internal/console` or `cmd/console` to `internal/console/testdata/claim-authority-sites.txt` in the format the failing test prints.
24- When a task wires a deck row, append its id to `seededWiredIDs` in `approved_seeded_copy_test.go` in the same commit.
25- Write each deck sentence as exactly one string literal or one format string holding the row's text, with `%s` or `%d` at 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 with `fmt.Fprintln(w, sentence)` or a `" %s\n"` format around a separately built sentence.
26
27## Review Focus
28
291. A box whose `seeds-floor.min-release` was written but whose `seeds-floor.json` was not (a crash between the two writes) must not let the units pick a `carlos.prev` the Go check would refuse. The write order is min-release first, JSON last, as for the moves floor. Test in Task 2 (`TestSeedsFloorFilesWriteOrder`).
302. 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").
313. 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`).
324. 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`).
335. Publishing a lower seeds floor must be refused, and publishing the same one again must succeed without change. Test in Task 1 (`TestPublishFloorOnlyRises`).
34
35---
36
37## File structure
38
39| path | responsibility |
40|---|---|
41| `internal/seeds/floor.go` | `ProtocolVersion`, `RequiredProtocol`, `FloorKey`, local file names, `Floor`, `ReadFloor`, `PublishFloor`, `ReleaseAtOrAbove` |
42| `internal/host/seedsfloor.go` | the box's local seeds floor, its verdict, the activation refusal, `armSeedsFloor`, the edges fields |
43| `internal/host/movesfloor.go` | `movesFloorRefresh` and `rootJudgePrev` also do the seeds floor |
44| `internal/host/systemapply.go` | `armFloor`, `notePrev`, `floorArmed`, `activationRule`, `activationGuard`, `floorAllowsForward` call the seeds half |
45| `internal/host/units/carlos-system-recover.service`, `carlos-system-update.service` | the second clause |
46| `internal/host/edgesfile.go` | three fields; the loop calls `withSeedsFields` |
47| `internal/console/instancesplacement.go` | the reader's three fields |
48| `internal/console/seedfloor.go` | `seedRegionReady`, `handleAPISystemSeedsFloor` |
49| `internal/console/authcli.go` | the route |
50| `system.go`, `system_seeds.go` | `carlos system seeds-floor publish` |
51| `docs/site/cli.md` | SD-N111 under `### carlos system` |
52
53---
54
55### Task 1: The floor object
56
57**Files:**
58- Create: `internal/seeds/floor.go`
59- Test: `internal/seeds/floor_test.go`
60
61**Interfaces:**
62- 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`.
63
64- [ ] **Step 1: Write the failing tests**
65
66```go
67package seeds
68
69import (
70 "context"
71 "errors"
72 "testing"
73
74 "github.com/carlosframework/platform/internal/release"
75)
76
77func TestReadFloorAbsentIsNil(t *testing.T) {
78 s := release.NewDirStore(t.TempDir())
79 f, err := ReadFloor(context.Background(), s)
80 if f != nil || err != nil {
81 t.Fatalf("absent floor = %+v, %v; want nil, nil", f, err)
82 }
83 if _, err := s.Put(context.Background(), FloorKey, []byte("{not json"), ""); err != nil {
84 t.Fatal(err)
85 }
86 if _, err := ReadFloor(context.Background(), s); err == nil {
87 t.Fatal("garbage at the floor key read as no floor")
88 }
89}
90
91func TestPublishFloorOnlyRises(t *testing.T) {
92 ctx := context.Background()
93 s := release.NewDirStore(t.TempDir())
94 if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.27.0"}); err != nil {
95 t.Fatal(err)
96 }
97 if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.27.0"}); err != nil {
98 t.Fatalf("the same floor again: %v", err)
99 }
100 var low *FloorLowerError
101 if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.26.9"}); !errors.As(err, &low) || low.Current != "v0.27.0" {
102 t.Fatalf("a lower floor: err = %v, want FloorLowerError naming v0.27.0", err)
103 }
104 if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.28.0"}); err != nil {
105 t.Fatalf("a higher floor: %v", err)
106 }
107 f, err := ReadFloor(ctx, s)
108 if err != nil || f == nil || f.MinRelease != "v0.28.0" || f.Min != RequiredProtocol {
109 t.Fatalf("floor = %+v, %v", f, err)
110 }
111 if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.29.0-rc.1"}); err == nil {
112 t.Fatal("a prerelease floor published")
113 }
114}
115
116func TestReleaseAtOrAbove(t *testing.T) {
117 for _, c := range []struct {
118 v, min string
119 want bool
120 err bool
121 }{
122 {"v0.27.0", "v0.27.0", true, false},
123 {"v0.27.1", "v0.27.0", true, false},
124 {"v0.26.9", "v0.27.0", false, false},
125 {"v0.28.0-rc.1", "v0.27.0", false, true},
126 {"v0.27.0", "garbage", false, true},
127 } {
128 got, err := ReleaseAtOrAbove(c.v, c.min)
129 if got != c.want || (err != nil) != c.err {
130 t.Errorf("ReleaseAtOrAbove(%s, %s) = %v, %v", c.v, c.min, got, err)
131 }
132 }
133}
134
135func TestProtocolStartsBelowWhatTheConsoleRequires(t *testing.T) {
136 // Plan 3 ships the floor dark: no box built from it can satisfy the
137 // console's check. Plan 7 Task 7 raises ProtocolVersion and rewrites
138 // this test.
139 if ProtocolVersion >= RequiredProtocol {
140 t.Fatalf("ProtocolVersion = %d, RequiredProtocol = %d", ProtocolVersion, RequiredProtocol)
141 }
142}
143```
144
145- [ ] **Step 2: Run the tests to verify they fail**
146
147Run: `go test ./internal/seeds/ -run 'TestReadFloor|TestPublishFloor|TestReleaseAtOrAbove|TestProtocol' -count=1`
148Expected: FAIL to compile: `undefined: ReadFloor`.
149
150- [ ] **Step 3: Write `internal/seeds/floor.go`**
151
152```go
153package seeds
154
155import (
156 "context"
157 "encoding/json"
158 "errors"
159 "fmt"
160 "os"
161 "strings"
162
163 "github.com/carlosframework/platform/internal/release"
164)
165
166// ProtocolVersion is the seeds protocol this build speaks. 0 until the box
167// can place, swap and roll back a seed (Plan 7 raises it to 1): a box that
168// reports 0 can never satisfy the console's RequiredProtocol.
169const ProtocolVersion = 0
170
171// RequiredProtocol is what the console requires every box in the region to
172// report before it writes any seed state (spec §3.3).
173const RequiredProtocol = 1
174
175// FloorKey is the operator-published seeds floor (spec §3.3).
176const FloorKey = "control/seeds-protocol.json"
177
178// The box's local copy of the floor and its verdict on carlos.prev, beside
179// the moves floor's files under the same floor.lock
180// (internal/host/movesfloor.go). The two system units read the second and
181// third by fixed path.
182const (
183 FloorFile = ".moves/seeds-floor.json" // under the box's DataDir
184 FloorMinReleaseFile = ".moves/seeds-floor.min-release" // under the box's DataDir
185 PrevAllowedFile = "carlos.prev.seeds-allowed" // beside carlos.prev in the opt dir
186)
187
188// Floor is the seeds floor: the protocol every box must speak, and the
189// first release that speaks it.
190type Floor struct {
191 Min int `json:"min"`
192 MinRelease string `json:"min_release"`
193}
194
195// ReadFloor reads the published floor. Absent is (nil, nil); present but
196// unreadable is an error, never a silent "no floor".
197func ReadFloor(ctx context.Context, s release.Store) (*Floor, error) {
198 f, _, err := readFloorETag(ctx, s)
199 return f, err
200}
201
202func readFloorETag(ctx context.Context, s release.Store) (*Floor, string, error) {
203 body, etag, err := s.Get(ctx, FloorKey)
204 if errors.Is(err, os.ErrNotExist) {
205 return nil, "", nil
206 }
207 if err != nil {
208 return nil, "", fmt.Errorf("seeds: read floor: %w", err)
209 }
210 var f Floor
211 if err := json.Unmarshal(body, &f); err != nil {
212 return nil, "", fmt.Errorf("seeds: parse floor: %w", err)
213 }
214 return &f, etag, nil
215}
216
217// ReleaseAtOrAbove reports whether version is at or above minRelease, both
218// vMAJOR.MINOR.PATCH. It fails closed: a prerelease or a malformed value is
219// false with an error.
220func ReleaseAtOrAbove(version, minRelease string) (bool, error) {
221 min, ok := release.ParseSemVer(strings.TrimPrefix(minRelease, "v"))
222 if !ok {
223 return false, fmt.Errorf("seeds: malformed minimum release %q", minRelease)
224 }
225 v, ok := release.ParseSemVer(strings.TrimPrefix(version, "v"))
226 if !ok {
227 return false, fmt.Errorf("seeds: unparseable release %q", version)
228 }
229 return v.Compare(min) >= 0, nil
230}
231
232// FloorLowerError is PublishFloor refusing to lower the floor.
233type FloorLowerError struct{ Current string }
234
235func (e *FloorLowerError) Error() string {
236 return "seeds: the published floor is already " + e.Current
237}
238
239// PublishFloor writes the floor by CAS. A floor only rises: a lower
240// min_release is a *FloorLowerError; the same one is a no-op success.
241func PublishFloor(ctx context.Context, s release.Store, f Floor) error {
242 if ok, err := ReleaseAtOrAbove(f.MinRelease, f.MinRelease); !ok || err != nil {
243 return fmt.Errorf("seeds: %q is not a release", f.MinRelease)
244 }
245 cur, etag, err := readFloorETag(ctx, s)
246 if err != nil {
247 return err
248 }
249 if cur != nil {
250 if ok, _ := ReleaseAtOrAbove(f.MinRelease, cur.MinRelease); !ok {
251 return &FloorLowerError{Current: cur.MinRelease}
252 }
253 if *cur == f {
254 return nil
255 }
256 }
257 body, err := json.Marshal(f)
258 if err != nil {
259 return err
260 }
261 _, err = s.Put(ctx, FloorKey, body, etag)
262 return err
263}
264```
265
266`release.ParseSemVer` and `SemVer.Compare` are the ones `internal/moves/floor.go:88` uses.
267
268- [ ] **Step 4: Run the tests to verify they pass**
269
270Run: `go test ./internal/seeds/ -count=1`
271Expected: PASS.
272
273- [ ] **Step 5: Commit**
274
275```bash
276git add internal/seeds/floor.go internal/seeds/floor_test.go
277git commit -m "seeds: the floor object, published by CAS and only rising (Plan 3 Task 1)
278
279Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
280```
281
282### Task 2: The box's local seeds floor and its verdict on `carlos.prev`
283
284**Files:**
285- Create: `internal/host/seedsfloor.go`
286- Modify: `internal/host/movesfloor.go:447-475` (`rootJudgePrev`, `movesFloorRefresh`)
287- Test: `internal/host/seedsfloor_test.go`, `internal/host/seedsfloor_linux_test.go` (`//go:build linux`)
288
289**Interfaces:**
290- 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 in `internal/host`).
291- 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`.
292
293- [ ] **Step 1: Write the failing tests**
294
295`internal/host/seedsfloor_linux_test.go` (the floor files are written through `movesDir`, which is Linux-only):
296
297```go
298//go:build linux
299
300package host
301
302import (
303 "context"
304 "os"
305 "path/filepath"
306 "testing"
307
308 "github.com/carlosframework/platform/internal/release"
309 "github.com/carlosframework/platform/internal/seeds"
310)
311
312func publishSeedsFloor(t *testing.T, s release.Store, rel string) {
313 t.Helper()
314 if err := seeds.PublishFloor(context.Background(), s, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: rel}); err != nil {
315 t.Fatal(err)
316 }
317}
318
319func TestSyncLocalSeedsFloorArmsAndOnlyRises(t *testing.T) {
320 ctx := context.Background()
321 dd := t.TempDir()
322 s := release.NewDirStore(t.TempDir())
323 if f, err := syncLocalSeedsFloor(ctx, s, dd); f != nil || err != nil {
324 t.Fatalf("nothing published: %+v, %v", f, err)
325 }
326 if seedsFloorArmed(dd) {
327 t.Fatal("armed with nothing published")
328 }
329 publishSeedsFloor(t, s, "v0.27.0")
330 f, err := syncLocalSeedsFloor(ctx, s, dd)
331 if err != nil || f == nil || f.MinRelease != "v0.27.0" || !seedsFloorArmed(dd) {
332 t.Fatalf("after publish: %+v, %v", f, err)
333 }
334 if line, ok := readBeneathLine(dd, seeds.FloorMinReleaseFile); !ok || line != "v0.27.0" {
335 t.Fatalf("min-release file = %q, %v", line, ok)
336 }
337 // Once armed, armed: an object that disappears leaves the files.
338 if err := s.Delete(ctx, seeds.FloorKey); err != nil {
339 t.Fatal(err)
340 }
341 if f, _ := syncLocalSeedsFloor(ctx, s, dd); f == nil || f.MinRelease != "v0.27.0" {
342 t.Fatalf("after the object went: %+v", f)
343 }
344}
345
346func TestSeedsFloorFilesWriteOrder(t *testing.T) {
347 dd := t.TempDir()
348 // A crash after the min-release file and before the JSON: the Go
349 // readers see no floor, and the units' line is AHEAD of every verdict.
350 if err := os.MkdirAll(filepath.Join(dd, ".moves"), 0o755); err != nil {
351 t.Fatal(err)
352 }
353 if err := os.WriteFile(filepath.Join(dd, seeds.FloorMinReleaseFile), []byte("v0.27.0\n"), 0o644); err != nil {
354 t.Fatal(err)
355 }
356 if f, err := readLocalSeedsFloor(dd); f != nil || err != nil {
357 t.Fatalf("readers trust only the JSON: %+v, %v", f, err)
358 }
359 // And the writer writes the line first, the JSON last.
360 var order []string
361 writeHook = func(name string) { order = append(order, name) }
362 defer func() { writeHook = nil }()
363 if err := withFloorLock(dd, true, func(d *movesDir) error {
364 return writeSeedsFloorFiles(d, seeds.Floor{Min: 1, MinRelease: "v0.28.0"})
365 }); err != nil {
366 t.Fatal(err)
367 }
368 if len(order) != 2 || order[0] != filepath.Base(seeds.FloorMinReleaseFile) || order[1] != filepath.Base(seeds.FloorFile) {
369 t.Fatalf("write order = %q", order)
370 }
371}
372
373func TestJudgeSeedsPrev(t *testing.T) {
374 opt, dd := t.TempDir(), t.TempDir()
375 verdict := seedsPrevAllowedPath(opt)
376 // No carlos.prev: no verdict.
377 if err := judgeSeedsPrev(opt, dd); err != nil {
378 t.Fatal(err)
379 }
380 if _, err := os.Stat(verdict); !os.IsNotExist(err) {
381 t.Fatal("a verdict with no carlos.prev")
382 }
383 if err := os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755); err != nil {
384 t.Fatal(err)
385 }
386 if err := writePrevVersion(opt, "v0.27.2"); err != nil {
387 t.Fatal(err)
388 }
389 // No seeds floor: no verdict (the units do not read one then).
390 if err := judgeSeedsPrev(opt, dd); err != nil {
391 t.Fatal(err)
392 }
393 if _, err := os.Stat(verdict); !os.IsNotExist(err) {
394 t.Fatal("a verdict with no seeds floor")
395 }
396 s := release.NewDirStore(t.TempDir())
397 publishSeedsFloor(t, s, "v0.27.0")
398 if _, err := syncLocalSeedsFloor(context.Background(), s, dd); err != nil {
399 t.Fatal(err)
400 }
401 if err := judgeSeedsPrev(opt, dd); err != nil {
402 t.Fatal(err)
403 }
404 if line, ok := readLine(verdict); !ok || line != "v0.27.0" {
405 t.Fatalf("verdict = %q, %v; want the floor's min_release", line, ok)
406 }
407 // carlos.prev below the floor: the verdict goes.
408 if err := writePrevVersion(opt, "v0.26.9"); err != nil {
409 t.Fatal(err)
410 }
411 if err := judgeSeedsPrev(opt, dd); err != nil {
412 t.Fatal(err)
413 }
414 if _, err := os.Stat(verdict); !os.IsNotExist(err) {
415 t.Fatal("a verdict for a carlos.prev below the floor")
416 }
417}
418```
419
420`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.
421
422- [ ] **Step 2: Run the tests to verify they fail**
423
424Run: `go test ./internal/host/ -run 'TestSyncLocalSeedsFloor|TestSeedsFloorFilesWriteOrder|TestJudgeSeedsPrev' -count=1`
425Expected: FAIL to compile: `undefined: syncLocalSeedsFloor`.
426
427- [ ] **Step 3: Write `internal/host/seedsfloor.go`**
428
429```go
430package host
431
432import (
433 "context"
434 "encoding/json"
435 "errors"
436 "fmt"
437 "io"
438 "os"
439 "path/filepath"
440
441 "github.com/carlosframework/platform/internal/release"
442 "github.com/carlosframework/platform/internal/seeds"
443)
444
445// The seeds protocol floor on a box (seeded-instances spec §3.3, codex 16):
446// the moves floor's mechanism (movesfloor.go), beside it. Its two local
447// files live in the same .moves directory and are written under the same
448// floor.lock; its verdict on carlos.prev sits beside the moves verdict; the
449// two system units carry a second clause for it; the activation rule
450// checks it as well. A deployment that never publishes it has none of the
451// files, and every path here then does what it did before.
452
453func seedsPrevAllowedPath(optDir string) string { return filepath.Join(optDir, seeds.PrevAllowedFile) }
454
455// readLocalSeedsFloor reads <dataDir>/.moves/seeds-floor.json without
456// following a symlink below dataDir. (nil, nil): not armed. A file that is
457// there but unusable is an error, which callers treat as armed and unknown.
458func readLocalSeedsFloor(dataDir string) (*seeds.Floor, error) {
459 f, err := openBeneath(dataDir, filepath.Dir(seeds.FloorFile), filepath.Base(seeds.FloorFile))
460 if errors.Is(err, os.ErrNotExist) {
461 return nil, nil
462 }
463 if err != nil {
464 return nil, err
465 }
466 defer f.Close()
467 st, err := f.Stat()
468 if err != nil {
469 return nil, err
470 }
471 if !st.Mode().IsRegular() || st.Size() > movesFloorMaxBytes {
472 return nil, fmt.Errorf("%s is not a regular file under %d bytes", f.Name(), movesFloorMaxBytes)
473 }
474 var fl seeds.Floor
475 if err := json.NewDecoder(io.LimitReader(f, movesFloorMaxBytes)).Decode(&fl); err != nil {
476 return nil, fmt.Errorf("%s: %w", f.Name(), err)
477 }
478 if ok, err := seeds.ReleaseAtOrAbove(fl.MinRelease, fl.MinRelease); !ok || err != nil {
479 return nil, fmt.Errorf("%s: min_release %q is not a release", f.Name(), fl.MinRelease)
480 }
481 return &fl, nil
482}
483
484// seedsFloorArmed: a local seeds floor that reads, or one that is there and
485// cannot be read.
486func seedsFloorArmed(dataDir string) bool {
487 f, err := readLocalSeedsFloor(dataDir)
488 return err != nil || f != nil
489}
490
491// writeSeedsFloorFiles writes the min-release line FIRST and the JSON LAST,
492// for writeFloorFiles' reason: after a crash between them the line can only
493// be ahead of the JSON, so the units never pick a carlos.prev the Go checks
494// would refuse.
495func writeSeedsFloorFiles(d *movesDir, f seeds.Floor) error {
496 body, err := json.Marshal(f)
497 if err != nil {
498 return err
499 }
500 if err := d.WriteDurable(filepath.Base(seeds.FloorMinReleaseFile), []byte(f.MinRelease+"\n"), 0o644); err != nil {
501 return err
502 }
503 return d.WriteDurable(filepath.Base(seeds.FloorFile), append(body, '\n'), 0o644)
504}
505
506// syncLocalSeedsFloor is syncLocalFloor's rule for the seeds floor: a
507// published, usable floor that is not lower is written under the floor
508// lock; absent leaves what is on disk; a lower one is refused; an
509// unreadable store leaves what is on disk and returns the error.
510func syncLocalSeedsFloor(ctx context.Context, s release.Store, dataDir string) (*seeds.Floor, error) {
511 if s == nil {
512 local, lerr := readLocalSeedsFloor(dataDir)
513 return local, errors.Join(errors.New("no deployment store to read the seeds floor from"), lerr)
514 }
515 remote, rerr := seeds.ReadFloor(ctx, s)
516 switch {
517 case rerr != nil:
518 local, lerr := readLocalSeedsFloor(dataDir)
519 return local, errors.Join(rerr, lerr)
520 case remote == nil:
521 return readLocalSeedsFloor(dataDir)
522 }
523 if ok, err := seeds.ReleaseAtOrAbove(remote.MinRelease, remote.MinRelease); !ok || err != nil {
524 local, lerr := readLocalSeedsFloor(dataDir)
525 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)
526 }
527 var out *seeds.Floor
528 ran := false
529 err := withFloorLock(dataDir, true, func(d *movesDir) error {
530 ran = true
531 local, lerr := readLocalSeedsFloor(dataDir)
532 if lerr == nil && local != nil {
533 if remote.Min < local.Min {
534 out = local
535 return fmt.Errorf("the published seeds floor's protocol minimum %d is below this box's %d", remote.Min, local.Min)
536 }
537 if ok, _ := seeds.ReleaseAtOrAbove(remote.MinRelease, local.MinRelease); !ok {
538 out = local
539 return fmt.Errorf("the published seeds min_release %q is below this box's %s", remote.MinRelease, local.MinRelease)
540 }
541 if *local == *remote {
542 if line, ok := readBeneathLine(dataDir, seeds.FloorMinReleaseFile); ok && line == remote.MinRelease {
543 out = local
544 return nil
545 }
546 }
547 }
548 if err := writeSeedsFloorFiles(d, *remote); err != nil {
549 out, _ = readLocalSeedsFloor(dataDir)
550 return fmt.Errorf("%w: %v", errFloorWrite, err)
551 }
552 out = remote
553 return nil
554 })
555 if !ran {
556 out, _ = readLocalSeedsFloor(dataDir)
557 return out, fmt.Errorf("%w: %v", errFloorWrite, err)
558 }
559 return out, err
560}
561
562// judgeSeedsPrev writes the seeds verdict when carlos.prev is at or above
563// the armed seeds floor, and removes it otherwise (no carlos.prev, no
564// floor, no version, below). Unlike the moves verdict it does not read the
565// registry: a seed has no hold rows.
566func judgeSeedsPrev(optDir, dataDir string) error {
567 deny := func(cause error) error {
568 if err := removeDurable(seedsPrevAllowedPath(optDir)); err != nil {
569 return errors.Join(cause, fmt.Errorf("remove the seeds verdict on carlos.prev: %w", err))
570 }
571 return cause
572 }
573 if _, err := os.Stat(systemPrevPath(optDir)); err != nil {
574 return deny(nil)
575 }
576 ran := false
577 err := withFloorLock(dataDir, false, func(*movesDir) error {
578 ran = true
579 floor, err := readLocalSeedsFloor(dataDir)
580 if err != nil {
581 return deny(fmt.Errorf("seeds protocol floor: the floor on this box cannot be read: %v", err))
582 }
583 if floor == nil {
584 return deny(nil)
585 }
586 version, ok := readLine(prevVersionPath(optDir))
587 if !ok {
588 return deny(nil)
589 }
590 if at, _ := seeds.ReleaseAtOrAbove(version, floor.MinRelease); !at {
591 return deny(nil)
592 }
593 return writeFileDurable(seedsPrevAllowedPath(optDir), []byte(floor.MinRelease+"\n"), 0o644)
594 })
595 if !ran {
596 return deny(fmt.Errorf("seeds protocol floor: the floor on this box cannot be read: %v", err))
597 }
598 return err
599}
600
601// rootJudgeSeedsPrev is the root adopt pass's call: nothing to do on a box
602// that has neither a seeds floor nor a seeds verdict.
603func rootJudgeSeedsPrev() error {
604 optDir, dd := release.OptDir(), dataDir()
605 if !seedsFloorArmed(dd) {
606 if _, err := os.Stat(seedsPrevAllowedPath(optDir)); errors.Is(err, os.ErrNotExist) {
607 return nil
608 }
609 }
610 return withPrevLock(optDir, func() error { return judgeSeedsPrev(optDir, dd) })
611}
612
613// seedsFloorRefresh is the agent's hourly refresh of the seeds floor.
614func seedsFloorRefresh(ctx context.Context, s release.Store, dataDir string, logf func(string, ...any)) {
615 if _, err := syncLocalSeedsFloor(ctx, s, dataDir); err != nil {
616 logf("agent: seeds protocol floor refresh: %v", err)
617 }
618}
619```
620
621- [ ] **Step 4: Call the seeds half from the moves floor's loop and the adopt pass**
622
623In `internal/host/movesfloor.go`, `movesFloorRefresh` (`:469`) gains one line after its own sync:
624
625```go
626 seedsFloorRefresh(ctx, s, dataDir, logf)
627```
628
629and `rootJudgePrev` (`:447`) returns both judgements. Replace its body's two `return` paths so the seeds judgement always runs:
630
631```go
632func rootJudgePrev(reg *registry.Registry) error {
633 return errors.Join(rootJudgeMovesPrev(reg), rootJudgeSeedsPrev())
634}
635
636// rootJudgeMovesPrev is the moves half, rootJudgePrev's body before the
637// seeds floor existed, unchanged.
638func rootJudgeMovesPrev(reg *registry.Registry) error {
639 optDir, dd := release.OptDir(), dataDir()
640 // ... the existing body, verbatim
641}
642```
643
644- [ ] **Step 5: Run the tests to verify they pass**
645
646Run: `go test ./internal/host/ -run 'TestSyncLocalSeedsFloor|TestSeedsFloorFilesWriteOrder|TestJudgeSeedsPrev|TestJudgePrev|TestSyncLocalFloor|TestFloor' -count=1`
647Expected: PASS.
648
649- [ ] **Step 6: Commit**
650
651```bash
652git add internal/host/seedsfloor.go internal/host/seedsfloor_linux_test.go internal/host/movesfloor.go internal/host/movesfloor_linux.go
653git commit -m "host: the box's local seeds floor and its verdict on carlos.prev (Plan 3 Task 2)
654
655Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
656```
657
658### Task 3: The system units and the activation rule
659
660**Files:**
661- Modify: `internal/host/units/carlos-system-recover.service:29`, `internal/host/units/carlos-system-update.service:35` and both files' header comments
662- Modify: `internal/host/systemapply.go` (`armFloor` `:833`, `notePrev` `:784`, `floorArmed` `:808`, `activationRule` `:1010`, `activationGuard` `:933`, `floorAllowsForward` `:1041`)
663- Modify: `internal/host/seedsfloor.go` (add `armSeedsFloor`, `seedsActivationRefusal`)
664- Test: `internal/host/seedsfloor_test.go`, `internal/host/host_test.go` (`TestSystemUnitsAreShapedLikeTheStageRelay`'s want lists)
665
666**Interfaces:**
667- Consumes: Task 2's functions; `refuse(format string, args ...any) error`, `errSystemDeferred`, `(a *systemApplier).ops.logf` (all `systemapply.go`).
668- Produces: `seedsActivationRefusal(version, dataDir string) error` (an `activationRefused`, SD-N1); `(a *systemApplier) armSeedsFloor(ctx) error`.
669
670- [ ] **Step 1: Write the failing tests**
671
672`internal/host/seedsfloor_test.go` (not Linux-only: it runs `/bin/sh`, which every CI unit runner has; skip on Windows):
673
674```go
675package host
676
677import (
678 "errors"
679 "os"
680 "os/exec"
681 "path/filepath"
682 "runtime"
683 "strings"
684 "testing"
685
686 "github.com/carlosframework/platform/internal/moves"
687 "github.com/carlosframework/platform/internal/seeds"
688)
689
690func TestSystemUnitsSelectPrevByTheSeedsFloor(t *testing.T) {
691 if runtime.GOOS == "windows" {
692 t.Skip("no /bin/sh")
693 }
694 for _, unit := range []string{"carlos-system-recover.service", "carlos-system-update.service"} {
695 t.Run(unit, func(t *testing.T) {
696 b, err := unitsFS.ReadFile("units/" + unit)
697 if err != nil {
698 t.Fatal(err)
699 }
700 var line string
701 for _, l := range strings.Split(string(b), "\n") {
702 if strings.HasPrefix(l, "ExecStart=") {
703 line = l
704 }
705 }
706 if strings.ContainsAny(line, "%$") {
707 t.Fatalf("ExecStart carries a %% or a $: %s", line)
708 }
709 for _, path := range []string{"/data/" + seeds.FloorMinReleaseFile, "/opt/carlos/" + seeds.PrevAllowedFile} {
710 if !strings.Contains(line, path) {
711 t.Fatalf("ExecStart does not read %s: %s", path, line)
712 }
713 }
714 body := strings.TrimSuffix(strings.TrimPrefix(line, "ExecStart=/bin/sh -c '"), "'")
715 for _, tc := range []struct {
716 name string
717 movesFloor, movesOK string
718 seedsFloor, seedsOK string
719 want string
720 }{
721 {"no seeds floor", "", "", "", "", "carlos.prev"},
722 {"seeds floor and a verdict naming it", "", "", "v0.27.0\n", "v0.27.0\n", "carlos.prev"},
723 {"seeds floor and no verdict", "", "", "v0.27.0\n", "", "carlos"},
724 {"a seeds verdict for an older floor", "", "", "v0.27.0\n", "v0.26.0\n", "carlos"},
725 {"both floors, both verdicts", "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "v0.27.0\n", "carlos.prev"},
726 {"both floors, only the moves verdict", "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "", "carlos"},
727 {"both floors, only the seeds verdict", "v0.30.0\n", "", "v0.27.0\n", "v0.27.0\n", "carlos"},
728 } {
729 t.Run(tc.name, func(t *testing.T) {
730 root := t.TempDir()
731 opt, data, ran := filepath.Join(root, "opt"), filepath.Join(root, "data"), filepath.Join(root, "ran")
732 os.MkdirAll(filepath.Join(opt, "system", "router"), 0o755)
733 os.MkdirAll(filepath.Join(data, ".moves"), 0o755)
734 for _, name := range []string{"carlos", "carlos.prev"} {
735 os.WriteFile(filepath.Join(opt, name), []byte("#!/bin/sh\necho "+name+" > "+ran+"\n"), 0o755)
736 }
737 os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0o600)
738 put := func(path, body string) {
739 if body != "" {
740 os.WriteFile(path, []byte(body), 0o644)
741 }
742 }
743 put(filepath.Join(data, moves.FloorMinReleaseFile), tc.movesFloor)
744 put(filepath.Join(opt, moves.PrevAllowedFile), tc.movesOK)
745 put(filepath.Join(data, seeds.FloorMinReleaseFile), tc.seedsFloor)
746 put(filepath.Join(opt, seeds.PrevAllowedFile), tc.seedsOK)
747 script := strings.ReplaceAll(strings.ReplaceAll(body, "/data/", data+"/"), "/opt/carlos/", opt+"/")
748 if out, err := exec.Command("/bin/sh", "-c", script).CombinedOutput(); err != nil {
749 t.Fatalf("%v: %s", err, out)
750 }
751 got, _ := os.ReadFile(ran)
752 if strings.TrimSpace(string(got)) != tc.want {
753 t.Fatalf("ran %q, want %s", strings.TrimSpace(string(got)), tc.want)
754 }
755 })
756 }
757 })
758 }
759}
760
761func TestSeedsActivationRefusal(t *testing.T) {
762 dd := t.TempDir()
763 if err := seedsActivationRefusal("v0.20.0", dd); err != nil {
764 t.Fatalf("no seeds floor: %v", err)
765 }
766 os.MkdirAll(filepath.Join(dd, ".moves"), 0o755)
767 os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{"min":1,"min_release":"v0.27.0"}`), 0o644)
768 if err := seedsActivationRefusal("v0.27.0", dd); err != nil {
769 t.Fatalf("at the floor: %v", err)
770 }
771 err := seedsActivationRefusal("v0.26.9", dd)
772 var refused activationRefused
773 if !errors.As(err, &refused) || err.Error() != "seeds protocol floor: v0.26.9 is below v0.27.0" {
774 t.Fatalf("below the floor: %v", err)
775 }
776 os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{garbage`), 0o644)
777 if err := seedsActivationRefusal("v0.30.0", dd); !errors.As(err, &refused) {
778 t.Fatalf("an unreadable seeds floor: %v, want a refusal", err)
779 }
780}
781```
782
783Add `"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.
784
785- [ ] **Step 2: Run the tests to verify they fail**
786
787Run: `go test ./internal/host/ -run 'TestSystemUnitsSelectPrevByTheSeedsFloor|TestSeedsActivationRefusal|TestSystemUnitsAreShapedLikeTheStageRelay' -count=1`
788Expected: FAIL: the units lack the seeds paths; `undefined: seedsActivationRefusal`.
789
790- [ ] **Step 3: Add the units' second clause**
791
792In both unit files, replace the `ExecStart=` line. Recover unit:
793
794```
795ExecStart=/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'
796```
797
798Update 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:
799
800```
801# The seeds floor (seeded-instances spec §3.3) adds the same test for its
802# own files: once /data/.moves/seeds-floor.min-release is there and not
803# empty, carlos.prev runs only when /opt/carlos/carlos.prev.seeds-allowed
804# names that same floor, byte for byte.
805```
806
807- [ ] **Step 4: Add the activation refusal and the apply's arming to `seedsfloor.go`**
808
809```go
810// seedsActivationRefusal is the seeds half of the activation rule: version
811// below the armed seeds floor is refused (SD-N1). The floor is read from
812// the local file alone (boot recovery runs before the network). Unarmed:
813// nil.
814func seedsActivationRefusal(version, dataDir string) error {
815 floor, err := readLocalSeedsFloor(dataDir)
816 if err != nil {
817 return refuse("seeds protocol floor: the floor on this box cannot be read: %v", err)
818 }
819 if floor == nil {
820 return nil
821 }
822 if ok, _ := seeds.ReleaseAtOrAbove(version, floor.MinRelease); !ok {
823 return refuse("seeds protocol floor: %s is below %s", version, floor.MinRelease)
824 }
825 return nil
826}
827
828// armSeedsFloor is armFloor's seeds half, with the same rule: a published
829// floor this box could not write down defers the apply; anything else is a
830// log line.
831func (a *systemApplier) armSeedsFloor(ctx context.Context) error {
832 f, err := syncLocalSeedsFloor(ctx, a.store, a.dataDir)
833 if err == nil {
834 return nil
835 }
836 if errors.Is(err, errFloorWrite) {
837 return fmt.Errorf("%w: seeds protocol floor: %v", errSystemDeferred, err)
838 }
839 onDisk := "none"
840 if f != nil {
841 onDisk = f.MinRelease
842 }
843 a.ops.logf("system apply: seeds protocol floor: %v (the seeds floor on this box stays %s)", err, onDisk)
844 return nil
845}
846```
847
848`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.
849
850- [ ] **Step 5: Wire the seeds half into `systemapply.go`**
851
852- `armFloor` (`:833`), first statement: `if err := a.armSeedsFloor(ctx); err != nil { return err }`.
853- `notePrev` (`:784`): in the `replace` branch, add `removeDurable(seedsPrevAllowedPath(a.optDir))` to the `errors.Join` with the moves verdict and sidecar; replace the final `return a.floorStep(judgePrevWith(…))` with `return a.floorStep(errors.Join(judgePrevWith(a.optDir, a.dataDir, a.freezeHolds, func() string { return a.prevBuild(ctx) }), judgeSeedsPrev(a.optDir, a.dataDir)))`.
854- `floorArmed` (`:808`): `return err != nil || f != nil || seedsFloorArmed(a.dataDir)`.
855- `activationRule` (`:1010`), first statement: `if err := seedsActivationRefusal(version, a.dataDir); err != nil { return err }`.
856- `activationGuard` (`:933`), inside the `withFloorLock` closure, before `floor, err := readLocalFloor(a.dataDir)`: `if err := seedsActivationRefusal(target, a.dataDir); err != nil { return err }`.
857- `floorAllowsForward` (`:1041`), first statement: `if err := seedsActivationRefusal(version, a.dataDir); err != nil { return err }`.
858
859- [ ] **Step 6: Run the tests to verify they pass**
860
861Run: `go test ./internal/host/ -run 'TestSystemUnits|TestSeedsActivationRefusal|TestActivation|TestSystemApply|TestNotePrev|TestDecidePrev|TestFloor' -count=1`
862Expected: PASS. The moves floor's existing tests are unchanged: with no seeds files, every new call is a no-op.
863
864- [ ] **Step 7: Wire SD-N1 and commit**
865
866Append `"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).
867
868```bash
869git 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
870git commit -m "host: the seeds floor in the system units and the activation rule (Plan 3 Task 3)
871
872Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
873```
874
875### Task 4: The edges file reports the seeds floor
876
877**Files:**
878- Modify: `internal/host/edgesfile.go:295-305` (struct), `:817` (the loop)
879- Modify: `internal/host/seedsfloor.go` (add `withSeedsFields`)
880- Modify: `internal/console/instancesplacement.go:83-86` (reader)
881- Test: `internal/host/seedsfloor_linux_test.go`
882
883**Interfaces:**
884- 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 on `edgesFilePlacementView`; `withSeedsFields(f edgesFile, dataDir, optDir string) edgesFile`.
885
886- [ ] **Step 1: Write the failing test**
887
888Append to `internal/host/seedsfloor_linux_test.go`:
889
890```go
891func TestWithSeedsFields(t *testing.T) {
892 dd, opt := t.TempDir(), t.TempDir()
893 f := withSeedsFields(edgesFile{Box: "kass-1"}, dd, opt)
894 if f.SeedsProtocol != seeds.ProtocolVersion || f.SeedsFloorMinRelease != "" || f.PrevSeedsAllowedFor != "" {
895 t.Fatalf("unarmed = %+v", f)
896 }
897 s := release.NewDirStore(t.TempDir())
898 publishSeedsFloor(t, s, "v0.27.0")
899 if _, err := syncLocalSeedsFloor(context.Background(), s, dd); err != nil {
900 t.Fatal(err)
901 }
902 os.WriteFile(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644)
903 f = withSeedsFields(f, dd, opt)
904 if f.SeedsFloorMinRelease != "v0.27.0" || f.PrevSeedsAllowedFor != "v0.27.0" {
905 t.Fatalf("armed = %+v", f)
906 }
907 body, _ := json.Marshal(f)
908 if !strings.Contains(string(body), `"seeds_protocol":0`) {
909 t.Fatalf("seeds_protocol must be on the wire even at 0: %s", body)
910 }
911}
912```
913
914(add `"encoding/json"` and `"strings"` to that file's imports).
915
916- [ ] **Step 2: Run it to verify it fails**
917
918Run: `go test ./internal/host/ -run TestWithSeedsFields -count=1`
919Expected: FAIL to compile: `undefined: withSeedsFields`.
920
921- [ ] **Step 3: Add the fields and the writer**
922
923In `edgesFile`, after `PrevAllowedFor`:
924
925```go
926 // The seeds protocol (seeded-instances spec §3.3), read by the console's
927 // seedRegionReady. SeedsProtocol is not omitempty: 0 is a real answer
928 // (a build that cannot place a seed). SeedsFloorMinRelease is the
929 // armed seeds floor's min_release (absent: not armed), and
930 // PrevSeedsAllowedFor the floor carlos.prev was judged at or above.
931 SeedsProtocol int `json:"seeds_protocol"`
932 SeedsFloorMinRelease string `json:"seeds_floor_min_release,omitempty"`
933 PrevSeedsAllowedFor string `json:"prev_seeds_allowed_for,omitempty"`
934```
935
936In `seedsfloor.go`:
937
938```go
939// withSeedsFields sets the edges file's seeds fields from this box's own
940// files, read at each write, as withMovesFields does for the moves floor.
941func withSeedsFields(f edgesFile, dataDir, optDir string) edgesFile {
942 f.SeedsProtocol = seeds.ProtocolVersion
943 f.SeedsFloorMinRelease = ""
944 if fl, err := readLocalSeedsFloor(dataDir); err == nil && fl != nil {
945 f.SeedsFloorMinRelease = fl.MinRelease
946 }
947 f.PrevSeedsAllowedFor, _ = readLine(seedsPrevAllowedPath(optDir))
948 return f
949}
950```
951
952In the loop (`edgesfile.go:817`), after `ident = withMovesFields(ident, dataDir, optDir)`: `ident = withSeedsFields(ident, dataDir, optDir)`.
953
954In `internal/console/instancesplacement.go`, after `PrevAllowedFor` in `edgesFilePlacementView`:
955
956```go
957 // The seeds floor (seeded-instances spec §3.3), read only by
958 // seedRegionReady.
959 SeedsProtocol int `json:"seeds_protocol"`
960 SeedsFloorMinRelease string `json:"seeds_floor_min_release"`
961 PrevSeedsAllowedFor string `json:"prev_seeds_allowed_for"`
962```
963
964- [ ] **Step 4: Run the tests to verify they pass**
965
966Run: `go test ./internal/host/ -run 'TestWithSeedsFields|TestEdgesFile|TestWithMovesFields' -count=1 && go test ./internal/console/ -run 'TestEdges|TestPlacement|TestSelectMoveBoxes' -count=1`
967Expected: PASS.
968
969- [ ] **Step 5: Commit**
970
971```bash
972git add internal/host/edgesfile.go internal/host/seedsfloor.go internal/host/seedsfloor_linux_test.go internal/console/instancesplacement.go
973git commit -m "edges file: the seeds protocol, floor and verdict (Plan 3 Task 4)
974
975Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
976```
977
978### Task 5: The console's region readiness check
979
980**Files:**
981- Create: `internal/console/seedfloor.go`
982- Test: `internal/console/seedfloor_test.go`
983
984**Interfaces:**
985- 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`.
986- Produces: `(s *Server) seedRegionReady(ctx context.Context, region string, oneBox bool) *doorRefusal`; nil means every box in `region` (the record vocabulary: `""` is the home region) is ready. Plans 5 and 6 call it before writing any seed state; the import door passes `oneBox` true (see below).
987
988The order of checks, each a refusal naming the boxes it found (sorted, comma-joined):
989
9901. `s.cfg.HostClaim` on this console: 409, SD-N8.
9912. The published floor: read error 503 SD-N4; absent 409 SD-N3.
9923. The edges snapshot: read error or `!snap.complete()` 503 SD-N5; no edges file for the region 503 SD-N5; with `oneBox`, more than one edges file for the region 409 SD-N113.
9934. Every edges file in the region: `SeedsProtocol < seeds.RequiredProtocol`, or `SeedsFloorMinRelease != floor.MinRelease`, or neither `PrevRelease == "none"` nor `PrevSeedsAllowedFor == floor.MinRelease`: 409 SD-N2.
9945. The heartbeats (`systemBoxes`, joined by label: an edges file's `box` is `metricsBoxLabel`, the same `status.Label` the heartbeat carries): a region box with no heartbeat, a stale one, or one with no `Metrics.Layout`: 503 SD-N5. `Metrics.Layout.Mode != "nested"`: 409 SD-N6. `Metrics.HostClaimEnabled`: 409 SD-N7.
995
996`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.
997
998- [ ] **Step 1: Write the failing tests**
999
1000```go
1001package console
1002
1003import (
1004 "context"
1005 "net/http"
1006 "strconv"
1007 "strings"
1008 "testing"
1009 "time"
1010
1011 "github.com/carlosframework/platform/internal/seeds"
1012 "github.com/carlosframework/platform/internal/status"
1013)
1014
1015func seedReadyBox(t *testing.T, fx *consoleFixture, label, placement string, protocol int, floor, prevAllowed, mode string, claim bool) {
1016 t.Helper()
1017 putEdges(t, fx, map[string]string{label: `{"v":1,"box":"` + label + `","eip":"1.2.3.4","placement":"` + placement +
1018 `","hibernation_configured":true,"hosts":{},"seeds_protocol":` + strconv.Itoa(protocol) +
1019 `,"seeds_floor_min_release":"` + floor + `","prev_release":"v0.27.1","prev_seeds_allowed_for":"` + prevAllowed + `"}`})
1020 var layout *status.LayoutReport
1021 if mode != "" {
1022 layout = &status.LayoutReport{Mode: mode}
1023 }
1024 b := status.CollectBox(nil, status.Probes{Layout: layout, HostClaimEnabled: claim}, time.Now())
1025 b.Label = label
1026 if err := status.WriteBox(context.Background(), fx.rel, b); err != nil {
1027 t.Fatal(err)
1028 }
1029}
1030
1031func publishSeedsFloorFx(t *testing.T, fx *consoleFixture, rel string) {
1032 t.Helper()
1033 if err := seeds.PublishFloor(context.Background(), fx.rel, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: rel}); err != nil {
1034 t.Fatal(err)
1035 }
1036}
1037
1038func TestSeedRegionReady(t *testing.T) {
1039 fx := newConsoleFixture(t)
1040 ctx := context.Background()
1041 if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusConflict || !strings.Contains(ref.Msg, "no seeds floor yet") {
1042 t.Fatalf("no floor: %+v", ref)
1043 }
1044 publishSeedsFloorFx(t, fx, "v0.27.0")
1045 seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false)
1046 if ref := fx.srv.seedRegionReady(ctx, "", false); ref != nil {
1047 t.Fatalf("a ready region: %+v", ref)
1048 }
1049 seedReadyBox(t, fx, "kass-2", "", 0, "v0.27.0", "v0.27.0", "nested", false)
1050 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") {
1051 t.Fatalf("a box on protocol 0: %+v", ref)
1052 }
1053 seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "", "nested", false)
1054 if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "below the seeds floor") {
1055 t.Fatalf("a carlos.prev without a seeds verdict: %+v", ref)
1056 }
1057 seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "flat", false)
1058 if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "nested") {
1059 t.Fatalf("a flat box: %+v", ref)
1060 }
1061 seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "nested", true)
1062 if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "claim control") {
1063 t.Fatalf("a claim-armed box: %+v", ref)
1064 }
1065 fx.srv.cfg.HostClaim = true
1066 if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "This console runs with claim control on") {
1067 t.Fatalf("a claim console: %+v", ref)
1068 }
1069}
1070
1071func TestSeedRegionReadyOneBoxForImports(t *testing.T) {
1072 fx := newConsoleFixture(t)
1073 publishSeedsFloorFx(t, fx, "v0.27.0")
1074 seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false)
1075 seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "nested", false)
1076 if ref := fx.srv.seedRegionReady(context.Background(), "", false); ref != nil {
1077 t.Fatalf("two ready boxes, --from: %+v", ref)
1078 }
1079 if ref := fx.srv.seedRegionReady(context.Background(), "", true); ref == nil || ref.Status != http.StatusConflict || !strings.Contains(ref.Msg, "more than one box") {
1080 t.Fatalf("two ready boxes, import: %+v", ref)
1081 }
1082}
1083
1084func TestSeedRegionReadyRefusesAnUnconfirmedBox(t *testing.T) {
1085 fx := newConsoleFixture(t)
1086 publishSeedsFloorFx(t, fx, "v0.27.0")
1087 seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "", false) // no layout report
1088 if ref := fx.srv.seedRegionReady(context.Background(), "", false); ref == nil || ref.Status != http.StatusServiceUnavailable {
1089 t.Fatalf("a box with no layout report: %+v", ref)
1090 }
1091}
1092
1093func TestSeedRegionReadyIgnoresOtherRegions(t *testing.T) {
1094 fx := newConsoleFixture(t)
1095 publishSeedsFloorFx(t, fx, "v0.27.0")
1096 seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false)
1097 seedReadyBox(t, fx, "uk-1", "uk", 0, "", "", "flat", true)
1098 if ref := fx.srv.seedRegionReady(context.Background(), "", false); ref != nil {
1099 t.Fatalf("a bad box in uk blocked the home region: %+v", ref)
1100 }
1101 if ref := fx.srv.seedRegionReady(context.Background(), "uk", false); ref == nil {
1102 t.Fatal("uk's own bad box did not block uk")
1103 }
1104}
1105```
1106
1107The 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.
1108
1109- [ ] **Step 2: Run the tests to verify they fail**
1110
1111Run: `go test ./internal/console/ -run 'TestSeedRegionReady' -count=1`
1112Expected: FAIL to compile: `fx.srv.seedRegionReady undefined`.
1113
1114- [ ] **Step 3: Write `internal/console/seedfloor.go`**
1115
1116```go
1117package console
1118
1119import (
1120 "context"
1121 "fmt"
1122 "log"
1123 "net/http"
1124 "sort"
1125 "strings"
1126 "time"
1127
1128 "github.com/carlosframework/platform/internal/seeds"
1129 "github.com/carlosframework/platform/internal/status"
1130)
1131
1132// seedRegionReady is the check both seed doors make before writing any seed
1133// state, even awaiting-upload (seeded-instances spec §3.3, §6.3): this
1134// console runs without claim control, the seeds floor is published, and
1135// every box in region has armed it, excluded or vouched for its
1136// carlos.prev, runs nested, and has no claim control. region is the record
1137// vocabulary ("" is the home region). nil means go ahead.
1138func (s *Server) seedRegionReady(ctx context.Context, region string, oneBox bool) *doorRefusal {
1139 if s.cfg.HostClaim {
1140 return &doorRefusal{http.StatusConflict, "This console runs with claim control on. Seeded instances are not supported here yet."}
1141 }
1142 floor, err := seeds.ReadFloor(ctx, s.cfg.Releases)
1143 if err != nil {
1144 log.Printf("console: seeds floor: %v", err)
1145 return &doorRefusal{http.StatusServiceUnavailable, "could not read the seeds floor. Try again."}
1146 }
1147 if floor == nil {
1148 return &doorRefusal{http.StatusConflict, "This deployment has no seeds floor yet. An operator publishes it with carlos system seeds-floor publish."}
1149 }
1150 name := s.homeRegionOut(region)
1151 if name == "" {
1152 name = "the home region"
1153 }
1154 unconfirmed := &doorRefusal{http.StatusServiceUnavailable, fmt.Sprintf("could not confirm every box in %s. Try again in a minute.", name)}
1155 snap, err := s.liveEdges(ctx)
1156 if err != nil || !snap.complete() {
1157 return unconfirmed
1158 }
1159 var inRegion, below []string
1160 for _, f := range snap.Files {
1161 if s.homeRegionIn(f.Placement) != region {
1162 continue
1163 }
1164 inRegion = append(inRegion, f.Box)
1165 prevOK := f.PrevRelease == "none" || f.PrevSeedsAllowedFor == floor.MinRelease
1166 if f.SeedsProtocol < seeds.RequiredProtocol || f.SeedsFloorMinRelease != floor.MinRelease || !prevOK {
1167 below = append(below, f.Box)
1168 }
1169 }
1170 if len(inRegion) == 0 {
1171 return unconfirmed
1172 }
1173 if oneBox && len(inRegion) > 1 {
1174 return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s has more than one box. An import needs a region with one box for now.", name)}
1175 }
1176 if len(below) > 0 {
1177 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)}
1178 }
1179 boxes, _, err := s.systemBoxes(ctx, false)
1180 if err != nil {
1181 log.Printf("console: seeds readiness: boxes: %v", err)
1182 return unconfirmed
1183 }
1184 byLabel := map[string]status.Box{}
1185 for _, b := range boxes {
1186 byLabel[b.Label] = b
1187 }
1188 now := time.Now()
1189 var flat, claimed []string
1190 for _, label := range inRegion {
1191 b, ok := byLabel[label]
1192 if !ok || status.BoxStale(b, now, status.SnapshotPeriod) || b.Metrics == nil || b.Metrics.Layout == nil {
1193 return unconfirmed
1194 }
1195 if b.Metrics.Layout.Mode != "nested" {
1196 flat = append(flat, label)
1197 }
1198 if b.Metrics.HostClaimEnabled {
1199 claimed = append(claimed, label)
1200 }
1201 }
1202 if len(flat) > 0 {
1203 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)}
1204 }
1205 if len(claimed) > 0 {
1206 return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s in %s run with claim control on. Seeded instances are not supported there yet.", boxList(claimed), name)}
1207 }
1208 return nil
1209}
1210
1211func boxList(boxes []string) string {
1212 sort.Strings(boxes)
1213 return strings.Join(boxes, ", ")
1214}
1215```
1216
1217`"the home region"` matches `dbRegionName`'s existing wording (`apidb.go`), which the deck reuses for `<region>`.
1218
1219- [ ] **Step 4: Run the tests to verify they pass**
1220
1221Run: `go test ./internal/console/ -run 'TestSeedRegionReady' -count=1`
1222Expected: PASS.
1223
1224- [ ] **Step 5: Wire SD-N2 to SD-N8 and commit**
1225
1226Append `"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).
1227
1228```bash
1229git add internal/console/seedfloor.go internal/console/seedfloor_test.go approved_seeded_copy_test.go
1230git commit -m "console: seedRegionReady, the floor, layout and claim check before any seed state (Plan 3 Task 5)
1231
1232Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1233```
1234
1235### Task 6: `carlos system seeds-floor publish`
1236
1237**Files:**
1238- Modify: `internal/console/seedfloor.go` (add `handleAPISystemSeedsFloor`)
1239- Modify: `internal/console/authcli.go:932` (one route, beside the geoip doors)
1240- Create: `system_seeds.go`, `system_seeds_test.go`
1241- Modify: `system.go:52` (`systemVerb`), `:58` (`runSystem`), `:107` (`systemUsage`)
1242- Modify: `docs/site/cli.md` (under `### carlos system`, `:1863`)
1243- Test: `internal/console/seedfloor_test.go`
1244
1245**Interfaces:**
1246- Consumes: `s.apiSystemRoller(w, r) (addr string, owner, ok bool)`, `s.auditSystem(addr, action, detail string)`, `readJSON`, `jsonError`, `writeJSON`, `seeds.PublishFloor`, `seeds.FloorLowerError`; CLI `consoleOption`, `requireDeployAPISession`, `printUsing`, `sess.postJSON`, `apiErrorFromBody`.
1247- Produces: `POST /api/cli/system/seeds-floor` `{"min_release": "vX.Y.Z"}` answering 200 `{"min_release": "vX.Y.Z"}`; `runSystemSeedsFloorVerb(rest []string)`.
1248
1249- [ ] **Step 1: Write the failing tests**
1250
1251Append to `internal/console/seedfloor_test.go`:
1252
1253```go
1254func TestSeedsFloorDoorIsForSystemOperators(t *testing.T) {
1255 fx := newConsoleFixture(t)
1256 if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil {
1257 t.Fatal(err)
1258 }
1259 member := cliToken(t, fx, "member@keymail.dev", "code-seeds-member")
1260 _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member)
1261 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 {
1262 t.Fatalf("a member = %d %q, want the bare 404", st, body)
1263 }
1264 owner := cliToken(t, fx, "paul@keymail.dev", "code-seeds-owner")
1265 if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]any{"min_release": "latest"}, owner); st != http.StatusBadRequest {
1266 t.Fatalf("a bad release = %d %v, want 400", st, out)
1267 }
1268 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" {
1269 t.Fatalf("publish = %d %v", st, out)
1270 }
1271 st, out := postJSON(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]any{"min_release": "v0.26.0"}, owner)
1272 if st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "already v0.27.0") {
1273 t.Fatalf("a lower floor = %d %v, want 409 naming v0.27.0", st, out)
1274 }
1275 f, err := seeds.ReadFloor(context.Background(), fx.rel)
1276 if err != nil || f == nil || f.MinRelease != "v0.27.0" || f.Min != seeds.RequiredProtocol {
1277 t.Fatalf("floor = %+v, %v", f, err)
1278 }
1279}
1280```
1281
1282`system_seeds_test.go`:
1283
1284```go
1285package main
1286
1287import "testing"
1288
1289func TestSystemVerbKnowsSeedsFloor(t *testing.T) {
1290 verb, rest, _ := systemVerb([]string{"seeds-floor", "publish", "--min-release", "v0.27.0"})
1291 if verb != "seeds-floor" || len(rest) != 3 || rest[0] != "publish" {
1292 t.Fatalf("systemVerb = %q %q", verb, rest)
1293 }
1294}
1295```
1296
1297- [ ] **Step 2: Run the tests to verify they fail**
1298
1299Run: `go test ./internal/console/ -run TestSeedsFloorDoorIsForSystemOperators -count=1; go test . -run TestSystemVerbKnowsSeedsFloor -count=1`
1300Expected: FAIL: the route 404s for the owner too; `systemVerb` returns `""`.
1301
1302- [ ] **Step 3: Write the door and register it**
1303
1304Append to `internal/console/seedfloor.go` (add `"errors"` to its imports):
1305
1306```go
1307// handleAPISystemSeedsFloor publishes the seeds floor (spec §3.3, §10 step
1308// 2). The update doors' authority: an ops account owner or an address on
1309// CARLOS_SYSTEM_OPERATORS (apiSystemRoller).
1310func (s *Server) handleAPISystemSeedsFloor(w http.ResponseWriter, r *http.Request) {
1311 addr, _, ok := s.apiSystemRoller(w, r)
1312 if !ok {
1313 return
1314 }
1315 var req struct {
1316 MinRelease string `json:"min_release"`
1317 }
1318 if !readJSON(w, r, &req) {
1319 return
1320 }
1321 if ok, err := seeds.ReleaseAtOrAbove(req.MinRelease, req.MinRelease); !ok || err != nil {
1322 jsonError(w, http.StatusBadRequest, "min_release must be a release such as v0.27.0")
1323 return
1324 }
1325 err := seeds.PublishFloor(r.Context(), s.cfg.Releases, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: req.MinRelease})
1326 var lower *seeds.FloorLowerError
1327 switch {
1328 case errors.As(err, &lower):
1329 jsonError(w, http.StatusConflict, fmt.Sprintf("the seeds floor is already %s. A floor only rises.", lower.Current))
1330 return
1331 case err != nil:
1332 log.Printf("console: publish seeds floor: %v", err)
1333 jsonError(w, http.StatusBadGateway, "could not publish the seeds floor. Try again.")
1334 return
1335 }
1336 s.auditSystem(addr, "seeds-floor-publish", fmt.Sprintf("published the seeds floor at %s", req.MinRelease))
1337 writeJSON(w, http.StatusOK, map[string]string{"min_release": req.MinRelease})
1338}
1339```
1340
1341In `authcli.go`, after the three geoip routes:
1342
1343```go
1344 // The seeds floor (seeded-instances spec §3.3): apiSystemRoller.
1345 s.cliRoute("POST /api/cli/system/seeds-floor", svcrole.Set{}, s.handleAPISystemSeedsFloor)
1346```
1347
1348- [ ] **Step 4: Write the CLI verb**
1349
1350`system_seeds.go`:
1351
1352```go
1353package main
1354
1355import (
1356 "context"
1357 "flag"
1358 "fmt"
1359 "log"
1360 "net/http"
1361 "os"
1362)
1363
1364// runSystemSeedsFloorVerb is `carlos system seeds-floor publish`
1365// (seeded-instances spec §3.3, §10 step 2).
1366func runSystemSeedsFloorVerb(rest []string) {
1367 if len(rest) == 0 || rest[0] != "publish" {
1368 systemUsage()
1369 os.Exit(2)
1370 }
1371 fs := flag.NewFlagSet("system seeds-floor publish", flag.ExitOnError)
1372 consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)")
1373 minRelease := fs.String("min-release", "", "the first release whose boxes can place a seed, such as v0.27.0")
1374 fs.Parse(rest[1:])
1375 if *minRelease == "" || fs.NArg() != 0 {
1376 fs.Usage()
1377 os.Exit(2)
1378 }
1379 sess := requireDeployAPISession(*consoleFlag)
1380 printUsing(stdout, sess)
1381 status, body, err := sess.postJSON(context.Background(), "/api/cli/system/seeds-floor", map[string]string{"min_release": *minRelease})
1382 if err != nil {
1383 log.Fatalf("carlos system seeds-floor publish: %v", err)
1384 }
1385 if status != http.StatusOK {
1386 log.Fatalf("carlos system seeds-floor publish: %v", apiErrorFromBody(status, body))
1387 }
1388 fmt.Fprintln(stdout, fmt.Sprintf("published the seeds floor at %s. Every box arms it within the hour.", *minRelease))
1389}
1390```
1391
1392In `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 CARLOS_SYSTEM_OPERATORS):" add:
1393
1394```
1395 seeds-floor publish --min-release <release>
1396 the first release whose boxes can place a seed, such as v0.27.0
1397```
1398
1399In `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:
1400
1401````markdown
1402```
1403carlos system seeds-floor publish --min-release v0.27.0
1404```
1405````
1406
1407- [ ] **Step 5: Run the tests to verify they pass**
1408
1409Run: `go test ./internal/console/ -run 'TestSeedsFloorDoor|TestSystemDoors' -count=1 && go test . -run 'TestSystemVerb|TestSeeded' -count=1`
1410Expected: PASS.
1411
1412- [ ] **Step 6: Wire SD-N9 to SD-N13, SD-N111, SD-N112 and commit**
1413
1414Append `"SD-N9", "SD-N10", "SD-N11", "SD-N12", "SD-N13", "SD-N111", "SD-N112"` to `seededWiredIDs`; run `go test . -run TestSeededWiredCopyIsApplied -count=1` (PASS).
1415
1416```bash
1417git 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
1418git commit -m "carlos system seeds-floor publish (Plan 3 Task 6)
1419
1420Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1421```
1422
1423### Task 7: Plan gate
1424
1425- [ ] **Step 1: Cross-compile and run the touched packages under -race**
1426
1427Run:
1428```bash
1429GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./... && \
1430nice go test -race ./internal/seeds/ ./internal/host/ ./internal/console/ . -count=1
1431```
1432Expected: 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.
1433
1434---
1435
1436## Self-review
1437
1438- Spec coverage: §3.3 the floor object (Task 1), the box arming and the `carlos.prev` verdict (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).
1439- Dark launch: `ProtocolVersion` 0 against `RequiredProtocol` 1 (Task 1's test pins it until Plan 7).
1440- Review Focus: line 1 Task 2, line 2 Task 3, lines 3 and 4 Task 5, line 5 Task 1.
1441- Deviation: `oneBox` (SD-N113) is not in the spec; it closes a multi-box gap the spec does not address (Task 5's note).
1442