carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

Seeded Instances, Plan 8: Sweeps, Move Refusals and Legacy Route Assignments Implementation Plan

> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 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.

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.

Tech Stack: Go 1.25, release.ModLister, provision.ListAllRecords, seeds.ReadLegacyOwners.

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.

Global Constraints

  • Every user-facing string (CLI output, errors, console messages, docs prose) is copy-reviewed by Paul before it is written into code; implementation tasks use only strings from the copy deck. Copy voice: no em dashes; full stops and new sentences instead.
  • CI cross-compiles darwin and windows: linux-only code needs //go:build linux plus a !linux stub.
  • 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.
  • Heavy loops run under nice; no -race full suite until the end of a plan.
  • Production use needs nested boxes; the claim-armed path is refused here and left to #250 (spec §3.4).
  • Commits end with Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • No new file in this series imports internal/moves. 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.
  • TestClaimAuthoritySourceCensus inventories console and cmd/console calls in its selected set; add each new site to internal/console/testdata/claim-authority-sites.txt.
  • When a task wires a deck row, append its id to seededWiredIDs in approved_seeded_copy_test.go in the same commit.
  • 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).
  • 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).

Review Focus

  1. 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).
  2. 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).
  3. 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).
  4. 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).
  5. 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).

---

File structure

| path | responsibility | |---|---| | internal/provision/reconcile.go | ListAllRecordsStrict beside ListAllRecords | | internal/console/seedsweep.go | StartSeedsPass, seedsPass | | internal/console/claim_workers.go, cmd/console/main.go | the worker registered and started | | internal/provision/seed.go | SeedMoveSentence; the move transitions' refusal | | internal/provision/provision.go | MoveRequest and MoveStartTransition call seedMoveRefusal | | internal/console/seedmove.go | refuseSeedMove, called by both move doors | | internal/console/apiinstancemove.go | two calls | | internal/host/movespass.go | the worker's skip | | internal/console/apilegacyroutes.go | POST /api/cli/system/legacy-routes | | system_legacy_routes.go, system.go | carlos system legacy-routes assign | | docs/site/cli.md | SD-N110 |

---

Task 1: A strict record listing

Files:

  • Modify: internal/provision/reconcile.go:1732 (ListAllRecords)
  • Test: internal/provision/reconcile_test.go

Interfaces:

  • 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.
  • [ ] Step 1: Write the failing test
func TestListAllRecordsStrictNamesUnreadableHosts(t *testing.T) {
	s, sqid := setup(t)
	if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil {
		t.Fatal(err)
	}
	bad := acmeRecord(sqid)
	bad.Host, bad.Slug = "bad.go.tito.io", "bad"
	if err := CreateRecord(ctx, s, bad); err != nil {
		t.Fatal(err)
	}
	putReplace(t, s, RecordKey(sqid, "tito", "bad.go.tito.io"), []byte("{not json"))
	recs, unreadable, err := ListAllRecordsStrict(ctx, s)
	if err != nil || len(recs) != 1 || recs[0].Host != "acme.go.tito.io" || !unreadable["bad.go.tito.io"] {
		t.Fatalf("recs %v unreadable %v err %v", recs, unreadable, err)
	}
}
  • [ ] Step 2: Run it to verify it fails

Run: go test ./internal/provision/ -run TestListAllRecordsStrict -count=1 Expected: FAIL to compile.

  • [ ] Step 3: Split the function

Rename 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:

// ListAllRecords is ListAllRecordsStrict without the unreadable hosts.
func ListAllRecords(ctx context.Context, s release.Store) ([]Record, error) {
	recs, _, err := ListAllRecordsStrict(ctx, s)
	return recs, err
}
  • [ ] Step 4: Run the package

Run: go test ./internal/provision/ -count=1 Expected: PASS.

  • [ ] Step 5: Commit
git add internal/provision/reconcile.go internal/provision/reconcile_test.go
git commit -m "provision: ListAllRecordsStrict names the hosts it could not read (Plan 8 Task 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 2: The seeds sweep

Files:

  • Create: internal/console/seedsweep.go, internal/console/seedsweep_test.go
  • Modify: internal/console/claim_workers.go ("StartSeedsPass": s.StartSeedsPass), cmd/console/main.go (go srv.RunClaimWorker(context.Background(), "StartSeedsPass") beside StartExpiryPass)

Interfaces:

  • Consumes: provision.ListAllRecordsStrict, provision.SetRecordSeed, provision.SeedAbort, s.finishSeed(ctx, sqid, app, host, id) (Plan 6), release.ModLister, seeds.ParseSnapshotKey, seeds.Prefix.
  • 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.

