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 8: Sweeps, Move Refusals and Legacy Route Assignments 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:** Close the series: the console's deadline sweep that abandons stale reservations and imports, finishes every rolled-back seed and deletes only snapshots no live record needs; moves refused until a seed is placed or an import committed, in both move doors and in the move worker; and `carlos system legacy-routes assign`, the operator's mapping that lets an import take over a route filed under the operator account.
6
7**Architecture:** `StartSeedsPass` runs every five minutes from `cmd/console/main.go` through the console's worker registry. It reads every record strictly (refusing to act on a partial set, and treating a host whose record body is unreadable as referenced), abandons stale `reserving` and `awaiting-upload`/`uploaded` seeds by CAS to `aborted`, calls Plan 6's `finishSeed` for every `rolled-back` seed, and walks `seeds/` with last-modified times. The move refusals live in `provision` (the transitions, under the record CAS) and in a console helper that both move doors call before their transition, so each door answers the deck's sentence; the worker skips a blocked record before its first effect. The assignment is a console door that writes `control/legacy-route-owners.json` by CAS, refusing to re-assign a name while an import under its current entry is between `ready` and `committed`.
8
9**Tech Stack:** Go 1.25, `release.ModLister`, `provision.ListAllRecords`, `seeds.ReadLegacyOwners`.
10
11**Spec:** `docs/superpowers/specs/2026-10-08-seeded-instances-design.md` rev 7: §3.1 (the sweep's three cases, codex R2-5, R3-2, R4-1), §5.6 (the sweep as the console finish's fallback, codex R6-1), §5.8 (codex R4-2), §6.4 (codex 9), §7 (`legacy-route-assign` audit row, the move errors). Index: `2026-10-08-seeded-instances-0-index.md`. Copy deck rows SD-N100 to SD-N110 and SD-N116.
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`. New sentences go in new files (`internal/console/seedsweep.go`, `seedmove.go`, `apilegacyroutes.go`, `system_legacy_routes.go`) or in `internal/provision/seed.go`; files listed in `movesScanFiles` (`apiinstancemove.go`, `movespass.go`, `movesphase1.go`, `provision.go`, `authcli.go`, `cmd/console/main.go`) gain only calls and routes.
22- `TestClaimAuthoritySourceCensus` inventories console and `cmd/console` calls in its `selected` set; add each new site to `internal/console/testdata/claim-authority-sites.txt`.
23- When a task wires a deck row, append its id to `seededWiredIDs` in `approved_seeded_copy_test.go` in the same commit.
24- 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)`.
25- The sweep deletes a snapshot only on positive knowledge: a complete record set, and, for "no record names it", a last-modified time more than an hour old (spec §3.1).
26
27## Review Focus
28
291. A record whose body cannot be decoded must count as naming whatever snapshots exist under its host: the sweep must never delete a snapshot because a record was unreadable for a pass. Test in Task 2 (`TestSweepKeepsSnapshotsOfAnUnreadableRecord`).
302. A `--from` record in `reserving` for less than an hour, with its snapshot already uploaded, must keep both: the door is between upload and publish. Test in Task 2 (`TestSweepLeavesALiveReservation`).
313. A stale import is abandoned by CAS first, and if the CAS loses (the app committed or uploaded that second), nothing is deleted. Test in Task 2 (`TestSweepAbandonsOnlyByCAS`).
324. A move request on a `--from` instance whose seed is `ready` is refused in both the legacy door and the Phase 1 door with the deck's sentence, and the record's `move` stays absent. Test in Task 3 (`TestMoveDoorsRefuseAnUnplacedSeed`).
335. Re-assigning a legacy name while an import under the old mapping is `swapped` is refused, and the file is unchanged. Test in Task 5 (`TestAssignRefusedWhileAnImportIsInFlight`).
34
35---
36
37## File structure
38
39| path | responsibility |
40|---|---|
41| `internal/provision/reconcile.go` | `ListAllRecordsStrict` beside `ListAllRecords` |
42| `internal/console/seedsweep.go` | `StartSeedsPass`, `seedsPass` |
43| `internal/console/claim_workers.go`, `cmd/console/main.go` | the worker registered and started |
44| `internal/provision/seed.go` | `SeedMoveSentence`; the move transitions' refusal |
45| `internal/provision/provision.go` | `MoveRequest` and `MoveStartTransition` call `seedMoveRefusal` |
46| `internal/console/seedmove.go` | `refuseSeedMove`, called by both move doors |
47| `internal/console/apiinstancemove.go` | two calls |
48| `internal/host/movespass.go` | the worker's skip |
49| `internal/console/apilegacyroutes.go` | `POST /api/cli/system/legacy-routes` |
50| `system_legacy_routes.go`, `system.go` | `carlos system legacy-routes assign` |
51| `docs/site/cli.md` | SD-N110 |
52
53---
54
55### Task 1: A strict record listing
56
57**Files:**
58- Modify: `internal/provision/reconcile.go:1732` (`ListAllRecords`)
59- Test: `internal/provision/reconcile_test.go`
60
61**Interfaces:**
62- Produces: `ListAllRecordsStrict(ctx context.Context, s release.Store) (recs []Record, unreadable map[string]bool, err error)`: `ListAllRecords`' set, plus the hosts whose record key parsed but whose body did not decode or disagreed with its path. `ListAllRecords` becomes a wrapper that drops the second value.
63
64- [ ] **Step 1: Write the failing test**
65
66```go
67func TestListAllRecordsStrictNamesUnreadableHosts(t *testing.T) {
68 s, sqid := setup(t)
69 if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil {
70 t.Fatal(err)
71 }
72 bad := acmeRecord(sqid)
73 bad.Host, bad.Slug = "bad.go.tito.io", "bad"
74 if err := CreateRecord(ctx, s, bad); err != nil {
75 t.Fatal(err)
76 }
77 putReplace(t, s, RecordKey(sqid, "tito", "bad.go.tito.io"), []byte("{not json"))
78 recs, unreadable, err := ListAllRecordsStrict(ctx, s)
79 if err != nil || len(recs) != 1 || recs[0].Host != "acme.go.tito.io" || !unreadable["bad.go.tito.io"] {
80 t.Fatalf("recs %v unreadable %v err %v", recs, unreadable, err)
81 }
82}
83```
84
85- [ ] **Step 2: Run it to verify it fails**
86
87Run: `go test ./internal/provision/ -run TestListAllRecordsStrict -count=1`
88Expected: FAIL to compile.
89
90- [ ] **Step 3: Split the function**
91
92Rename `ListAllRecords`' body to `ListAllRecordsStrict`, add `unreadable := map[string]bool{}` and set `unreadable[host] = true` in each of its two `continue` branches (malformed, disagrees with its path), and return `recs, unreadable, nil`. Then:
93
94```go
95// ListAllRecords is ListAllRecordsStrict without the unreadable hosts.
96func ListAllRecords(ctx context.Context, s release.Store) ([]Record, error) {
97 recs, _, err := ListAllRecordsStrict(ctx, s)
98 return recs, err
99}
100```
101
102- [ ] **Step 4: Run the package**
103
104Run: `go test ./internal/provision/ -count=1`
105Expected: PASS.
106
107- [ ] **Step 5: Commit**
108
109```bash
110git add internal/provision/reconcile.go internal/provision/reconcile_test.go
111git commit -m "provision: ListAllRecordsStrict names the hosts it could not read (Plan 8 Task 1)
112
113Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
114```
115
116### Task 2: The seeds sweep
117
118**Files:**
119- Create: `internal/console/seedsweep.go`, `internal/console/seedsweep_test.go`
120- Modify: `internal/console/claim_workers.go` (`"StartSeedsPass": s.StartSeedsPass`), `cmd/console/main.go` (`go srv.RunClaimWorker(context.Background(), "StartSeedsPass")` beside `StartExpiryPass`)
121
122**Interfaces:**
123- Consumes: `provision.ListAllRecordsStrict`, `provision.SetRecordSeed`, `provision.SeedAbort`, `s.finishSeed(ctx, sqid, app, host, id)` (Plan 6), `release.ModLister`, `seeds.ParseSnapshotKey`, `seeds.Prefix`.
124- Produces: `(s *Server) StartSeedsPass(ctx context.Context)`; `(s *Server) seedsPass(ctx context.Context, now time.Time)`; `seedsPassInterval = 5 * time.Minute`, `seedReserveTTL = time.Hour`, `seedImportTTL = 7 * 24 * time.Hour`, `seedOrphanAge = time.Hour`.
125
126One pass (spec §3.1, §5.6):
127
1281. Records, strictly; an error ends the pass (nothing is deleted on a partial set).
1292. For each seeded record: `reserving` and older than an hour, or an import `awaiting-upload`/`uploaded` older than seven days, is CASed to `aborted` (`bumpSerial` after; the box finishes its part). `rolled-back` is finished (`finishSeed`).
1303. Snapshots under `seeds/` with their last-modified times (`ListMod`; a store without it skips the walk). For each `seeds/<host>/<id>/snapshot.db`:
131 - the host is unreadable this pass: keep;
132 - the host's record names this `id`: keep, unless its seed is `placed` or `committed` (the host's own replica exists, so the box's delete was lost), or the record was just abandoned by this pass (step 2) and its CAS landed;
133 - the host's record names another `id` and is `placed` or `committed`: delete;
134 - otherwise (no record, or a record naming another `id` in any other state): delete when the object is more than an hour old.
135 A delete names the exact key, so it never reaches another seed's snapshot.
136
137The "placed or committed with this `id`" case is not one of spec §3.1's three; it is the same safety argument (placed means the host's own replica exists, §3.4 step 5) applied to a snapshot whose delete the box lost, which otherwise would never be collected.
138
139- [ ] **Step 1: Write the failing tests**
140
141```go
142package console
143
144import (
145 "context"
146 "errors"
147 "os"
148 "strings"
149 "testing"
150 "time"
151
152 "github.com/carlosframework/platform/internal/provision"
153 "github.com/carlosframework/platform/internal/release"
154 "github.com/carlosframework/platform/internal/seeds"
155)
156
157var sweepDataRoot string // the DirStore's root, to age its files
158
159func sweepFixture(t *testing.T) (*consoleFixture, release.Store) {
160 t.Helper()
161 fx := newConsoleFixture(t)
162 sweepDataRoot = t.TempDir()
163 data := release.NewDirStore(sweepDataRoot)
164 fx.srv.cfg.SeedData = data
165 return fx, data
166}
167
168func seededRec(t *testing.T, fx *consoleFixture, host, kind, state string, at time.Time) string {
169 t.Helper()
170 id, _ := seeds.NewID()
171 rec := provision.Record{Schema: 1, Host: host, Slug: strings.Split(host, ".")[0], App: "titogo", Account: "bab",
172 Channel: "stable", CreatedAt: at, Seed: &provision.SeedSource{ID: id, Kind: kind, State: state, By: "t", At: at}}
173 if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil {
174 t.Fatal(err)
175 }
176 return id
177}
178
179func putSnapshotAged(t *testing.T, data release.Store, host, id string, age time.Duration) string {
180 t.Helper()
181 key := seeds.SnapshotKey(host, id)
182 if _, err := data.Put(context.Background(), key, []byte("db"), ""); err != nil {
183 t.Fatal(err)
184 }
185 // DirStore keeps a key at root/key and its ListMod reports the file's
186 // mtime (store.go:338, :509): age the file.
187 path := filepath.Join(sweepDataRoot, filepath.FromSlash(key))
188 when := time.Now().Add(-age)
189 if err := os.Chtimes(path, when, when); err != nil {
190 t.Fatal(err)
191 }
192 return key
193}
194
195func exists(t *testing.T, data release.Store, key string) bool {
196 t.Helper()
197 _, _, err := data.Get(context.Background(), key)
198 return !errors.Is(err, os.ErrNotExist)
199}
200
201func TestSweepLeavesALiveReservation(t *testing.T) {
202 fx, data := sweepFixture(t)
203 now := time.Now()
204 id := seededRec(t, fx, "canary.go.tito.io", seeds.KindReplica, seeds.StateReserving, now.Add(-10*time.Minute))
205 key := putSnapshotAged(t, data, "canary.go.tito.io", id, 10*time.Minute)
206 fx.srv.seedsPass(context.Background(), now)
207 rec, _ := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io")
208 if rec.Seed.State != seeds.StateReserving || !exists(t, data, key) {
209 t.Fatalf("a live reservation was touched: %s, snapshot kept %v", rec.Seed.State, exists(t, data, key))
210 }
211 // An hour later it is abandoned by CAS.
212 fx.srv.seedsPass(context.Background(), now.Add(2*time.Hour))
213 rec, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io")
214 if rec.Seed.State != seeds.StateAborted {
215 t.Fatalf("a stale reservation = %s, want aborted", rec.Seed.State)
216 }
217}
218
219func TestSweepDeletesOnlyUnreferencedOrSupersededSnapshots(t *testing.T) {
220 fx, data := sweepFixture(t)
221 now := time.Now()
222 live := seededRec(t, fx, "a.go.tito.io", seeds.KindReplica, seeds.StateReady, now)
223 liveKey := putSnapshotAged(t, data, "a.go.tito.io", live, 3*time.Hour)
224 stale, _ := seeds.NewID()
225 staleKey := putSnapshotAged(t, data, "a.go.tito.io", stale, 3*time.Hour) // another id, record not placed: an orphan
226 fresh, _ := seeds.NewID()
227 freshKey := putSnapshotAged(t, data, "nobody.go.tito.io", fresh, 10*time.Minute) // no record yet, too young
228 seededRec(t, fx, "b.go.tito.io", seeds.KindReplica, seeds.StatePlaced, now)
229 old, _ := seeds.NewID()
230 supersededKey := putSnapshotAged(t, data, "b.go.tito.io", old, 5*time.Minute) // another id, record placed
231 fx.srv.seedsPass(context.Background(), now)
232 if !exists(t, data, liveKey) {
233 t.Error("a snapshot a live record needs was deleted")
234 }
235 if exists(t, data, staleKey) {
236 t.Error("an orphan over an hour old survived")
237 }
238 if !exists(t, data, freshKey) {
239 t.Error("a young unreferenced snapshot was deleted (its door may be between record and upload)")
240 }
241 if exists(t, data, supersededKey) {
242 t.Error("a snapshot superseded by a placed seed survived")
243 }
244}
245
246func TestSweepKeepsSnapshotsOfAnUnreadableRecord(t *testing.T) {
247 fx, data := sweepFixture(t)
248 id := seededRec(t, fx, "a.go.tito.io", seeds.KindReplica, seeds.StateReady, time.Now())
249 key := putSnapshotAged(t, data, "a.go.tito.io", id, 3*time.Hour)
250 _, etag, _ := fx.rel.Get(context.Background(), provision.RecordKey("bab", "titogo", "a.go.tito.io"))
251 fx.rel.Put(context.Background(), provision.RecordKey("bab", "titogo", "a.go.tito.io"), []byte("{not json"), etag)
252 fx.srv.seedsPass(context.Background(), time.Now())
253 if !exists(t, data, key) {
254 t.Fatal("an unreadable record's snapshot was deleted")
255 }
256}
257
258func TestSweepAbandonsOnlyByCAS(t *testing.T) {
259 fx, data := sweepFixture(t)
260 old := time.Now().Add(-8 * 24 * time.Hour)
261 id := seededRec(t, fx, "imp.go.tito.io", seeds.KindImport, seeds.StateUploaded, old)
262 key := putSnapshotAged(t, data, "imp.go.tito.io", id, 8*24*time.Hour)
263 // The app marks it stopped in the same instant: the CAS the sweep makes
264 // must lose and nothing be deleted.
265 seedsPassBeforeAbort = func() {
266 provision.SetRecordSeed(context.Background(), fx.rel, "bab", "titogo", "imp.go.tito.io", provision.SeedStopped(id))
267 }
268 defer func() { seedsPassBeforeAbort = nil }()
269 fx.srv.seedsPass(context.Background(), time.Now())
270 rec, _ := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "imp.go.tito.io")
271 if rec.Seed.State != seeds.StateReady || !exists(t, data, key) {
272 t.Fatalf("state %s, snapshot kept %v; want the app's stop to win and nothing deleted", rec.Seed.State, exists(t, data, key))
273 }
274}
275
276func TestSweepFinishesARolledBackSeed(t *testing.T) {
277 fx, _ := sweepFixture(t)
278 seededRec(t, fx, "gone.go.tito.io", seeds.KindImport, seeds.StateRolledBack, time.Now())
279 fx.srv.seedsPass(context.Background(), time.Now())
280 if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gone.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
281 t.Fatalf("a rolled-back record after the sweep: %v", err)
282 }
283}
284```
285
286`seedsPassBeforeAbort` is a test seam in `seedsweep.go`: a `func()` called just before each abandonment CAS, nil in production. Because `SetRecordSeed` retries once on a lost etag and re-applies `SeedAbort`, which accepts `ready` for an import, the test as written would see the abort land on the retry; make the sweep's abandonment use a guarded mutation that re-checks staleness on the fresh record (`rec.Seed.State` still `awaiting-upload`/`uploaded`/`reserving` and still older than its limit), so the retry refuses once the app has moved it on.
287
288- [ ] **Step 2: Run the tests to verify they fail**
289
290Run: `go test ./internal/console/ -run 'TestSweep' -count=1`
291Expected: FAIL to compile.
292
293- [ ] **Step 3: Write `seedsweep.go`**
294
295```go
296package console
297
298import (
299 "context"
300 "errors"
301 "log"
302 "time"
303
304 "github.com/carlosframework/platform/internal/provision"
305 "github.com/carlosframework/platform/internal/release"
306 "github.com/carlosframework/platform/internal/seeds"
307)
308
309const (
310 seedsPassInterval = 5 * time.Minute
311 seedReserveTTL = time.Hour
312 seedImportTTL = 7 * 24 * time.Hour
313 seedOrphanAge = time.Hour
314)
315
316// seedsPassBeforeAbort is the tests' pause before an abandonment CAS.
317var seedsPassBeforeAbort func()
318
319var errNotStale = errors.New("console: the seed moved on")
320
321// StartSeedsPass runs the seeds sweep now and every five minutes
322// (seeded-instances spec §3.1, §5.6), StartExpiryPass's shape.
323func (s *Server) StartSeedsPass(ctx context.Context) {
324 if s.cfg.Releases == nil || s.cfg.SeedData == nil {
325 return
326 }
327 t := time.NewTicker(seedsPassInterval)
328 defer t.Stop()
329 for {
330 s.seedsPass(ctx, time.Now())
331 select {
332 case <-ctx.Done():
333 return
334 case <-t.C:
335 }
336 }
337}
338
339func stale(seed *provision.SeedSource, now time.Time) bool {
340 switch {
341 case seed.State == seeds.StateReserving:
342 return now.Sub(seed.At) > seedReserveTTL
343 case seed.Import() && (seed.State == seeds.StateAwaitingUpload || seed.State == seeds.StateUploaded):
344 return now.Sub(seed.At) > seedImportTTL
345 }
346 return false
347}
348
349func (s *Server) seedsPass(ctx context.Context, now time.Time) {
350 recs, unreadable, err := provision.ListAllRecordsStrict(ctx, s.cfg.Releases)
351 if err != nil {
352 log.Printf("console: seeds pass: %v (nothing swept this pass)", err)
353 return
354 }
355 byHost := map[string]provision.Record{}
356 abandoned := map[string]string{} // host -> seed id aborted by this pass
357 for _, rec := range recs {
358 byHost[rec.Host] = rec
359 seed := rec.Seed
360 if seed == nil {
361 continue
362 }
363 switch {
364 case stale(seed, now):
365 if seedsPassBeforeAbort != nil {
366 seedsPassBeforeAbort()
367 }
368 _, err := provision.SetRecordSeed(ctx, s.cfg.Releases, rec.Account, rec.App, rec.Host, func(r *provision.Record) error {
369 if r.Seed == nil || r.Seed.ID != seed.ID || !stale(r.Seed, now) {
370 return errNotStale
371 }
372 return provision.SeedAbort(seed.ID)(r)
373 })
374 if err != nil {
375 if !errors.Is(err, errNotStale) && !errors.Is(err, provision.ErrSeedConflict) {
376 log.Printf("console: seeds pass: abandon %s: %v", rec.Host, err)
377 }
378 continue
379 }
380 abandoned[rec.Host] = seed.ID
381 s.bumpSerial(ctx)
382 log.Printf("console: seeds pass: abandoned seed %s of %s (%s since %s)", seed.ID, rec.Host, seed.State, seed.At.Format(time.RFC3339))
383 case seed.State == seeds.StateRolledBack:
384 if err := s.finishSeed(ctx, rec.Account, rec.App, rec.Host, seed.ID); err != nil {
385 log.Printf("console: seeds pass: finish %s: %v", rec.Host, err)
386 }
387 }
388 }
389 ml, ok := s.cfg.SeedData.(release.ModLister)
390 if !ok {
391 return
392 }
393 objs, err := ml.ListMod(ctx, seeds.Prefix)
394 if err != nil {
395 log.Printf("console: seeds pass: list snapshots: %v", err)
396 return
397 }
398 for _, o := range objs {
399 host, id, ok := seeds.ParseSnapshotKey(o.Key)
400 if !ok || unreadable[host] {
401 continue
402 }
403 rec, has := byHost[host]
404 del := false
405 switch {
406 case has && rec.Seed != nil && rec.Seed.ID == id:
407 placed := rec.Seed.State == seeds.StatePlaced || rec.Seed.State == seeds.StateCommitted
408 del = placed || abandoned[host] == id
409 case has && rec.Seed != nil && (rec.Seed.State == seeds.StatePlaced || rec.Seed.State == seeds.StateCommitted):
410 del = true
411 default:
412 del = now.Sub(o.Mod) > seedOrphanAge
413 }
414 if !del {
415 continue
416 }
417 if err := s.cfg.SeedData.Delete(ctx, o.Key); err != nil {
418 log.Printf("console: seeds pass: delete %s: %v", o.Key, err)
419 continue
420 }
421 log.Printf("console: seeds pass: deleted %s", o.Key)
422 }
423}
424```
425
426Register the worker in `claim_workers.go`'s map (`"StartSeedsPass": s.StartSeedsPass,`) and start it in `cmd/console/main.go` beside `StartExpiryPass`:
427
428```go
429 // The seeds sweep (seeded-instances spec §3.1, §5.6): stale
430 // reservations and imports abandoned, rolled-back seeds finished,
431 // unreferenced snapshots deleted. Same placement rule as every pass
432 // above.
433 go srv.RunClaimWorker(context.Background(), "StartSeedsPass")
434```
435
436If a test pins the worker names (`grep -rn '"StartExpiryPass"' internal/console/*_test.go`), add `StartSeedsPass` there.
437
438- [ ] **Step 4: Run the tests to verify they pass**
439
440Run: `go test ./internal/console/ -run 'TestSweep|TestClaimWorker|TestClaimAuthoritySourceCensus' -count=1`
441Expected: PASS.
442
443- [ ] **Step 5: Commit**
444
445```bash
446git add internal/console/seedsweep.go internal/console/seedsweep_test.go internal/console/claim_workers.go cmd/console/main.go internal/console/testdata/claim-authority-sites.txt
447git commit -m "console: the seeds sweep (Plan 8 Task 2)
448
449Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
450```
451
452### Task 3: Moves wait for placement, in both doors
453
454**Files:**
455- Modify: `internal/provision/seed.go` (`SeedMoveSentence`, `seedMoveRefusal`), `internal/provision/provision.go:1146` (`MoveRequest`), `:1177` (`MoveStartTransition`)
456- Create: `internal/console/seedmove.go`
457- Modify: `internal/console/apiinstancemove.go` (`legacyHandleAPIInstanceMoveRequest` `:260`, after `moveGate`; `handleAPIInstanceMoveRequest` `:396`, after `moveOperatorGate`)
458- Test: `internal/provision/seed_test.go`, `internal/console/seedmove_test.go`
459
460**Interfaces:**
461- Produces: `provision.SeedMoveSentence(block string) string` (SD-N100 for `SeedMoveUnplaced`, SD-N101 for `SeedMoveUncommitted`, `""` otherwise); `seedMoveRefusal(rec *Record) error` (an `ErrMoveConflict` carrying the sentence, or nil); `(s *Server) refuseSeedMove(w http.ResponseWriter, rec *provision.Record) bool`.
462
463The refusal sits in two places (spec §5.8): inside the two move transitions, under the record CAS; and in a console helper both doors call after their gate and before their transition, so each door answers the deck's sentence (the Phase 1 door's `phase1Conflict` maps unknown provision refusals to one fixed sentence, and the legacy door prints the wrapped error). The record has already been read by both gates.
464
465- [ ] **Step 1: Write the failing tests**
466
467In `internal/provision/seed_test.go`:
468
469```go
470func TestMoveTransitionsRefuseAnUnplacedSeed(t *testing.T) {
471 ctx := context.Background()
472 for _, c := range []struct {
473 kind, state, want string
474 }{
475 {seeds.KindReplica, seeds.StateReady, "wake the instance once before moving it"},
476 {seeds.KindImport, seeds.StatePlaced, "commit or abort the import first"},
477 } {
478 s := release.NewDirStore(t.TempDir())
479 rec := seedTestRecord(t, s, testSeed(t, c.kind, c.state))
480 _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, MoveRequest("uk", time.Now()))
481 if !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), c.want) {
482 t.Fatalf("%s %s: err = %v", c.kind, c.state, err)
483 }
484 _, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, MoveStartTransition(MoveStart{ID: "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", Incarnation: "i", From: "", To: "uk", Cut: "auto", By: "op", Now: time.Now()}))
485 if !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), c.want) {
486 t.Fatalf("%s %s start: err = %v", c.kind, c.state, err)
487 }
488 }
489 s := release.NewDirStore(t.TempDir())
490 rec := seedTestRecord(t, s, testSeed(t, seeds.KindReplica, seeds.StatePlaced))
491 if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, MoveRequest("uk", time.Now())); err != nil {
492 t.Fatalf("a placed seed: %v", err)
493 }
494}
495```
496
497`internal/console/seedmove_test.go` (the legacy door is the one a default console runs; the Phase 1 door needs `MovesEnabled` and an operator, which `apiinstancemove_test.go` sets up with a helper: read it and use the same helper for the second half):
498
499```go
500package console
501
502import (
503 "context"
504 "net/http"
505 "strings"
506 "testing"
507 "time"
508
509 "github.com/carlosframework/platform/internal/provision"
510 "github.com/carlosframework/platform/internal/seeds"
511)
512
513func TestMoveDoorsRefuseAnUnplacedSeed(t *testing.T) {
514 fx, token := instanceFixture(t, "code-seed-move")
515 m := titoManifest()
516 m.Pool = ""
517 enableInstances(t, fx, "titogo", m)
518 id, _ := seeds.NewID()
519 rec := provision.Record{Schema: 1, Host: "canary.go.tito.io", Slug: "canary", App: "titogo", Account: "bab",
520 Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC(),
521 Seed: &provision.SeedSource{ID: id, Kind: seeds.KindReplica, State: seeds.StateReady, By: "t", At: time.Now().UTC()}}
522 if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil {
523 t.Fatal(err)
524 }
525 code, _, raw := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo")+"/canary.go.tito.io/move", token, `{"region":"uk"}`)
526 if code != http.StatusConflict || !strings.Contains(raw, "wake the instance once before moving it") {
527 t.Fatalf("legacy door = %d %s", code, raw)
528 }
529 got, _ := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io")
530 if got.Move != nil {
531 t.Fatalf("a refused move left a journal: %+v", got.Move)
532 }
533}
534```
535
536Add the Phase 1 half with `apiinstancemove_test.go`'s operator fixture (it sets `fx.srv.cfg.MovesEnabled = true` and an operator token); the assertion is the same 409 and sentence.
537
538- [ ] **Step 2: Run the tests to verify they fail**
539
540Run: `go test ./internal/provision/ -run TestMoveTransitionsRefuseAnUnplacedSeed -count=1; go test ./internal/console/ -run TestMoveDoorsRefuseAnUnplacedSeed -count=1`
541Expected: FAIL: the moves are accepted.
542
543- [ ] **Step 3: Write the refusal**
544
545Append to `internal/provision/seed.go`:
546
547```go
548// SeedMoveSentence is a move refusal's sentence (spec §5.8).
549func SeedMoveSentence(block string) string {
550 switch block {
551 case SeedMoveUnplaced:
552 return "wake the instance once before moving it"
553 case SeedMoveUncommitted:
554 return "commit or abort the import first"
555 }
556 return ""
557}
558
559// seedMoveRefusal is the move transitions' seed rule, under the record CAS.
560func seedMoveRefusal(rec *Record) error {
561 if b := rec.Seed.MoveBlock(); b != "" {
562 return fmt.Errorf("%w: %s", ErrMoveConflict, SeedMoveSentence(b))
563 }
564 return nil
565}
566```
567
568In `MoveRequest`'s and `MoveStartTransition`'s closures, first statement: `if err := seedMoveRefusal(rec); err != nil { return err }`.
569
570`internal/console/seedmove.go`:
571
572```go
573package console
574
575import (
576 "net/http"
577
578 "github.com/carlosframework/platform/internal/provision"
579)
580
581// refuseSeedMove answers a move of a seed that is not placed (--from) or
582// not committed (import) with the deck's sentence (spec §5.8). The
583// transitions refuse it again under the record CAS.
584func (s *Server) refuseSeedMove(w http.ResponseWriter, rec *provision.Record) bool {
585 if b := rec.Seed.MoveBlock(); b != "" {
586 jsonError(w, http.StatusConflict, provision.SeedMoveSentence(b))
587 return true
588 }
589 return false
590}
591```
592
593In `apiinstancemove.go`: after `moveGate` in `legacyHandleAPIInstanceMoveRequest`, `if s.refuseSeedMove(w, rec) { return }`; after `moveOperatorGate` in `handleAPIInstanceMoveRequest`, the same. Both gates return the record they read as a `*provision.Record` (`moveGate` `:44`, `moveOperatorGate` `:357`).
594
595- [ ] **Step 4: Run the tests to verify they pass**
596
597Run: `go test ./internal/provision/ -run 'TestMove|TestSeed' -count=1 && go test ./internal/console/ -run 'TestMove|TestAPIInstanceMove' -count=1 && go test . -run TestMoves -count=1`
598Expected: PASS, the moves copy gate included (the doors gained one call each).
599
600- [ ] **Step 5: Wire SD-N100, SD-N101 and commit**
601
602```bash
603git add internal/provision/seed.go internal/provision/seed_test.go internal/provision/provision.go internal/console/seedmove.go internal/console/seedmove_test.go internal/console/apiinstancemove.go approved_seeded_copy_test.go
604git commit -m "moves wait for a seed's placement or an import's commit, in both doors (Plan 8 Task 3)
605
606Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
607```
608
609### Task 4: The move worker refuses before any effect
610
611**Files:**
612- Modify: `internal/host/movespass.go:596` (`movesPass`'s record loop)
613- Test: `internal/host/movespass_test.go`
614
615**Interfaces:**
616- Consumes: `(*provision.SeedSource).MoveBlock`, `provision.SeedMoveSentence`, `w.notice(key, format string, args ...any)`.
617
618A record that slipped past an old console (a move journaled while its seed is unplaced or its import uncommitted) is skipped while its move is `preparing`, before the first mirror (legacy) or staging write (Phase 1), with one rate-limited log line. A move already past `preparing` has had its effects and is not this rule's to stop.
619
620- [ ] **Step 1: Write the failing test**
621
622```go
623func TestMovesPassSkipsAnUnplacedSeed(t *testing.T) {
624 h := newMoveHarness(t)
625 host := "a.go.example.com"
626 id, _ := seeds.NewID()
627 rec := provision.Record{Schema: 1, Host: host, Slug: "s", App: "app", Account: "bab", Region: "ie", Channel: "stable",
628 CreatedAt: time.Now().UTC(),
629 Move: &provision.MoveState{To: "uk", State: provision.MovePreparing, RequestedAt: time.Now().UTC()},
630 Seed: &provision.SeedSource{ID: id, Kind: seeds.KindReplica, State: seeds.StateReady, By: "t", At: time.Now().UTC()}}
631 if err := provision.CreateRecord(context.Background(), h.s, rec); err != nil {
632 t.Fatal(err)
633 }
634 h.f.put("data-ie", "instances/"+host+"/generations/g/wal/000", "e1")
635 if err := movesPass(context.Background(), h.s, h.worker()); err != nil {
636 t.Fatal(err)
637 }
638 if staged, _ := h.f.List(context.Background(), "data-uk", "moves/"+host+"/"); len(staged) != 0 {
639 t.Fatalf("the worker mirrored an unplaced seed: %v", staged)
640 }
641 if got := h.read(t, host); got.Move.State != provision.MovePreparing {
642 t.Fatalf("state = %s", got.Move.State)
643 }
644}
645```
646
647(add the `seeds` import to `movespass_test.go`).
648
649- [ ] **Step 2: Run it to verify it fails**
650
651Run: `go test ./internal/host/ -run TestMovesPassSkipsAnUnplacedSeed -count=1`
652Expected: FAIL: the round mirrors.
653
654- [ ] **Step 3: Add the skip**
655
656In `movesPass`'s `for _, rec := range recs {` loop, first statement:
657
658```go
659 // A seed moves only once placed, an import only once committed
660 // (seeded-instances spec §5.8): a record that slipped past an old
661 // console is refused here, before the first copy.
662 if rec.Move != nil && rec.Move.State == provision.MovePreparing {
663 if b := rec.Seed.MoveBlock(); b != "" {
664 w.notice(rec.Host+"#seed", "moves: %s: %s", rec.Host, provision.SeedMoveSentence(b))
665 continue
666 }
667 }
668```
669
670- [ ] **Step 4: Run the tests to verify they pass**
671
672Run: `go test ./internal/host/ -run 'TestMovesPass' -count=1 && go test . -run TestMoves -count=1`
673Expected: PASS.
674
675- [ ] **Step 5: Commit**
676
677```bash
678git add internal/host/movespass.go internal/host/movespass_test.go
679git commit -m "moves worker: an unplaced seed waits before any copy (Plan 8 Task 4)
680
681Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
682```
683
684### Task 5: `carlos system legacy-routes assign`
685
686**Files:**
687- Create: `internal/console/apilegacyroutes.go`, `internal/console/apilegacyroutes_test.go`, `system_legacy_routes.go`, `system_legacy_routes_test.go`
688- Modify: `internal/console/authcli.go` (route beside the seeds-floor door), `system.go` (`systemVerb`, `runSystem`, `systemUsage`), `docs/site/cli.md`
689
690**Interfaces:**
691- Consumes: `s.apiSystemRoller`, `s.auditSystem`, `release.ValidApp`, `s.store.AccountBySqid(sqid) (*Account, error)`, `s.store.HasAppIn(accountID int64, app string) (bool, error)`, `provision.ListRecords`, `seeds.ReadLegacyOwners`, `seeds.LegacyOwnersKey`.
692- Produces: `POST /api/cli/system/legacy-routes` `{"name": "titogo", "to": "bae/titogo"}` answering 200 `{"name":…, "account":…, "app":…}`; `runSystemLegacyRoutesVerb(rest []string)`.
693
694The door (spec §6.4): operator-only (`apiSystemRoller`, `CARLOS_SYSTEM_OPERATORS` or an ops owner); `name` must be an app name (SD-N104); `to` must be `<account>/<app>` naming an existing app (SD-N105); the file is read with its etag; an existing entry for `name` that maps elsewhere is replaced only when no record of the old mapping's account and app holds an import between `ready` and `committed` (`ready`, `swapped`, `placed`: SD-N103); the same mapping again is a 200 no-op; the file is written by CAS (a lost race or a failed write answers SD-N116); audit SD-N106.
695
696- [ ] **Step 1: Write the failing tests**
697
698```go
699package console
700
701import (
702 "context"
703 "net/http"
704 "strings"
705 "testing"
706 "time"
707
708 "github.com/carlosframework/platform/internal/provision"
709 "github.com/carlosframework/platform/internal/seeds"
710)
711
712func TestAssignLegacyRoutes(t *testing.T) {
713 fx, _ := instanceFixture(t, "code-assign") // app titogo in bab
714 owner := cliToken(t, fx, "paul@keymail.dev", "code-assign-owner")
715 u := fx.ts.URL + "/api/cli/system/legacy-routes"
716 if st, out := postJSON(t, u, map[string]any{"name": "Not An App!", "to": "bab/titogo"}, owner); st != http.StatusBadRequest {
717 t.Fatalf("bad name = %d %v", st, out)
718 }
719 if st, out := postJSON(t, u, map[string]any{"name": "titogo", "to": "bab"}, owner); st != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), "to must be") {
720 t.Fatalf("bad to = %d %v", st, out)
721 }
722 if st, out := postJSON(t, u, map[string]any{"name": "titogo", "to": "bab/titogo"}, owner); st != http.StatusOK || out["account"] != "bab" {
723 t.Fatalf("assign = %d %v", st, out)
724 }
725 o, _, err := seeds.ReadLegacyOwners(context.Background(), fx.rel)
726 if got, ok := o.Lookup("titogo"); err != nil || !ok || got != (seeds.LegacyOwner{Account: "bab", App: "titogo"}) {
727 t.Fatalf("file = %+v %v", o, err)
728 }
729}
730
731func TestAssignRefusedWhileAnImportIsInFlight(t *testing.T) {
732 fx, _ := instanceFixture(t, "code-assign-busy")
733 owner := cliToken(t, fx, "paul@keymail.dev", "code-assign-busy-owner")
734 u := fx.ts.URL + "/api/cli/system/legacy-routes"
735 postJSON(t, u, map[string]any{"name": "titogo", "to": "bab/titogo"}, owner)
736 id, _ := seeds.NewID()
737 rec := provision.Record{Schema: 1, Host: "old.go.tito.io", Slug: "old", App: "titogo", Account: "bab", Channel: "stable",
738 CreatedAt: time.Now().UTC(), Seed: &provision.SeedSource{ID: id, Kind: seeds.KindImport, State: seeds.StateSwapped, By: "t", At: time.Now().UTC()}}
739 if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil {
740 t.Fatal(err)
741 }
742 createApp(t, fx, "bab", "other") // the fixture helper that adds an app to an account
743 st, out := postJSON(t, u, map[string]any{"name": "titogo", "to": "bab/other"}, owner)
744 if st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "has an import in flight") {
745 t.Fatalf("re-assign = %d %v", st, out)
746 }
747 o, _, _ := seeds.ReadLegacyOwners(context.Background(), fx.rel)
748 if got, _ := o.Lookup("titogo"); got.App != "titogo" {
749 t.Fatalf("the file changed: %+v", got)
750 }
751}
752```
753
754`createApp(t, fx, sqid, app)`: use the store call `instanceFixture` uses to create `titogo` (read `apiinstances_test.go:102`) for a second app. A member who is not an operator gets the bare 404 (the `apiSystemRoller` rule); add that assertion as `TestSeedsFloorDoorIsForSystemOperators` does.
755
756`system_legacy_routes_test.go`:
757
758```go
759package main
760
761import "testing"
762
763func TestSystemVerbKnowsLegacyRoutes(t *testing.T) {
764 verb, rest, _ := systemVerb([]string{"legacy-routes", "assign", "--name", "titogo", "--to", "bae/titogo"})
765 if verb != "legacy-routes" || len(rest) != 5 || rest[0] != "assign" {
766 t.Fatalf("systemVerb = %q %q", verb, rest)
767 }
768}
769```
770
771- [ ] **Step 2: Run the tests to verify they fail**
772
773Run: `go test ./internal/console/ -run 'TestAssign' -count=1; go test . -run TestSystemVerbKnowsLegacyRoutes -count=1`
774Expected: FAIL.
775
776- [ ] **Step 3: Write the door, the route, the verb and the docs**
777
778`internal/console/apilegacyroutes.go`:
779
780```go
781package console
782
783import (
784 "encoding/json"
785 "errors"
786 "fmt"
787 "log"
788 "net/http"
789 "strings"
790
791 "github.com/carlosframework/platform/internal/provision"
792 "github.com/carlosframework/platform/internal/release"
793 "github.com/carlosframework/platform/internal/seeds"
794)
795
796// handleAPISystemLegacyRoutes is `carlos system legacy-routes assign`
797// (seeded-instances spec §6.4, codex 9): an operator maps an app name, as
798// filed under the operator account, to one account and app.
799func (s *Server) handleAPISystemLegacyRoutes(w http.ResponseWriter, r *http.Request) {
800 addr, _, ok := s.apiSystemRoller(w, r)
801 if !ok {
802 return
803 }
804 var req struct {
805 Name string `json:"name"`
806 To string `json:"to"`
807 }
808 if !readJSON(w, r, &req) {
809 return
810 }
811 name := strings.TrimSpace(req.Name)
812 if !release.ValidApp(name) {
813 jsonError(w, http.StatusBadRequest, "name must be an app name")
814 return
815 }
816 acctSqid, app, found := strings.Cut(strings.TrimSpace(req.To), "/")
817 if !found || !release.ValidApp(app) {
818 jsonError(w, http.StatusBadRequest, "to must be <account>/<app>")
819 return
820 }
821 acct, err := s.store.AccountBySqid(acctSqid)
822 if err != nil || acct == nil {
823 jsonError(w, http.StatusBadRequest, "to must be <account>/<app>")
824 return
825 }
826 if has, err := s.store.HasAppIn(acct.ID, app); err != nil || !has {
827 jsonError(w, http.StatusBadRequest, "to must be <account>/<app>")
828 return
829 }
830 ctx := r.Context()
831 owners, etag, err := seeds.ReadLegacyOwners(ctx, s.cfg.Releases)
832 if err != nil {
833 log.Printf("console: legacy routes: %v", err)
834 jsonError(w, http.StatusBadGateway, "could not read the legacy route assignments. Try again.")
835 return
836 }
837 want := seeds.LegacyOwner{Account: acct.Sqid, App: app}
838 if cur, ok := owners.Lookup(name); ok {
839 if cur == want {
840 writeJSON(w, http.StatusOK, map[string]string{"name": name, "account": want.Account, "app": want.App})
841 return
842 }
843 busy, err := importInFlight(ctx, s.cfg.Releases, cur)
844 if err != nil {
845 log.Printf("console: legacy routes: in-flight check: %v", err)
846 jsonError(w, http.StatusBadGateway, "could not read the legacy route assignments. Try again.")
847 return
848 }
849 if busy {
850 jsonError(w, http.StatusConflict, fmt.Sprintf("%s has an import in flight under its current assignment. Commit or abort it first.", name))
851 return
852 }
853 }
854 owners.Names[name] = want
855 body, _ := json.Marshal(owners)
856 if _, err := s.cfg.Releases.Put(ctx, seeds.LegacyOwnersKey, body, etag); err != nil {
857 if !errors.Is(err, release.ErrCASConflict) {
858 log.Printf("console: legacy routes: write: %v", err)
859 }
860 jsonError(w, http.StatusBadGateway, "could not save the legacy route assignment. Try again.")
861 return
862 }
863 s.auditSystem(addr, "legacy-route-assign", fmt.Sprintf("assigned the legacy routes named %s to %s/%s", name, want.Account, want.App))
864 s.bumpSerial(ctx)
865 writeJSON(w, http.StatusOK, map[string]string{"name": name, "account": want.Account, "app": want.App})
866}
867
868// importInFlight reports an import of o's account and app between ready
869// and committed (spec §6.4).
870func importInFlight(ctx context.Context, rs release.Store, o seeds.LegacyOwner) (bool, error) {
871 recs, truncated, err := provision.ListRecords(ctx, rs, o.Account, o.App, 10000)
872 if err != nil {
873 return false, err
874 }
875 if truncated {
876 return true, nil // a listing that did not see every record never says "idle"
877 }
878 for _, rec := range recs {
879 if rec.Seed.Import() {
880 switch rec.Seed.State {
881 case seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced:
882 return true, nil
883 }
884 }
885 }
886 return false, nil
887}
888```
889
890(add `"context"` to the imports).
891
892In `authcli.go`, beside the seeds-floor route: `s.cliRoute("POST /api/cli/system/legacy-routes", svcrole.Set{}, s.handleAPISystemLegacyRoutes)`.
893
894`system_legacy_routes.go`:
895
896```go
897package main
898
899import (
900 "context"
901 "flag"
902 "fmt"
903 "log"
904 "net/http"
905 "os"
906)
907
908// runSystemLegacyRoutesVerb is `carlos system legacy-routes assign`
909// (seeded-instances spec §6.4).
910func runSystemLegacyRoutesVerb(rest []string) {
911 if len(rest) == 0 || rest[0] != "assign" {
912 systemUsage()
913 os.Exit(2)
914 }
915 fs := flag.NewFlagSet("system legacy-routes assign", flag.ExitOnError)
916 consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)")
917 name := fs.String("name", "", "the app name the legacy routes are filed under, in the operator account")
918 to := fs.String("to", "", "the account and app they belong to, as <account>/<app>")
919 fs.Parse(rest[1:])
920 if *name == "" || *to == "" || fs.NArg() != 0 {
921 fs.Usage()
922 os.Exit(2)
923 }
924 sess := requireDeployAPISession(*consoleFlag)
925 printUsing(stdout, sess)
926 status, body, err := sess.postJSON(context.Background(), "/api/cli/system/legacy-routes", map[string]string{"name": *name, "to": *to})
927 if err != nil {
928 log.Fatalf("carlos system legacy-routes assign: %v", err)
929 }
930 if status != http.StatusOK {
931 log.Fatalf("carlos system legacy-routes assign: %v", apiErrorFromBody(status, body))
932 }
933 var out struct {
934 Name string `json:"name"`
935 Account string `json:"account"`
936 App string `json:"app"`
937 }
938 if err := json.Unmarshal(body, &out); err != nil {
939 log.Fatalf("carlos system legacy-routes assign: unreadable response: %v", err)
940 }
941 fmt.Fprintln(stdout, fmt.Sprintf("assigned the legacy routes named %s to %s/%s", out.Name, out.Account, out.App))
942}
943```
944
945(add `"encoding/json"` to the imports).
946
947In `system.go`: `"legacy-routes"` in `systemVerb`'s case list; `case "legacy-routes": runSystemLegacyRoutesVerb(rest)` in `runSystem`; the usage line ` carlos system legacy-routes assign --name <name> --to <account>/<app>` at the top, and under "The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS):"
948
949```
950 legacy-routes assign --name <name> --to <account>/<app>
951 the account and app they belong to, as <account>/<app>
952```
953
954In `docs/site/cli.md`, under `### carlos system`, a `#### Legacy route assignments` subsection with SD-N110's approved text and:
955
956````markdown
957```
958carlos system legacy-routes assign --name titogo --to bae/titogo
959```
960````
961
962- [ ] **Step 4: Run the tests to verify they pass**
963
964Run: `go test ./internal/console/ -run 'TestAssign|TestSystemDoors' -count=1 && go test . -run 'TestSystemVerb|TestSeeded|TestMoves' -count=1`
965Expected: PASS.
966
967- [ ] **Step 5: Wire SD-N102 to SD-N110, SD-N116 and commit**
968
969```bash
970git add internal/console/apilegacyroutes.go internal/console/apilegacyroutes_test.go internal/console/authcli.go system.go system_legacy_routes.go system_legacy_routes_test.go docs/site/cli.md approved_seeded_copy_test.go
971git commit -m "carlos system legacy-routes assign (Plan 8 Task 5)
972
973Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
974```
975
976### Task 6: Series gate
977
978- [ ] **Step 1: Every deck row is wired**
979
980`seededWiredIDs` now names every id in `seededCopyIDs`. Add the test that holds it:
981
982```go
983func TestEverySeededRowIsWired(t *testing.T) {
984 wired := map[string]bool{}
985 for _, id := range seededWiredIDs {
986 wired[id] = true
987 }
988 for _, id := range seededCopyIDs {
989 if !wired[id] {
990 t.Errorf("%s is approved and never wired", id)
991 }
992 }
993}
994```
995
996Run: `go test . -run 'TestSeeded|TestEverySeededRowIsWired' -count=1` (PASS).
997
998- [ ] **Step 2: Cross-compile and run the full suite under -race once**
999
1000Run:
1001```bash
1002GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./... && \
1003nice go test -race ./... -count=1
1004```
1005Expected: PASS.
1006
1007- [ ] **Step 3: Commit**
1008
1009```bash
1010git add approved_seeded_copy_test.go
1011git commit -m "copy gate: every seeded row is wired (Plan 8 Task 6)
1012
1013Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1014```
1015
1016---
1017
1018## Self-review
1019
1020- Spec coverage: §3.1's three sweep cases with seed-ID-scoped deletes and the CAS-first abandonment (Task 2), §5.6's sweep as the finish's fallback (Task 2 calling Plan 6's `finishSeed`), §5.8 in both doors under the CAS and in the worker before effects (Tasks 3, 4), §6.4's operator-only assignment with its in-flight rule and audit row (Task 5), §7's move errors (Task 3).
1021- §9's console tests: the seeds sweep deletes only unreferenced or superseded snapshots and never one a live `--from` needs (Task 2); the sweep abandons `reserving` only after an hour (Task 2); a move refused before placement or commit in both doors and the worker (Tasks 3, 4); `system legacy-routes assign` (Task 5).
1022- Deviation: the sweep also deletes a snapshot whose own record is `placed` or `committed` (Task 2's note); spec §3.1 lists three cases and does not cover a box's lost delete.
1023- Deviation: "more than an hour old" is the object's last-modified time from `ListMod`; a store without `ListMod` skips the snapshot walk.
1024- The dev-box checks of spec §9 and the rollout of §10 need Paul and are not tasks here.
1025