One pass (spec §3.1, §5.6):

  1. Records, strictly; an error ends the pass (nothing is deleted on a partial set).
  2. 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).
  3. Snapshots under seeds/ with their last-modified times (ListMod; a store without it skips the walk). For each seeds/<host>/<id>/snapshot.db: - the host is unreadable this pass: keep; - 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; - the host's record names another id and is placed or committed: delete; - otherwise (no record, or a record naming another id in any other state): delete when the object is more than an hour old. A delete names the exact key, so it never reaches another seed's snapshot.

The "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.

  • [ ] Step 1: Write the failing tests
package console

import (
	"context"
	"errors"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/provision"
	"github.com/carlosframework/platform/internal/release"
	"github.com/carlosframework/platform/internal/seeds"
)

var sweepDataRoot string // the DirStore's root, to age its files

func sweepFixture(t *testing.T) (*consoleFixture, release.Store) {
	t.Helper()
	fx := newConsoleFixture(t)
	sweepDataRoot = t.TempDir()
	data := release.NewDirStore(sweepDataRoot)
	fx.srv.cfg.SeedData = data
	return fx, data
}

func seededRec(t *testing.T, fx *consoleFixture, host, kind, state string, at time.Time) string {
	t.Helper()
	id, _ := seeds.NewID()
	rec := provision.Record{Schema: 1, Host: host, Slug: strings.Split(host, ".")[0], App: "titogo", Account: "bab",
		Channel: "stable", CreatedAt: at, Seed: &provision.SeedSource{ID: id, Kind: kind, State: state, By: "t", At: at}}
	if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil {
		t.Fatal(err)
	}
	return id
}

func putSnapshotAged(t *testing.T, data release.Store, host, id string, age time.Duration) string {
	t.Helper()
	key := seeds.SnapshotKey(host, id)
	if _, err := data.Put(context.Background(), key, []byte("db"), ""); err != nil {
		t.Fatal(err)
	}
	// DirStore keeps a key at root/key and its ListMod reports the file's
	// mtime (store.go:338, :509): age the file.
	path := filepath.Join(sweepDataRoot, filepath.FromSlash(key))
	when := time.Now().Add(-age)
	if err := os.Chtimes(path, when, when); err != nil {
		t.Fatal(err)
	}
	return key
}

func exists(t *testing.T, data release.Store, key string) bool {
	t.Helper()
	_, _, err := data.Get(context.Background(), key)
	return !errors.Is(err, os.ErrNotExist)
}

func TestSweepLeavesALiveReservation(t *testing.T) {
	fx, data := sweepFixture(t)
	now := time.Now()
	id := seededRec(t, fx, "canary.go.tito.io", seeds.KindReplica, seeds.StateReserving, now.Add(-10*time.Minute))
	key := putSnapshotAged(t, data, "canary.go.tito.io", id, 10*time.Minute)
	fx.srv.seedsPass(context.Background(), now)
	rec, _ := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io")
	if rec.Seed.State != seeds.StateReserving || !exists(t, data, key) {
		t.Fatalf("a live reservation was touched: %s, snapshot kept %v", rec.Seed.State, exists(t, data, key))
	}
	// An hour later it is abandoned by CAS.
	fx.srv.seedsPass(context.Background(), now.Add(2*time.Hour))
	rec, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io")
	if rec.Seed.State != seeds.StateAborted {
		t.Fatalf("a stale reservation = %s, want aborted", rec.Seed.State)
	}
}

func TestSweepDeletesOnlyUnreferencedOrSupersededSnapshots(t *testing.T) {
	fx, data := sweepFixture(t)
	now := time.Now()
	live := seededRec(t, fx, "a.go.tito.io", seeds.KindReplica, seeds.StateReady, now)
	liveKey := putSnapshotAged(t, data, "a.go.tito.io", live, 3*time.Hour)
	stale, _ := seeds.NewID()
	staleKey := putSnapshotAged(t, data, "a.go.tito.io", stale, 3*time.Hour) // another id, record not placed: an orphan
	fresh, _ := seeds.NewID()
	freshKey := putSnapshotAged(t, data, "nobody.go.tito.io", fresh, 10*time.Minute) // no record yet, too young
	seededRec(t, fx, "b.go.tito.io", seeds.KindReplica, seeds.StatePlaced, now)
	old, _ := seeds.NewID()
	supersededKey := putSnapshotAged(t, data, "b.go.tito.io", old, 5*time.Minute) // another id, record placed
	fx.srv.seedsPass(context.Background(), now)
	if !exists(t, data, liveKey) {
		t.Error("a snapshot a live record needs was deleted")
	}
	if exists(t, data, staleKey) {
		t.Error("an orphan over an hour old survived")
	}
	if !exists(t, data, freshKey) {
		t.Error("a young unreferenced snapshot was deleted (its door may be between record and upload)")
	}
	if exists(t, data, supersededKey) {
		t.Error("a snapshot superseded by a placed seed survived")
	}
}

func TestSweepKeepsSnapshotsOfAnUnreadableRecord(t *testing.T) {
	fx, data := sweepFixture(t)
	id := seededRec(t, fx, "a.go.tito.io", seeds.KindReplica, seeds.StateReady, time.Now())
	key := putSnapshotAged(t, data, "a.go.tito.io", id, 3*time.Hour)
	_, etag, _ := fx.rel.Get(context.Background(), provision.RecordKey("bab", "titogo", "a.go.tito.io"))
	fx.rel.Put(context.Background(), provision.RecordKey("bab", "titogo", "a.go.tito.io"), []byte("{not json"), etag)
	fx.srv.seedsPass(context.Background(), time.Now())
	if !exists(t, data, key) {
		t.Fatal("an unreadable record's snapshot was deleted")
	}
}

func TestSweepAbandonsOnlyByCAS(t *testing.T) {
	fx, data := sweepFixture(t)
	old := time.Now().Add(-8 * 24 * time.Hour)
	id := seededRec(t, fx, "imp.go.tito.io", seeds.KindImport, seeds.StateUploaded, old)
	key := putSnapshotAged(t, data, "imp.go.tito.io", id, 8*24*time.Hour)
	// The app marks it stopped in the same instant: the CAS the sweep makes
	// must lose and nothing be deleted.
	seedsPassBeforeAbort = func() {
		provision.SetRecordSeed(context.Background(), fx.rel, "bab", "titogo", "imp.go.tito.io", provision.SeedStopped(id))
	}
	defer func() { seedsPassBeforeAbort = nil }()
	fx.srv.seedsPass(context.Background(), time.Now())
	rec, _ := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "imp.go.tito.io")
	if rec.Seed.State != seeds.StateReady || !exists(t, data, key) {
		t.Fatalf("state %s, snapshot kept %v; want the app's stop to win and nothing deleted", rec.Seed.State, exists(t, data, key))
	}
}

func TestSweepFinishesARolledBackSeed(t *testing.T) {
	fx, _ := sweepFixture(t)
	seededRec(t, fx, "gone.go.tito.io", seeds.KindImport, seeds.StateRolledBack, time.Now())
	fx.srv.seedsPass(context.Background(), time.Now())
	if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gone.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
		t.Fatalf("a rolled-back record after the sweep: %v", err)
	}
}

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.

  • [ ] Step 2: Run the tests to verify they fail

Run: go test ./internal/console/ -run 'TestSweep' -count=1 Expected: FAIL to compile.

  • [ ] Step 3: Write seedsweep.go
package console

import (
	"context"
	"errors"
	"log"
	"time"

	"github.com/carlosframework/platform/internal/provision"
	"github.com/carlosframework/platform/internal/release"
	"github.com/carlosframework/platform/internal/seeds"
)

const (
	seedsPassInterval = 5 * time.Minute
	seedReserveTTL    = time.Hour
	seedImportTTL     = 7 * 24 * time.Hour
	seedOrphanAge     = time.Hour
)

// seedsPassBeforeAbort is the tests' pause before an abandonment CAS.
var seedsPassBeforeAbort func()

var errNotStale = errors.New("console: the seed moved on")

// StartSeedsPass runs the seeds sweep now and every five minutes
// (seeded-instances spec §3.1, §5.6), StartExpiryPass's shape.
func (s *Server) StartSeedsPass(ctx context.Context) {
	if s.cfg.Releases == nil || s.cfg.SeedData == nil {
		return
	}
	t := time.NewTicker(seedsPassInterval)
	defer t.Stop()
	for {
		s.seedsPass(ctx, time.Now())
		select {
		case <-ctx.Done():
			return
		case <-t.C:
		}
	}
}

func stale(seed *provision.SeedSource, now time.Time) bool {
	switch {
	case seed.State == seeds.StateReserving:
		return now.Sub(seed.At) > seedReserveTTL
	case seed.Import() && (seed.State == seeds.StateAwaitingUpload || seed.State == seeds.StateUploaded):
		return now.Sub(seed.At) > seedImportTTL
	}
	return false
}

func (s *Server) seedsPass(ctx context.Context, now time.Time) {
	recs, unreadable, err := provision.ListAllRecordsStrict(ctx, s.cfg.Releases)
	if err != nil {
		log.Printf("console: seeds pass: %v (nothing swept this pass)", err)
		return
	}
	byHost := map[string]provision.Record{}
	abandoned := map[string]string{} // host -> seed id aborted by this pass
	for _, rec := range recs {
		byHost[rec.Host] = rec
		seed := rec.Seed
		if seed == nil {
			continue
		}
		switch {
		case stale(seed, now):
			if seedsPassBeforeAbort != nil {
				seedsPassBeforeAbort()
			}
			_, err := provision.SetRecordSeed(ctx, s.cfg.Releases, rec.Account, rec.App, rec.Host, func(r *provision.Record) error {
				if r.Seed == nil || r.Seed.ID != seed.ID || !stale(r.Seed, now) {
					return errNotStale
				}
				return provision.SeedAbort(seed.ID)(r)
			})
			if err != nil {
				if !errors.Is(err, errNotStale) && !errors.Is(err, provision.ErrSeedConflict) {
					log.Printf("console: seeds pass: abandon %s: %v", rec.Host, err)
				}
				continue
			}
			abandoned[rec.Host] = seed.ID
			s.bumpSerial(ctx)
			log.Printf("console: seeds pass: abandoned seed %s of %s (%s since %s)", seed.ID, rec.Host, seed.State, seed.At.Format(time.RFC3339))
		case seed.State == seeds.StateRolledBack:
			if err := s.finishSeed(ctx, rec.Account, rec.App, rec.Host, seed.ID); err != nil {
				log.Printf("console: seeds pass: finish %s: %v", rec.Host, err)
			}
		}
	}
	ml, ok := s.cfg.SeedData.(release.ModLister)
	if !ok {
		return
	}
	objs, err := ml.ListMod(ctx, seeds.Prefix)
	if err != nil {
		log.Printf("console: seeds pass: list snapshots: %v", err)
		return
	}
	for _, o := range objs {
		host, id, ok := seeds.ParseSnapshotKey(o.Key)
		if !ok || unreadable[host] {
			continue
		}
		rec, has := byHost[host]
		del := false
		switch {
		case has && rec.Seed != nil && rec.Seed.ID == id:
			placed := rec.Seed.State == seeds.StatePlaced || rec.Seed.State == seeds.StateCommitted
			del = placed || abandoned[host] == id
		case has && rec.Seed != nil && (rec.Seed.State == seeds.StatePlaced || rec.Seed.State == seeds.StateCommitted):
			del = true
		default:
			del = now.Sub(o.Mod) > seedOrphanAge
		}
		if !del {
			continue
		}
		if err := s.cfg.SeedData.Delete(ctx, o.Key); err != nil {
			log.Printf("console: seeds pass: delete %s: %v", o.Key, err)
			continue
		}
		log.Printf("console: seeds pass: deleted %s", o.Key)
	}
}

Register the worker in claim_workers.go's map ("StartSeedsPass": s.StartSeedsPass,) and start it in cmd/console/main.go beside StartExpiryPass:

	// The seeds sweep (seeded-instances spec §3.1, §5.6): stale
	// reservations and imports abandoned, rolled-back seeds finished,
	// unreferenced snapshots deleted. Same placement rule as every pass
	// above.
	go srv.RunClaimWorker(context.Background(), "StartSeedsPass")

If a test pins the worker names (grep -rn '"StartExpiryPass"' internal/console/*_test.go), add StartSeedsPass there.

  • [ ] Step 4: Run the tests to verify they pass

Run: go test ./internal/console/ -run 'TestSweep|TestClaimWorker|TestClaimAuthoritySourceCensus' -count=1 Expected: PASS.

  • [ ] Step 5: Commit
git 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
git commit -m "console: the seeds sweep (Plan 8 Task 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: Moves wait for placement, in both doors

Files:

  • Modify: internal/provision/seed.go (SeedMoveSentence, seedMoveRefusal), internal/provision/provision.go:1146 (MoveRequest), :1177 (MoveStartTransition)
  • Create: internal/console/seedmove.go
  • Modify: internal/console/apiinstancemove.go (legacyHandleAPIInstanceMoveRequest :260, after moveGate; handleAPIInstanceMoveRequest :396, after moveOperatorGate)
  • Test: internal/provision/seed_test.go, internal/console/seedmove_test.go

Interfaces:

  • 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.

The 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.

  • [ ] Step 1: Write the failing tests

In internal/provision/seed_test.go:

func TestMoveTransitionsRefuseAnUnplacedSeed(t *testing.T) {
	ctx := context.Background()
	for _, c := range []struct {
		kind, state, want string
	}{
		{seeds.KindReplica, seeds.StateReady, "wake the instance once before moving it"},
		{seeds.KindImport, seeds.StatePlaced, "commit or abort the import first"},
	} {
		s := release.NewDirStore(t.TempDir())
		rec := seedTestRecord(t, s, testSeed(t, c.kind, c.state))
		_, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, MoveRequest("uk", time.Now()))
		if !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), c.want) {
			t.Fatalf("%s %s: err = %v", c.kind, c.state, err)
		}
		_, 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()}))
		if !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), c.want) {
			t.Fatalf("%s %s start: err = %v", c.kind, c.state, err)
		}
	}
	s := release.NewDirStore(t.TempDir())
	rec := seedTestRecord(t, s, testSeed(t, seeds.KindReplica, seeds.StatePlaced))
	if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, MoveRequest("uk", time.Now())); err != nil {
		t.Fatalf("a placed seed: %v", err)
	}
}

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):

package console

import (
	"context"
	"net/http"
	"strings"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/provision"
	"github.com/carlosframework/platform/internal/seeds"
)

func TestMoveDoorsRefuseAnUnplacedSeed(t *testing.T) {
	fx, token := instanceFixture(t, "code-seed-move")
	m := titoManifest()
	m.Pool = ""
	enableInstances(t, fx, "titogo", m)
	id, _ := seeds.NewID()
	rec := provision.Record{Schema: 1, Host: "canary.go.tito.io", Slug: "canary", App: "titogo", Account: "bab",
		Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC(),
		Seed: &provision.SeedSource{ID: id, Kind: seeds.KindReplica, State: seeds.StateReady, By: "t", At: time.Now().UTC()}}
	if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil {
		t.Fatal(err)
	}
	code, _, raw := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo")+"/canary.go.tito.io/move", token, `{"region":"uk"}`)
	if code != http.StatusConflict || !strings.Contains(raw, "wake the instance once before moving it") {
		t.Fatalf("legacy door = %d %s", code, raw)
	}
	got, _ := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io")
	if got.Move != nil {
		t.Fatalf("a refused move left a journal: %+v", got.Move)
	}
}

Add 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.

  • [ ] Step 2: Run the tests to verify they fail

Run: go test ./internal/provision/ -run TestMoveTransitionsRefuseAnUnplacedSeed -count=1; go test ./internal/console/ -run TestMoveDoorsRefuseAnUnplacedSeed -count=1 Expected: FAIL: the moves are accepted.

  • [ ] Step 3: Write the refusal

Append to internal/provision/seed.go:

// SeedMoveSentence is a move refusal's sentence (spec §5.8).
func SeedMoveSentence(block string) string {
	switch block {
	case SeedMoveUnplaced:
		return "wake the instance once before moving it"
	case SeedMoveUncommitted:
		return "commit or abort the import first"
	}
	return ""
}

// seedMoveRefusal is the move transitions' seed rule, under the record CAS.
func seedMoveRefusal(rec *Record) error {
	if b := rec.Seed.MoveBlock(); b != "" {
		return fmt.Errorf("%w: %s", ErrMoveConflict, SeedMoveSentence(b))
	}
	return nil
}

In MoveRequest's and MoveStartTransition's closures, first statement: if err := seedMoveRefusal(rec); err != nil { return err }.

internal/console/seedmove.go:

package console

import (
	"net/http"

	"github.com/carlosframework/platform/internal/provision"
)

// refuseSeedMove answers a move of a seed that is not placed (--from) or
// not committed (import) with the deck's sentence (spec §5.8). The
// transitions refuse it again under the record CAS.
func (s *Server) refuseSeedMove(w http.ResponseWriter, rec *provision.Record) bool {
	if b := rec.Seed.MoveBlock(); b != "" {
		jsonError(w, http.StatusConflict, provision.SeedMoveSentence(b))
		return true
	}
	return false
}

In 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).

  • [ ] Step 4: Run the tests to verify they pass

Run: go test ./internal/provision/ -run 'TestMove|TestSeed' -count=1 && go test ./internal/console/ -run 'TestMove|TestAPIInstanceMove' -count=1 && go test . -run TestMoves -count=1 Expected: PASS, the moves copy gate included (the doors gained one call each).

  • [ ] Step 5: Wire SD-N100, SD-N101 and commit
git 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
git commit -m "moves wait for a seed's placement or an import's commit, in both doors (Plan 8 Task 3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: The move worker refuses before any effect

Files:

  • Modify: internal/host/movespass.go:596 (movesPass's record loop)
  • Test: internal/host/movespass_test.go

Interfaces:

  • Consumes: (*provision.SeedSource).MoveBlock, provision.SeedMoveSentence, w.notice(key, format string, args ...any).

A 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.

  • [ ] Step 1: Write the failing test
func TestMovesPassSkipsAnUnplacedSeed(t *testing.T) {
	h := newMoveHarness(t)
	host := "a.go.example.com"
	id, _ := seeds.NewID()
	rec := provision.Record{Schema: 1, Host: host, Slug: "s", App: "app", Account: "bab", Region: "ie", Channel: "stable",
		CreatedAt: time.Now().UTC(),
		Move:      &provision.MoveState{To: "uk", State: provision.MovePreparing, RequestedAt: time.Now().UTC()},
		Seed:      &provision.SeedSource{ID: id, Kind: seeds.KindReplica, State: seeds.StateReady, By: "t", At: time.Now().UTC()}}
	if err := provision.CreateRecord(context.Background(), h.s, rec); err != nil {
		t.Fatal(err)
	}
	h.f.put("data-ie", "instances/"+host+"/generations/g/wal/000", "e1")
	if err := movesPass(context.Background(), h.s, h.worker()); err != nil {
		t.Fatal(err)
	}
	if staged, _ := h.f.List(context.Background(), "data-uk", "moves/"+host+"/"); len(staged) != 0 {
		t.Fatalf("the worker mirrored an unplaced seed: %v", staged)
	}
	if got := h.read(t, host); got.Move.State != provision.MovePreparing {
		t.Fatalf("state = %s", got.Move.State)
	}
}

(add the seeds import to movespass_test.go).

  • [ ] Step 2: Run it to verify it fails

Run: go test ./internal/host/ -run TestMovesPassSkipsAnUnplacedSeed -count=1 Expected: FAIL: the round mirrors.

  • [ ] Step 3: Add the skip

In movesPass's for _, rec := range recs { loop, first statement:

		// A seed moves only once placed, an import only once committed
		// (seeded-instances spec §5.8): a record that slipped past an old
		// console is refused here, before the first copy.
		if rec.Move != nil && rec.Move.State == provision.MovePreparing {
			if b := rec.Seed.MoveBlock(); b != "" {
				w.notice(rec.Host+"#seed", "moves: %s: %s", rec.Host, provision.SeedMoveSentence(b))
				continue
			}
		}
  • [ ] Step 4: Run the tests to verify they pass

Run: go test ./internal/host/ -run 'TestMovesPass' -count=1 && go test . -run TestMoves -count=1 Expected: PASS.

  • [ ] Step 5: Commit
git add internal/host/movespass.go internal/host/movespass_test.go
git commit -m "moves worker: an unplaced seed waits before any copy (Plan 8 Task 4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 5: carlos system legacy-routes assign

Files:

  • Create: internal/console/apilegacyroutes.go, internal/console/apilegacyroutes_test.go, system_legacy_routes.go, system_legacy_routes_test.go
  • Modify: internal/console/authcli.go (route beside the seeds-floor door), system.go (systemVerb, runSystem, systemUsage), docs/site/cli.md

Interfaces:

  • 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.
  • Produces: POST /api/cli/system/legacy-routes {"name": "titogo", "to": "bae/titogo"} answering 200 {"name":…, "account":…, "app":…}; runSystemLegacyRoutesVerb(rest []string).

The 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.

  • [ ] Step 1: Write the failing tests
package console

import (
	"context"
	"net/http"
	"strings"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/provision"
	"github.com/carlosframework/platform/internal/seeds"
)

func TestAssignLegacyRoutes(t *testing.T) {
	fx, _ := instanceFixture(t, "code-assign") // app titogo in bab
	owner := cliToken(t, fx, "paul@keymail.dev", "code-assign-owner")
	u := fx.ts.URL + "/api/cli/system/legacy-routes"
	if st, out := postJSON(t, u, map[string]any{"name": "Not An App!", "to": "bab/titogo"}, owner); st != http.StatusBadRequest {
		t.Fatalf("bad name = %d %v", st, out)
	}
	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") {
		t.Fatalf("bad to = %d %v", st, out)
	}
	if st, out := postJSON(t, u, map[string]any{"name": "titogo", "to": "bab/titogo"}, owner); st != http.StatusOK || out["account"] != "bab" {
		t.Fatalf("assign = %d %v", st, out)
	}
	o, _, err := seeds.ReadLegacyOwners(context.Background(), fx.rel)
	if got, ok := o.Lookup("titogo"); err != nil || !ok || got != (seeds.LegacyOwner{Account: "bab", App: "titogo"}) {
		t.Fatalf("file = %+v %v", o, err)
	}
}

func TestAssignRefusedWhileAnImportIsInFlight(t *testing.T) {
	fx, _ := instanceFixture(t, "code-assign-busy")
	owner := cliToken(t, fx, "paul@keymail.dev", "code-assign-busy-owner")
	u := fx.ts.URL + "/api/cli/system/legacy-routes"
	postJSON(t, u, map[string]any{"name": "titogo", "to": "bab/titogo"}, owner)
	id, _ := seeds.NewID()
	rec := provision.Record{Schema: 1, Host: "old.go.tito.io", Slug: "old", App: "titogo", Account: "bab", Channel: "stable",
		CreatedAt: time.Now().UTC(), Seed: &provision.SeedSource{ID: id, Kind: seeds.KindImport, State: seeds.StateSwapped, By: "t", At: time.Now().UTC()}}
	if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil {
		t.Fatal(err)
	}
	createApp(t, fx, "bab", "other") // the fixture helper that adds an app to an account
	st, out := postJSON(t, u, map[string]any{"name": "titogo", "to": "bab/other"}, owner)
	if st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "has an import in flight") {
		t.Fatalf("re-assign = %d %v", st, out)
	}
	o, _, _ := seeds.ReadLegacyOwners(context.Background(), fx.rel)
	if got, _ := o.Lookup("titogo"); got.App != "titogo" {
		t.Fatalf("the file changed: %+v", got)
	}
}

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.

system_legacy_routes_test.go:

package main

import "testing"

func TestSystemVerbKnowsLegacyRoutes(t *testing.T) {
	verb, rest, _ := systemVerb([]string{"legacy-routes", "assign", "--name", "titogo", "--to", "bae/titogo"})
	if verb != "legacy-routes" || len(rest) != 5 || rest[0] != "assign" {
		t.Fatalf("systemVerb = %q %q", verb, rest)
	}
}
  • [ ] Step 2: Run the tests to verify they fail

Run: go test ./internal/console/ -run 'TestAssign' -count=1; go test . -run TestSystemVerbKnowsLegacyRoutes -count=1 Expected: FAIL.

  • [ ] Step 3: Write the door, the route, the verb and the docs

internal/console/apilegacyroutes.go:

package console

import (
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"strings"

	"github.com/carlosframework/platform/internal/provision"
	"github.com/carlosframework/platform/internal/release"
	"github.com/carlosframework/platform/internal/seeds"
)

// handleAPISystemLegacyRoutes is `carlos system legacy-routes assign`
// (seeded-instances spec §6.4, codex 9): an operator maps an app name, as
// filed under the operator account, to one account and app.
func (s *Server) handleAPISystemLegacyRoutes(w http.ResponseWriter, r *http.Request) {
	addr, _, ok := s.apiSystemRoller(w, r)
	if !ok {
		return
	}
	var req struct {
		Name string `json:"name"`
		To   string `json:"to"`
	}
	if !readJSON(w, r, &req) {
		return
	}
	name := strings.TrimSpace(req.Name)
	if !release.ValidApp(name) {
		jsonError(w, http.StatusBadRequest, "name must be an app name")
		return
	}
	acctSqid, app, found := strings.Cut(strings.TrimSpace(req.To), "/")
	if !found || !release.ValidApp(app) {
		jsonError(w, http.StatusBadRequest, "to must be <account>/<app>")
		return
	}
	acct, err := s.store.AccountBySqid(acctSqid)
	if err != nil || acct == nil {
		jsonError(w, http.StatusBadRequest, "to must be <account>/<app>")
		return
	}
	if has, err := s.store.HasAppIn(acct.ID, app); err != nil || !has {
		jsonError(w, http.StatusBadRequest, "to must be <account>/<app>")
		return
	}
	ctx := r.Context()
	owners, etag, err := seeds.ReadLegacyOwners(ctx, s.cfg.Releases)
	if err != nil {
		log.Printf("console: legacy routes: %v", err)
		jsonError(w, http.StatusBadGateway, "could not read the legacy route assignments. Try again.")
		return
	}
	want := seeds.LegacyOwner{Account: acct.Sqid, App: app}
	if cur, ok := owners.Lookup(name); ok {
		if cur == want {
			writeJSON(w, http.StatusOK, map[string]string{"name": name, "account": want.Account, "app": want.App})
			return
		}
		busy, err := importInFlight(ctx, s.cfg.Releases, cur)
		if err != nil {
			log.Printf("console: legacy routes: in-flight check: %v", err)
			jsonError(w, http.StatusBadGateway, "could not read the legacy route assignments. Try again.")
			return
		}
		if busy {
			jsonError(w, http.StatusConflict, fmt.Sprintf("%s has an import in flight under its current assignment. Commit or abort it first.", name))
			return
		}
	}
	owners.Names[name] = want
	body, _ := json.Marshal(owners)
	if _, err := s.cfg.Releases.Put(ctx, seeds.LegacyOwnersKey, body, etag); err != nil {
		if !errors.Is(err, release.ErrCASConflict) {
			log.Printf("console: legacy routes: write: %v", err)
		}
		jsonError(w, http.StatusBadGateway, "could not save the legacy route assignment. Try again.")
		return
	}
	s.auditSystem(addr, "legacy-route-assign", fmt.Sprintf("assigned the legacy routes named %s to %s/%s", name, want.Account, want.App))
	s.bumpSerial(ctx)
	writeJSON(w, http.StatusOK, map[string]string{"name": name, "account": want.Account, "app": want.App})
}

// importInFlight reports an import of o's account and app between ready
// and committed (spec §6.4).
func importInFlight(ctx context.Context, rs release.Store, o seeds.LegacyOwner) (bool, error) {
	recs, truncated, err := provision.ListRecords(ctx, rs, o.Account, o.App, 10000)
	if err != nil {
		return false, err
	}
	if truncated {
		return true, nil // a listing that did not see every record never says "idle"
	}
	for _, rec := range recs {
		if rec.Seed.Import() {
			switch rec.Seed.State {
			case seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced:
				return true, nil
			}
		}
	}
	return false, nil
}

(add "context" to the imports).

In authcli.go, beside the seeds-floor route: s.cliRoute("POST /api/cli/system/legacy-routes", svcrole.Set{}, s.handleAPISystemLegacyRoutes).

system_legacy_routes.go:

package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
)

// runSystemLegacyRoutesVerb is `carlos system legacy-routes assign`
// (seeded-instances spec §6.4).
func runSystemLegacyRoutesVerb(rest []string) {
	if len(rest) == 0 || rest[0] != "assign" {
		systemUsage()
		os.Exit(2)
	}
	fs := flag.NewFlagSet("system legacy-routes assign", flag.ExitOnError)
	consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)")
	name := fs.String("name", "", "the app name the legacy routes are filed under, in the operator account")
	to := fs.String("to", "", "the account and app they belong to, as <account>/<app>")
	fs.Parse(rest[1:])
	if *name == "" || *to == "" || fs.NArg() != 0 {
		fs.Usage()
		os.Exit(2)
	}
	sess := requireDeployAPISession(*consoleFlag)
	printUsing(stdout, sess)
	status, body, err := sess.postJSON(context.Background(), "/api/cli/system/legacy-routes", map[string]string{"name": *name, "to": *to})
	if err != nil {
		log.Fatalf("carlos system legacy-routes assign: %v", err)
	}
	if status != http.StatusOK {
		log.Fatalf("carlos system legacy-routes assign: %v", apiErrorFromBody(status, body))
	}
	var out struct {
		Name    string `json:"name"`
		Account string `json:"account"`
		App     string `json:"app"`
	}
	if err := json.Unmarshal(body, &out); err != nil {
		log.Fatalf("carlos system legacy-routes assign: unreadable response: %v", err)
	}
	fmt.Fprintln(stdout, fmt.Sprintf("assigned the legacy routes named %s to %s/%s", out.Name, out.Account, out.App))
}

(add "encoding/json" to the imports).

In 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 CARLOSSYSTEMOPERATORS):"

  legacy-routes assign --name <name> --to <account>/<app>
            the account and app they belong to, as <account>/<app>

In docs/site/cli.md, under ### carlos system, a #### Legacy route assignments subsection with SD-N110's approved text and:

````markdown

carlos system legacy-routes assign --name titogo --to bae/titogo

````

  • [ ] Step 4: Run the tests to verify they pass

Run: go test ./internal/console/ -run 'TestAssign|TestSystemDoors' -count=1 && go test . -run 'TestSystemVerb|TestSeeded|TestMoves' -count=1 Expected: PASS.

  • [ ] Step 5: Wire SD-N102 to SD-N110, SD-N116 and commit
git 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
git commit -m "carlos system legacy-routes assign (Plan 8 Task 5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 6: Series gate

  • [ ] Step 1: Every deck row is wired

seededWiredIDs now names every id in seededCopyIDs. Add the test that holds it:

func TestEverySeededRowIsWired(t *testing.T) {
	wired := map[string]bool{}
	for _, id := range seededWiredIDs {
		wired[id] = true
	}
	for _, id := range seededCopyIDs {
		if !wired[id] {
			t.Errorf("%s is approved and never wired", id)
		}
	}
}

Run: go test . -run 'TestSeeded|TestEverySeededRowIsWired' -count=1 (PASS).

  • [ ] Step 2: Cross-compile and run the full suite under -race once

Run:

GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./... && \
nice go test -race ./... -count=1

Expected: PASS.

  • [ ] Step 3: Commit
git add approved_seeded_copy_test.go
git commit -m "copy gate: every seeded row is wired (Plan 8 Task 6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

---

Self-review

  • 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).
  • §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).
  • 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.
  • Deviation: "more than an hour old" is the object's last-modified time from ListMod; a store without ListMod skips the snapshot walk.
  • The dev-box checks of spec §9 and the rollout of §10 need Paul and are not tasks here.