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 6: The Import Door 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: The console's half of importing an instance the platform never ran: begin (with a once-shown verify token), a pinned upload checked twice before it reaches the bucket, the app's "stopped", commit after verification, abort before commit, the delete refusal, the console's finish of an abandoned seed, and carlos instances import that drives it end to end.

Architecture: Begin is the create door with "import" in its body: after every ordinary check it hands off to createImport (new file), which checks the region (one box, Plan 3), the declared length and hash, mints the seed ID and the verify token, writes the record awaiting-upload with only the token's hash, provisions an empty store marked with the seed, and answers the token once. Upload streams into a spool under the shared scratch pool, hashing as it writes; a mismatch or a file that fails CheckDBContext is refused; one PutFrom writes seeds/<host>/<id>/snapshot.db; the seed becomes uploaded. stopped, commit and abort are record CASes through SetRecordSeed, so a commit and an abort racing on the etag end with one winner. finishSeed is the console's last word on an abandoned seed (spec §5.6, codex R6-1): tear down the store whose declared seed is this ID, then delete the record by If-Match, only while it still names this ID. The CLI command is a thin driver over these five calls plus the instance read.

Tech Stack: Go 1.25, http.MaxBytesReader, http.ResponseController read deadlines, lease.CheckDBContext, release.PutObjectFrom, release.DeleteIfMatch, objstore.TeardownIn.

Spec: docs/superpowers/specs/2026-10-08-seeded-instances-design.md rev 7: §5.1, §5.2 (codex 19, 20), §5.3, §5.4's commit, §5.5, §5.6's abort, delete refusal and console finish (codex R2-7, R6-1), §6.1, §7 (audit rows, errors). Index: 2026-10-08-seeded-instances-0-index.md. Copy deck rows SD-N48 to SD-N82, SD-N89, SD-N90, SD-N115.

Dependency: Task 5 reads provision.Record.ActiveRun (run spec §5.1, the run series' record task). Build Task 5 after that task has merged; Tasks 1 to 4 and 6 do not need it.

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 (apiinstanceimport.go, seedfinish.go, instances_import.go), never in a file listed in movesScanFiles; apiinstances.go and authcli.go gain only calls and routes.
  • TestClaimAuthoritySourceCensus inventories console calls in its selected set (Open among them); 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) or a " %s\n" format around a separately built sentence.
  • Every door here authorizes through cliRoute with svcrole.Allow(svcrole.Instance) and then instanceGate (membership for people), as POST …/instances does (spec §6.1).
  • The verify token is in exactly one response body (begin's 201). It is never logged, audited or stored; the record keeps seeds.HashToken(token).

Review Focus

  1. An upload whose body is longer than bytes must be cut at bytes + 1 and refused without the console holding more than the reservation on disk. Test in Task 2 (TestImportUploadRefusesLengthAndHash).
  2. A second upload after a successful one (a client retry) must be refused by state, not overwrite the snapshot the record now vouches for. Test in Task 2 (TestImportUploadOnlyOnce).
  3. Commit and abort racing: exactly one wins, and the loser gets the "changed while" answer or the committed answer, never a 502. Test in Task 5 (TestImportCommitAbortRace).
  4. finishSeed must never tear down a store whose declared seed is another ID or none, and must leave a record that names another seed ID alone. Test in Task 4 (TestFinishSeedIsScopedToItsID).
  5. carlos instances import --verify on a verify path that answers 503 while the instance wakes must retry within a bound, not abort on the first 503. Test in Task 6 (TestImportVerifyRetriesThroughTheWake).

---

File structure

| path | responsibility | |---|---| | internal/console/apiinstanceimport.go | createImport, the upload, stopped, commit and abort handlers, refuseImportDelete | | internal/console/seedfinish.go | finishSeed: the seed-guarded store teardown and the record's If-Match delete | | internal/console/apiinstances.go | the request's import field and hand-off; one call in the delete handler | | internal/console/authcli.go | four routes | | instances_import.go, cli.go | carlos instances import, import commit, import abort | | apimode.go | the client calls | | docs/site/cli.md | SD-N89 |

---

Task 1: Begin

Files:

  • Create: internal/console/apiinstanceimport.go, internal/console/apiinstanceimport_test.go
  • Modify: internal/console/apiinstances.go (request struct; seeded covers Import; hand-off after createFrom's)

Interfaces:

  • Consumes: seedCreate, seedCommonRefusal, seedPlacementRefusal, s.seedStoreRefusal, s.instanceStoreProvisionFor, s.seedRegionReady(ctx, region, true) (Plans 3, 5); seeds.NewID, seeds.NewVerifyToken, seeds.MaxBytes; provision.CreateRecord, SetRecordSeed, SeedAbort; s.dbResidencyRefusal.
  • Produces: type seedImportReq struct{ SHA256 string; Bytes int64; Replaces string } (body key import); (s *Server) createImport(w, r, c seedCreate, imp seedImportReq); the 201 body: the record view plus "verify_token": "<token>".

Begin's checks, after the create door's own (spec §5.1): placement (SD-N37); a data bucket (SD-N38); bytes in 1 to 4 GiB (SD-N48: 400 for ≤ 0, 413 above the limit); sha256 as 64 lowercase hex (SD-N49); replaces as host:port with a numeric port (SD-N50); the record homed in this console's region (rec.Region != s.cfg.Region: 409 with the existing dbResidencyRefusal); seedRegionReady(ctx, rec.Region, true); the store rule (seedStoreRefusal). Then mint the ID and token, CreateRecord with seed awaiting-upload (CAS conflict: the existing "instance <host> already exists", which is the "host isn't already a platform record" check), provision the store (abort on refusal), audit instance-seed (SD-N62), and answer 201.

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

import (
	"context"
	"encoding/json"
	"net/http"
	"strings"
	"testing"

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

func newImportFixture(t *testing.T) fromFixture {
	t.Helper()
	f := newFromFixture(t, 5) // Plan 5's fixture: a ready one-box home region, scratch, a data store
	return f
}

func beginImport(t *testing.T, f fromFixture, host, sha string, bytes int64) (int, map[string]any) {
	t.Helper()
	body, _ := json.Marshal(map[string]any{"host": host, "import": map[string]any{"sha256": sha, "bytes": bytes, "replaces": "127.0.0.1:8401"}})
	return f.create(t, string(body))
}

func TestImportBegin(t *testing.T) {
	f := newImportFixture(t)
	sha := strings.Repeat("a", 64)
	code, out := beginImport(t, f, "old.go.tito.io", sha, 4096)
	if code != http.StatusCreated {
		t.Fatalf("begin = %d %v", code, out)
	}
	token := str(t, out, "verify_token")
	if len(token) != 43 {
		t.Fatalf("verify token %q", token)
	}
	rec, err := provision.GetRecord(context.Background(), f.fx.rel, "bab", "titogo", "old.go.tito.io")
	if err != nil || rec.Seed == nil || rec.Seed.Kind != seeds.KindImport || rec.Seed.State != seeds.StateAwaitingUpload ||
		rec.Seed.SHA256 != sha || rec.Seed.Bytes != 4096 || rec.Seed.Replaces != "127.0.0.1:8401" || rec.Seed.VerifyHash != seeds.HashToken(token) {
		t.Fatalf("record = %+v, %v", rec.Seed, err)
	}
	raw, _, _ := f.fx.rel.Get(context.Background(), provision.RecordKey("bab", "titogo", "old.go.tito.io"))
	if strings.Contains(string(raw), token) {
		t.Fatal("the token itself reached the bucket")
	}
	// A second begin for the same host: the record holds it.
	if code, _ := beginImport(t, f, "old.go.tito.io", sha, 4096); code != http.StatusConflict {
		t.Fatalf("second begin = %d, want 409", code)
	}
}

func TestImportBeginRefusals(t *testing.T) {
	f := newImportFixture(t)
	for _, c := range []struct {
		body string
		code int
		want string
	}{
		{`{"host":"a.go.tito.io","import":{"sha256":"` + strings.Repeat("a", 64) + `","bytes":0,"replaces":"127.0.0.1:1"}}`, http.StatusBadRequest, "more than 0"},
		{`{"host":"a.go.tito.io","import":{"sha256":"` + strings.Repeat("a", 64) + `","bytes":4294967297,"replaces":"127.0.0.1:1"}}`, http.StatusRequestEntityTooLarge, "at most 4 GiB"},
		{`{"host":"a.go.tito.io","import":{"sha256":"ABC","bytes":10,"replaces":"127.0.0.1:1"}}`, http.StatusBadRequest, "64 lowercase hex"},
		{`{"host":"a.go.tito.io","import":{"sha256":"` + strings.Repeat("a", 64) + `","bytes":10,"replaces":"nope"}}`, http.StatusBadRequest, "host:port"},
		{`{"host":"a.go.tito.io","kind":"service","import":{"sha256":"` + strings.Repeat("a", 64) + `","bytes":10,"replaces":"127.0.0.1:1"}}`, http.StatusBadRequest, "ordinary instance"},
	} {
		if code, out := f.create(t, c.body); code != c.code || !strings.Contains(str(t, out, "error"), c.want) {
			t.Errorf("%s = %d %v, want %d %q", c.body, code, out, c.code, c.want)
		}
	}
}

func TestImportBeginNeedsOneBox(t *testing.T) {
	f := newImportFixture(t)
	seedReadyBox(t, f.fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "nested", false)
	if code, out := beginImport(t, f, "old.go.tito.io", strings.Repeat("a", 64), 10); code != http.StatusConflict || !strings.Contains(str(t, out, "error"), "more than one box") {
		t.Fatalf("= %d %v", code, out)
	}
}
  • [ ] Step 2: Run the tests to verify they fail

Run: go test ./internal/console/ -run 'TestImportBegin' -count=1 Expected: FAIL (the import key is ignored, so the request creates an ordinary instance, or compile errors once the test references new names).

  • [ ] Step 3: Write createImport

internal/console/apiinstanceimport.go:

package console

import (
	"errors"
	"fmt"
	"log"
	"net"
	"net/http"
	"regexp"
	"strconv"
	"time"

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

// seedImportReq is the create body's "import" (seeded-instances spec §5.1).
type seedImportReq struct {
	SHA256   string `json:"sha256"`
	Bytes    int64  `json:"bytes"`
	Replaces string `json:"replaces"`
}

var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)

func validReplaces(addr string) bool {
	host, port, err := net.SplitHostPort(addr)
	if err != nil || host == "" {
		return false
	}
	n, err := strconv.Atoi(port)
	return err == nil && n > 0 && n < 65536
}

// createImport is the import door's begin (spec §5.1).
func (s *Server) createImport(w http.ResponseWriter, r *http.Request, c seedCreate, imp seedImportReq) {
	ctx := r.Context()
	acct, app, host := c.acct, c.app, c.rec.Host
	if ref := seedPlacementRefusal(c); ref != nil {
		jsonError(w, ref.Status, ref.Msg)
		return
	}
	if s.cfg.SeedData == nil || s.cfg.HibernateBucket == "" {
		jsonError(w, http.StatusServiceUnavailable, "this console has no data bucket set up, so it cannot create a seeded instance.")
		return
	}
	switch {
	case imp.Bytes <= 0:
		jsonError(w, http.StatusBadRequest, "bytes must be more than 0 and at most 4 GiB")
		return
	case imp.Bytes > seedMaxBytes:
		jsonError(w, http.StatusRequestEntityTooLarge, "bytes must be more than 0 and at most 4 GiB")
		return
	case !sha256Hex.MatchString(imp.SHA256):
		jsonError(w, http.StatusBadRequest, "sha256 must be 64 lowercase hex characters")
		return
	case !validReplaces(imp.Replaces):
		jsonError(w, http.StatusBadRequest, "replaces must name the address the import takes over, as host:port")
		return
	}
	if c.rec.Region != s.cfg.Region {
		jsonError(w, http.StatusConflict, s.dbResidencyRefusal(c.rec))
		return
	}
	if ref := s.seedRegionReady(ctx, c.rec.Region, true); ref != nil {
		jsonError(w, ref.Status, ref.Msg)
		return
	}
	id, err := seeds.NewID()
	if err != nil {
		jsonError(w, http.StatusBadGateway, "could not store the upload. Try again.")
		return
	}
	token, hash, err := seeds.NewVerifyToken()
	if err != nil {
		jsonError(w, http.StatusBadGateway, "could not store the upload. Try again.")
		return
	}
	if ref := s.seedStoreRefusal(ctx, c, id); ref != nil {
		jsonError(w, ref.Status, ref.Msg)
		return
	}
	rec := c.rec
	rec.Seed = &provision.SeedSource{ID: id, Kind: seeds.KindImport, SHA256: imp.SHA256, Bytes: imp.Bytes,
		State: seeds.StateAwaitingUpload, By: c.principal, At: time.Now().UTC(), Replaces: imp.Replaces, VerifyHash: hash}
	if err := provision.CreateRecord(ctx, s.cfg.Releases, rec); err != nil {
		if errors.Is(err, release.ErrCASConflict) {
			jsonError(w, http.StatusConflict, "instance "+host+" already exists")
			return
		}
		log.Printf("console: import %s: begin: %v", host, err)
		jsonError(w, http.StatusBadGateway, "could not store the upload. Try again.")
		return
	}
	if c.m.PrivateStorage {
		if _, ok := s.instanceStoreProvisionFor(ctx, w, acct, app, rec.Environment, c.principal, id); !ok {
			s.abortSeedQuietly(ctx, acct, app, host, id, "store provisioning")
			return
		}
	}
	short := imp.SHA256[:12]
	s.auditActivity(acct, app, c.principal, Activity{
		Action: "instance-seed", TargetType: "instance", Target: host, Message: c.message,
		Detail: fmt.Sprintf("began an import of %s over %s (%s, %d bytes)", host, imp.Replaces, short, imp.Bytes),
	})
	out := struct {
		apiRecordView
		VerifyToken string `json:"verify_token"`
	}{recordView(rec), token}
	out.RegionFrom = c.regionFrom
	writeJSON(w, http.StatusCreated, out)
}

Add to apiinstanceseed.go the shared quiet abort (and use it in createFrom's abort closure too, replacing its body):

// abortSeedQuietly CASes a seed to aborted under a context the client's
// departure cannot cancel, and bumps the serial so the box acts on it.
func (s *Server) abortSeedQuietly(ctx context.Context, acct *Account, app, host, id, why string) {
	bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), instanceStoreTimeout)
	defer cancel()
	if _, err := provision.SetRecordSeed(bg, s.cfg.Releases, acct.Sqid, app, host, provision.SeedAbort(id)); err != nil {
		log.Printf("console: seed %s of %s: abort after %s: %v", id, host, why, err)
	}
	s.bumpSerial(bg)
}

In handleAPIInstanceCreate, add Import *seedImportReq json:"import,omitempty"` beside From; seeded := req.From != nil || req.Import != nil; both together are refused with SD-N36's 400 (one seed door per create); and after createFrom`'s hand-off:

	if req.Import != nil {
		s.createImport(w, r, seedCreate{acct: acct, app: app, principal: principal, message: message, m: m,
			rec: rec, placed: placed, regionFrom: regionFrom}, *req.Import)
		return
	}
  • [ ] Step 4: Run the tests to verify they pass

Run: go test ./internal/console/ -run 'TestImportBegin|TestFrom' -count=1 Expected: PASS.

  • [ ] Step 5: Wire SD-N48 to SD-N50, SD-N62, SD-N67 and commit
git add internal/console/apiinstanceimport.go internal/console/apiinstanceimport_test.go internal/console/apiinstanceseed.go internal/console/apiinstances.go approved_seeded_copy_test.go
git commit -m "console: the import door's begin, with a once-shown verify token (Plan 6 Task 1)

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

Task 2: Upload

Files:

  • Modify: internal/console/apiinstanceimport.go (add handleAPIImportUpload, importGate)
  • Modify: internal/console/authcli.go (route beside DELETE …/instances/{host}, :683)
  • Test: internal/console/apiinstanceimport_test.go

Interfaces:

  • Consumes: s.instanceGate, s.seedScratch.reserve, checkSeedDBConsole (a package var = lease.CheckDBContext), release.PutObjectFrom, provision.SeedUploaded.
  • Produces: PUT /api/cli/apps/{account}/{app}/instances/{host}/import; (s *Server) importGate(w, r) (principal string, acct *Account, app, host string, rec *provision.Record, ok bool) (instance gate, host shape, record read, "is an import" check: SD-N59); 200 {"state":"uploaded"}.

The upload (spec §5.2): the record must be an import in awaiting-upload (SD-N53 otherwise); seedRegionReady(ctx, rec.Region, true) again (the door writes seed state); reserve rec.Seed.Bytes from the pool; extend the read deadline to 60 minutes; spool to <scratch>/<host>.db under http.MaxBytesReader(w, r.Body, bytes+1), hashing as it writes; length or hash differs: 422 SD-N51; CheckDBContext under a 30-minute deadline: deadline 504 SD-N61, failure 422 SD-N52; one create-only PutObjectFrom to seeds.SnapshotKey(host, id) (a leftover object from an earlier failed attempt at the same key is deleted and the put retried once); the spool is removed on every path; SeedUploaded; audit SD-N63.

  • [ ] Step 1: Write the failing tests
func uploadImport(t *testing.T, f fromFixture, host string, body []byte) (int, map[string]any) {
	t.Helper()
	req, _ := http.NewRequest(http.MethodPut, f.url+"/"+host+"/import", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+f.token)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer res.Body.Close()
	var out map[string]any
	_ = json.NewDecoder(res.Body).Decode(&out)
	return res.StatusCode, out
}

func sqliteBytes(t *testing.T) ([]byte, string) {
	t.Helper()
	p := filepath.Join(t.TempDir(), "snap.db")
	db, err := sql.Open("sqlite", p)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := db.Exec(`CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1)`); err != nil {
		t.Fatal(err)
	}
	db.Close()
	b, _ := os.ReadFile(p)
	sum := sha256.Sum256(b)
	return b, hex.EncodeToString(sum[:])
}

func TestImportUploadStoresTheSnapshot(t *testing.T) {
	f := newImportFixture(t)
	body, sha := sqliteBytes(t)
	if code, out := beginImport(t, f, "old.go.tito.io", sha, int64(len(body))); code != http.StatusCreated {
		t.Fatalf("begin = %d %v", code, out)
	}
	if code, out := uploadImport(t, f, "old.go.tito.io", body); code != http.StatusOK || out["state"] != "uploaded" {
		t.Fatalf("upload = %d %v", code, out)
	}
	rec, _ := provision.GetRecord(context.Background(), f.fx.rel, "bab", "titogo", "old.go.tito.io")
	got, _, err := f.data.Get(context.Background(), seeds.SnapshotKey(rec.Host, rec.Seed.ID))
	if err != nil || !bytes.Equal(got, body) || rec.Seed.State != seeds.StateUploaded {
		t.Fatalf("snapshot %d bytes, %v; state %s", len(got), err, rec.Seed.State)
	}
	if ents, _ := os.ReadDir(f.fx.srv.seedScratch.dir()); len(ents) != 0 {
		t.Fatalf("spool left behind: %v", ents)
	}
}

func TestImportUploadRefusesLengthAndHash(t *testing.T) {
	f := newImportFixture(t)
	body, sha := sqliteBytes(t)
	beginImport(t, f, "old.go.tito.io", sha, int64(len(body)))
	long := append(append([]byte{}, body...), 'x')
	if code, out := uploadImport(t, f, "old.go.tito.io", long); code != http.StatusUnprocessableEntity || !strings.Contains(str(t, out, "error"), "does not match the length and sha256") {
		t.Fatalf("too long = %d %v", code, out)
	}
	bad := append([]byte{}, body...)
	bad[len(bad)-1] ^= 0xff
	if code, _ := uploadImport(t, f, "old.go.tito.io", bad); code != http.StatusUnprocessableEntity {
		t.Fatalf("wrong hash = %d", code)
	}
	junk := bytes.Repeat([]byte("j"), 100)
	sum := sha256.Sum256(junk)
	beginImport(t, f, "junk.go.tito.io", hex.EncodeToString(sum[:]), 100)
	if code, out := uploadImport(t, f, "junk.go.tito.io", junk); code != http.StatusUnprocessableEntity || !strings.Contains(str(t, out, "error"), "integrity check") {
		t.Fatalf("not a database = %d %v", code, out)
	}
	rec, _ := provision.GetRecord(context.Background(), f.fx.rel, "bab", "titogo", "old.go.tito.io")
	if rec.Seed.State != seeds.StateAwaitingUpload {
		t.Fatalf("a refused upload moved the state to %s", rec.Seed.State)
	}
}

func TestImportUploadOnlyOnce(t *testing.T) {
	f := newImportFixture(t)
	body, sha := sqliteBytes(t)
	beginImport(t, f, "old.go.tito.io", sha, int64(len(body)))
	uploadImport(t, f, "old.go.tito.io", body)
	if code, out := uploadImport(t, f, "old.go.tito.io", body); code != http.StatusConflict || !strings.Contains(str(t, out, "error"), "only while it waits for one") {
		t.Fatalf("second upload = %d %v", code, out)
	}
}

(imports: bytes, crypto/sha256, database/sql, encoding/hex, os, path/filepath, _ "modernc.org/sqlite" if the console test package does not already register the driver.)

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

Run: go test ./internal/console/ -run 'TestImportUpload' -count=1 Expected: FAIL: 404 for the unregistered route.

  • [ ] Step 3: Write the gate and the upload
// checkSeedDBConsole is lease.CheckDBContext, a seam for the tests.
var checkSeedDBConsole = lease.CheckDBContext

const (
	importUploadReadDeadline = 60 * time.Minute
	importCheckDeadline      = 30 * time.Minute
)

// importGate is every import call's gate: the instance gate (cliRoute has
// already applied the instance role), the host, the record, an import.
func (s *Server) importGate(w http.ResponseWriter, r *http.Request) (string, *Account, string, string, *provision.Record, bool) {
	principal, acct, app, ok := s.instanceGate(w, r)
	if !ok {
		return "", nil, "", "", nil, false
	}
	host := r.PathValue("host")
	if !provision.ValidHostShape(host) {
		http.NotFound(w, r)
		return "", nil, "", "", nil, false
	}
	rec, err := provision.GetRecord(r.Context(), s.cfg.Releases, acct.Sqid, app, host)
	if errors.Is(err, os.ErrNotExist) {
		http.NotFound(w, r)
		return "", nil, "", "", nil, false
	}
	if err != nil {
		log.Printf("console: import %s: read record: %v", host, err)
		jsonError(w, http.StatusBadGateway, "could not store the upload. Try again.")
		return "", nil, "", "", nil, false
	}
	if !rec.Seed.Import() {
		jsonError(w, http.StatusConflict, fmt.Sprintf("%s is not an import.", host))
		return "", nil, "", "", nil, false
	}
	return principal, acct, app, host, rec, true
}

// handleAPIImportUpload is spec §5.2.
func (s *Server) handleAPIImportUpload(w http.ResponseWriter, r *http.Request) {
	principal, acct, app, host, rec, ok := s.importGate(w, r)
	if !ok {
		return
	}
	ctx := r.Context()
	seed := rec.Seed
	if seed.State != seeds.StateAwaitingUpload {
		jsonError(w, http.StatusConflict, fmt.Sprintf("%s is %s. It takes an upload only while it waits for one.", host, seed.State))
		return
	}
	if ref := s.seedRegionReady(ctx, rec.Region, true); ref != nil {
		jsonError(w, ref.Status, ref.Msg)
		return
	}
	release_, ref := s.seedScratch.reserve(acct.Sqid+"/"+app, seed.Bytes)
	if ref != nil {
		jsonError(w, ref.Status, ref.Msg)
		return
	}
	defer release_()
	rc := http.NewResponseController(w)
	if err := rc.SetReadDeadline(time.Now().Add(importUploadReadDeadline)); err != nil && !errors.Is(err, http.ErrNotSupported) {
		log.Printf("console: import %s: set read deadline: %v", host, err)
	}
	defer func() { _ = rc.SetReadDeadline(time.Time{}) }()
	spool := filepath.Join(s.seedScratch.dir(), host+".db")
	defer os.Remove(spool)
	f, err := os.OpenFile(spool, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
	if err != nil {
		jsonError(w, http.StatusInsufficientStorage, "the console has no room for this right now. Try again later.")
		return
	}
	h := sha256.New()
	n, cerr := io.Copy(io.MultiWriter(f, h), http.MaxBytesReader(w, r.Body, seed.Bytes+1))
	ferr := f.Close()
	if cerr != nil && !isMaxBytes(cerr) {
		if ctx.Err() == nil {
			jsonError(w, http.StatusBadGateway, "could not store the upload. Try again.")
		}
		return
	}
	if ferr != nil {
		jsonError(w, http.StatusBadGateway, "could not store the upload. Try again.")
		return
	}
	if isMaxBytes(cerr) || n != seed.Bytes || hex.EncodeToString(h.Sum(nil)) != seed.SHA256 {
		jsonError(w, http.StatusUnprocessableEntity, "the upload does not match the length and sha256 this import began with, so it was not kept.")
		return
	}
	cctx, cancel := context.WithTimeout(ctx, importCheckDeadline)
	defer cancel()
	if err := checkSeedDBConsole(cctx, spool); err != nil {
		if errors.Is(cctx.Err(), context.DeadlineExceeded) {
			jsonError(w, http.StatusGatewayTimeout, "checking the upload took longer than 30 minutes, so it was not kept.")
			return
		}
		jsonError(w, http.StatusUnprocessableEntity, "the upload is not a database that passes an integrity check, so it was not kept.")
		return
	}
	if err := putSnapshot(ctx, s.cfg.SeedData, spool, seeds.SnapshotKey(host, seed.ID)); err != nil {
		log.Printf("console: import %s: put snapshot: %v", host, err)
		jsonError(w, http.StatusBadGateway, "could not store the upload. Try again.")
		return
	}
	if _, err := provision.SetRecordSeed(ctx, s.cfg.Releases, acct.Sqid, app, host, provision.SeedUploaded(seed.ID)); err != nil {
		s.importTransitionFailure(w, host, err)
		return
	}
	s.auditActivity(acct, app, principal, Activity{Action: "import-uploaded", TargetType: "instance", Target: host,
		Detail: fmt.Sprintf("uploaded the snapshot for %s", host)})
	writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateUploaded})
}

func isMaxBytes(err error) bool {
	var mbe *http.MaxBytesError
	return errors.As(err, &mbe)
}

// putSnapshot writes path to key with one create-only PutFrom. An object
// already there is a failed earlier attempt for this same seed id (the key
// is scoped to it); it is deleted and the put retried once.
func putSnapshot(ctx context.Context, data release.Store, path, key string) error {
	put := func() error {
		f, err := os.Open(path)
		if err != nil {
			return err
		}
		defer f.Close()
		st, err := f.Stat()
		if err != nil {
			return err
		}
		_, err = release.PutObjectFrom(ctx, data, key, f, st.Size(), "")
		return err
	}
	err := put()
	if errors.Is(err, release.ErrCASConflict) {
		if derr := data.Delete(ctx, key); derr != nil {
			return derr
		}
		err = put()
	}
	return err
}

// importTransitionFailure answers a seed CAS that did not land.
func (s *Server) importTransitionFailure(w http.ResponseWriter, host string, err error) {
	if errors.Is(err, provision.ErrSeedConflict) {
		jsonError(w, http.StatusConflict, fmt.Sprintf("%s changed while this request was made. Read its state with carlos instances list and try again.", host))
		return
	}
	log.Printf("console: import %s: record: %v", host, err)
	jsonError(w, http.StatusBadGateway, "could not store the upload. Try again.")
}

Add "context", "crypto/sha256", "encoding/hex", "io", "os", "path/filepath" and "github.com/carlosframework/platform/internal/lease" to the file's imports. Add the census lines for os.Open(path) in putSnapshot (and change Plan 5's uploadSnapshot to reuse putSnapshot for its upload, keeping its hashing).

In authcli.go, after the DELETE …/instances/{host} route:

	// The import door (seeded-instances spec §5): the instance role, like
	// the create door that begins it.
	s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/import", svcrole.Allow(svcrole.Instance), s.handleAPIImportUpload)
  • [ ] Step 4: Run the tests to verify they pass

Run: go test ./internal/console/ -run 'TestImport' -count=1 Expected: PASS.

  • [ ] Step 5: Wire SD-N51 to SD-N53, SD-N59 to SD-N61, SD-N63 and commit
git add internal/console/ approved_seeded_copy_test.go
git commit -m "console: the import upload, spooled, hashed, checked, then one PutFrom (Plan 6 Task 2)

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

Task 3: Stopped and commit

Files:

  • Modify: internal/console/apiinstanceimport.go, internal/console/authcli.go
  • Test: internal/console/apiinstanceimport_test.go

Interfaces:

  • Produces: POST …/instances/{host}/import/stopped (200 {"state":"ready"}), POST …/instances/{host}/import/commit (200 {"state":"committed"}); handlers handleAPIImportStopped, handleAPIImportCommit.

Stopped (spec §5.3): uploaded → ready (SD-N54 otherwise), seedRegionReady again, audit SD-N64, bumpSerial (the box swaps on its next pass). Commit (spec §5.4): placed → committed; an import before placed (awaiting-upload, uploaded, ready, swapped) answers 409 SD-N55; committed already answers 200 (a retried commit); refused, aborted, rolled-back answer 409 SD-N115; audit SD-N65; bumpSerial (the box clears the gate).

  • [ ] Step 1: Write the failing tests
func postImport(t *testing.T, f fromFixture, host, verb string) (int, map[string]any) {
	t.Helper()
	code, _, raw := rawReq(t, http.MethodPost, f.url+"/"+host+"/import/"+verb, f.token, "")
	var out map[string]any
	_ = json.Unmarshal([]byte(raw), &out)
	return code, out
}

func setSeedState(t *testing.T, f fromFixture, host string, steps ...func(string) func(*provision.Record) error) {
	t.Helper()
	rec, _ := provision.GetRecord(context.Background(), f.fx.rel, "bab", "titogo", host)
	for _, step := range steps {
		if _, err := provision.SetRecordSeed(context.Background(), f.fx.rel, "bab", "titogo", host, step(rec.Seed.ID)); err != nil {
			t.Fatal(err)
		}
	}
}

func TestImportStoppedAndCommit(t *testing.T) {
	f := newImportFixture(t)
	body, sha := sqliteBytes(t)
	beginImport(t, f, "old.go.tito.io", sha, int64(len(body)))
	if code, out := postImport(t, f, "old.go.tito.io", "stopped"); code != http.StatusConflict || !strings.Contains(str(t, out, "error"), "after its upload") {
		t.Fatalf("stopped before upload = %d %v", code, out)
	}
	uploadImport(t, f, "old.go.tito.io", body)
	before := serial(t, f.fx)
	if code, out := postImport(t, f, "old.go.tito.io", "stopped"); code != http.StatusOK || out["state"] != "ready" {
		t.Fatalf("stopped = %d %v", code, out)
	}
	if serial(t, f.fx) == before {
		t.Fatal("stopped did not bump the serial")
	}
	if code, out := postImport(t, f, "old.go.tito.io", "commit"); code != http.StatusConflict || !strings.Contains(str(t, out, "error"), "nothing has been verified yet") {
		t.Fatalf("commit before placed = %d %v", code, out)
	}
	// The box swaps and places (Plans 4 and 7); the test plays the box.
	setSeedState(t, f, "old.go.tito.io", provision.SeedSwapped, provision.SeedPlace)
	if code, out := postImport(t, f, "old.go.tito.io", "commit"); code != http.StatusOK || out["state"] != "committed" {
		t.Fatalf("commit = %d %v", code, out)
	}
	if code, _ := postImport(t, f, "old.go.tito.io", "commit"); code != http.StatusOK {
		t.Fatalf("a retried commit = %d, want 200", code)
	}
}

(serial(t, fx) is the helper apisystemlayout_test.go uses.)

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

Run: go test ./internal/console/ -run TestImportStoppedAndCommit -count=1 Expected: FAIL: 404.

  • [ ] Step 3: Write the two handlers and routes
// handleAPIImportStopped is the app saying its own process is down (spec
// §5.3): uploaded to ready, and a serial bump so the box swaps.
func (s *Server) handleAPIImportStopped(w http.ResponseWriter, r *http.Request) {
	principal, acct, app, host, rec, ok := s.importGate(w, r)
	if !ok {
		return
	}
	if rec.Seed.State != seeds.StateUploaded {
		jsonError(w, http.StatusConflict, fmt.Sprintf("%s is %s. Mark it stopped once, after its upload.", host, rec.Seed.State))
		return
	}
	if ref := s.seedRegionReady(r.Context(), rec.Region, true); ref != nil {
		jsonError(w, ref.Status, ref.Msg)
		return
	}
	if _, err := provision.SetRecordSeed(r.Context(), s.cfg.Releases, acct.Sqid, app, host, provision.SeedStopped(rec.Seed.ID)); err != nil {
		s.importTransitionFailure(w, host, err)
		return
	}
	s.auditActivity(acct, app, principal, Activity{Action: "import-stopped", TargetType: "instance", Target: host,
		Detail: fmt.Sprintf("marked the old process of %s stopped", host)})
	s.bumpSerial(r.Context())
	writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateReady})
}

// handleAPIImportCommit is the app's verdict that the instance serves
// (spec §5.4): placed to committed; the box then clears the gate.
func (s *Server) handleAPIImportCommit(w http.ResponseWriter, r *http.Request) {
	principal, acct, app, host, rec, ok := s.importGate(w, r)
	if !ok {
		return
	}
	switch rec.Seed.State {
	case seeds.StateCommitted:
		writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateCommitted})
		return
	case seeds.StatePlaced:
	case seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateReady, seeds.StateSwapped:
		jsonError(w, http.StatusConflict, "nothing has been verified yet. Send a request with the verify token first, then commit.")
		return
	default:
		jsonError(w, http.StatusConflict, fmt.Sprintf("%s is %s, so there is nothing to commit.", host, rec.Seed.State))
		return
	}
	if _, err := provision.SetRecordSeed(r.Context(), s.cfg.Releases, acct.Sqid, app, host, provision.SeedCommit(rec.Seed.ID)); err != nil {
		s.importTransitionFailure(w, host, err)
		return
	}
	s.auditActivity(acct, app, principal, Activity{Action: "import-committed", TargetType: "instance", Target: host,
		Detail: fmt.Sprintf("committed the import of %s", host)})
	s.bumpSerial(r.Context())
	writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateCommitted})
}

Routes beside the upload route:

	s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped", svcrole.Allow(svcrole.Instance), s.handleAPIImportStopped)
	s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit", svcrole.Allow(svcrole.Instance), s.handleAPIImportCommit)
  • [ ] Step 4: Run the tests to verify they pass

Run: go test ./internal/console/ -run 'TestImport' -count=1 Expected: PASS.

  • [ ] Step 5: Wire SD-N54, SD-N55, SD-N64, SD-N65, SD-N115 and commit
git add internal/console/ approved_seeded_copy_test.go
git commit -m "console: the import's stopped and commit (Plan 6 Task 3)

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

Task 4: The console's finish

Files:

  • Create: internal/console/seedfinish.go, internal/console/seedfinish_test.go

Interfaces:

  • Consumes: objstore.GetIn, objstore.TeardownIn(ctx, s, p, sc, acct, app, env), storeTeardownTimeout, provision.GetRecordETag, provision.RecordKey, release.DeleteIfMatch.
  • Produces: (s *Server) finishSeed(ctx context.Context, sqid, app, host, seedID string) error: the store teardown, then the record delete. Plan 8's sweep calls it too, with the record's account sqid.

Spec §5.6 "The console finishes" (codex R6-1): for a record whose seed is rolled-back:

  1. Read the store record for the record's environment. When its Declared.Seed is this ID and it holds any estate (Grant or Provisioned), run objstore.TeardownIn; a declared-only record with this seed is deleted with the teardown too (TeardownIn handles a partial store and deletes its record last, walk.go:547, :728). A store with another seed or none is never touched.
  2. Delete the record with release.DeleteIfMatch on the etag of the read that saw this seed ID in rolled-back. A record that names another ID, or another state, is left alone.

The record holds the hostname until step 2, so a re-import of the same host never meets the leftover store §4 would refuse. Both steps are idempotent; a crash between them resumes at step 1, which then finds no store.

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

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

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

func rolledBackRecord(t *testing.T, fx *consoleFixture, host, env string) string {
	t.Helper()
	id, _ := seeds.NewID()
	rec := provision.Record{Schema: 1, Host: host, Slug: "canary", App: "titogo", Account: "bab", Channel: "stable",
		Environment: env, CreatedAt: time.Now().UTC(),
		Seed: &provision.SeedSource{ID: id, Kind: seeds.KindImport, State: seeds.StateRolledBack, By: "t", At: time.Now().UTC()}}
	if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil {
		t.Fatal(err)
	}
	return id
}

func TestFinishSeedDeletesTheRecordLast(t *testing.T) {
	fx := newConsoleFixture(t)
	id := rolledBackRecord(t, fx, "canary.go.tito.io", "canary")
	teardowns := useFakeStoreTeardown(t, fx) // counts objstore.TeardownIn calls by env (seam: storeTeardown var in seedfinish.go)
	putStoreRecord(t, fx, "titogo", "canary", `{"schema":1,"declared":{"env_prefix":"S3","at":"2026-10-08T09:00:00Z","by":"x","seed":"`+id+`"},"grant":{"backend":"aws","bucket":"b","source":"account-approval","at":"2026-10-08T09:00:00Z","by":"x"}}`)
	if err := fx.srv.finishSeed(context.Background(), "bab", "titogo", "canary.go.tito.io", id); err != nil {
		t.Fatal(err)
	}
	if (*teardowns)["canary"] != 1 {
		t.Fatalf("teardowns = %v, want the seed's store torn down once", *teardowns)
	}
	if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
		t.Fatalf("record after finish: %v, want gone", err)
	}
}

func TestFinishSeedIsScopedToItsID(t *testing.T) {
	fx := newConsoleFixture(t)
	id := rolledBackRecord(t, fx, "canary.go.tito.io", "canary")
	teardowns := useFakeStoreTeardown(t, fx)
	putStoreRecord(t, fx, "titogo", "canary", `{"schema":1,"declared":{"env_prefix":"S3","at":"2026-10-08T09:00:00Z","by":"x","seed":"sd_bbbbbbbbbbbbbbbbbbbbbbbbbb"},"grant":{"backend":"aws","bucket":"b","source":"account-approval","at":"2026-10-08T09:00:00Z","by":"x"}}`)
	if err := fx.srv.finishSeed(context.Background(), "bab", "titogo", "canary.go.tito.io", id); err != nil {
		t.Fatal(err)
	}
	if len(*teardowns) != 0 {
		t.Fatalf("another seed's store was torn down: %v", *teardowns)
	}
	// A record naming another seed is left alone.
	other := rolledBackRecord(t, fx, "other.go.tito.io", "other")
	if err := fx.srv.finishSeed(context.Background(), "bab", "titogo", "other.go.tito.io", id); err != nil {
		t.Fatal(err)
	}
	if rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "other.go.tito.io"); err != nil || rec.Seed.ID != other {
		t.Fatalf("a record naming another seed: %+v, %v", rec, err)
	}
}

useFakeStoreTeardown replaces seedfinish.go's package var storeTeardown = objstore.TeardownIn with a counter keyed by env that also deletes the store record (as the real teardown does), and restores it on cleanup.

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

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

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

import (
	"context"
	"errors"
	"fmt"
	"os"

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

// storeTeardown is objstore.TeardownIn, a seam for the tests.
var storeTeardown = objstore.TeardownIn

// finishSeed is the console's last word on an abandoned seed (spec §5.6,
// codex R6-1): tear down the store this seed created, then delete the
// record, only while it still names this seed and says rolled-back. The
// record goes last: until then it holds the hostname.
func (s *Server) finishSeed(ctx context.Context, sqid, app, host, seedID string) error {
	rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, sqid, app, host)
	if errors.Is(err, os.ErrNotExist) {
		return nil
	}
	if err != nil {
		return err
	}
	if rec.Seed == nil || rec.Seed.ID != seedID || rec.Seed.State != seeds.StateRolledBack {
		return nil
	}
	store, _, err := objstore.GetIn(ctx, s.cfg.Releases, sqid, app, rec.Environment)
	if err != nil {
		return fmt.Errorf("read the store of %s: %w", host, err)
	}
	if store != nil && store.Declared != nil && store.Declared.Seed == seedID {
		if s.cfg.StoreProvisioner == nil {
			return errors.New("this console has no storage backend to tear the seed's store down with")
		}
		tctx, cancel := context.WithTimeout(ctx, storeTeardownTimeout)
		defer cancel()
		if err := storeTeardown(tctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreScanner, sqid, app, rec.Environment); err != nil {
			return fmt.Errorf("tear down the store of %s: %w", host, err)
		}
	}
	if err := release.DeleteIfMatch(ctx, s.cfg.Releases, provision.RecordKey(sqid, app, host), etag); err != nil && !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("delete the record of %s: %w", host, err)
	}
	s.bumpSerial(ctx)
	return nil
}

A store whose Declared.Seed is this seed's but which holds no estate still goes through TeardownIn, which deletes its record; a test fixture with a nil StoreProvisioner and such a store gets the error above, which the sweep logs and retries.

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

Run: go test ./internal/console/ -run TestFinishSeed -count=1 Expected: PASS.

  • [ ] Step 5: Commit
git add internal/console/seedfinish.go internal/console/seedfinish_test.go
git commit -m "console: finishSeed, the seed-guarded store teardown and the record delete last (Plan 6 Task 4)

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

Task 5: Abort, and the delete refusal

Files:

  • Modify: internal/console/apiinstanceimport.go (handleAPIImportAbort, refuseImportDelete, activeRunOpen), internal/console/authcli.go, internal/console/apiinstances.go:1746 (one call in handleAPIInstanceDelete)
  • Test: internal/console/apiinstanceimport_test.go

Interfaces:

  • Consumes: provision.Record.ActiveRun with RunState.State (run series); provision.SeedAbort, provision.SeedRolledBackBeforeSwap; finishSeed; s.cfg.SeedData.
  • Produces: POST …/instances/{host}/import/abort (200 {"state":"aborted"} or {"state":"rolled-back"} when the console finished it); (s *Server) refuseImportDelete(w, r, acct *Account, app, host string) (answered, finished bool); activeRunOpen(rec *provision.Record) bool.

Abort (spec §5.6): refused after committed (SD-N56); refused while a run is open (SD-N58), checked inside the same CAS as the transition; aborted already: 200; rolled-back already: finishSeed, then 200. Otherwise CAS to aborted, audit SD-N66, bumpSerial. Delete of an import that is not committed (spec §5.6, codex R2-7): awaiting-upload, uploaded, refused → CAS straight to rolled-back, delete seeds/<host>/<id>/snapshot.db, finishSeed, answer as the ordinary delete does; rolled-back → finishSeed; any other uncommitted state → 409 SD-N57.

  • [ ] Step 1: Write the failing tests
func TestImportCommitAbortRace(t *testing.T) {
	f := newImportFixture(t)
	body, sha := sqliteBytes(t)
	beginImport(t, f, "old.go.tito.io", sha, int64(len(body)))
	uploadImport(t, f, "old.go.tito.io", body)
	postImport(t, f, "old.go.tito.io", "stopped")
	setSeedState(t, f, "old.go.tito.io", provision.SeedSwapped, provision.SeedPlace)
	codes := make(chan int, 2)
	for _, verb := range []string{"commit", "abort"} {
		go func(verb string) {
			code, _ := postImport(t, f, "old.go.tito.io", verb)
			codes <- code
		}(verb)
	}
	a, b := <-codes, <-codes
	rec, _ := provision.GetRecord(context.Background(), f.fx.rel, "bab", "titogo", "old.go.tito.io")
	ok := (rec.Seed.State == seeds.StateCommitted) != (rec.Seed.State == seeds.StateAborted)
	if !ok || (a != http.StatusOK && b != http.StatusOK) || a == http.StatusBadGateway || b == http.StatusBadGateway {
		t.Fatalf("codes %d %d, final state %s: want exactly one winner", a, b, rec.Seed.State)
	}
}

func TestImportAbortRefusals(t *testing.T) {
	f := newImportFixture(t)
	body, sha := sqliteBytes(t)
	beginImport(t, f, "old.go.tito.io", sha, int64(len(body)))
	setRun(t, f, "old.go.tito.io", "picked") // writes Record.ActiveRun through the run series' transition
	if code, out := postImport(t, f, "old.go.tito.io", "abort"); code != http.StatusConflict || !strings.Contains(str(t, out, "error"), "carlos run is busy") {
		t.Fatalf("abort with a run open = %d %v", code, out)
	}
	setRun(t, f, "old.go.tito.io", "done")
	if code, out := postImport(t, f, "old.go.tito.io", "abort"); code != http.StatusOK || out["state"] != "aborted" {
		t.Fatalf("abort = %d %v", code, out)
	}
	if code, _ := postImport(t, f, "old.go.tito.io", "abort"); code != http.StatusOK {
		t.Fatalf("abort again = %d, want 200", code)
	}
}

func TestImportDeleteRefusedUntilCommittedOrUnswapped(t *testing.T) {
	f := newImportFixture(t)
	body, sha := sqliteBytes(t)
	beginImport(t, f, "early.go.tito.io", sha, int64(len(body)))
	if code, _, _ := rawReq(t, http.MethodDelete, f.url+"/early.go.tito.io", f.token, ""); code != http.StatusOK {
		t.Fatalf("delete before upload = %d, want 200", code)
	}
	if _, err := provision.GetRecord(context.Background(), f.fx.rel, "bab", "titogo", "early.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
		t.Fatalf("record after an early delete: %v", err)
	}
	beginImport(t, f, "late.go.tito.io", sha, int64(len(body)))
	uploadImport(t, f, "late.go.tito.io", body)
	postImport(t, f, "late.go.tito.io", "stopped")
	setSeedState(t, f, "late.go.tito.io", provision.SeedSwapped)
	code, _, raw := rawReq(t, http.MethodDelete, f.url+"/late.go.tito.io", f.token, "")
	if code != http.StatusConflict || !strings.Contains(raw, "Abort the import instead") {
		t.Fatalf("delete after the swap = %d %s", code, raw)
	}
}

setRun writes the run series' field directly, through the public etag-guarded writer (an ordinary writer may change ActiveRun unless the run series protects it; if it does, apply its own transition instead):

func setRun(t *testing.T, f fromFixture, host, state string) {
	t.Helper()
	rec, etag, err := provision.GetRecordETag(context.Background(), f.fx.rel, "bab", "titogo", host)
	if err != nil {
		t.Fatal(err)
	}
	rec.ActiveRun = &provision.RunState{ID: "run_1", State: state, Deadline: time.Now().Add(time.Hour)}
	if err := provision.UpdateRecord(context.Background(), f.fx.rel, *rec, etag); err != nil {
		t.Fatal(err)
	}
}
  • [ ] Step 2: Run the tests to verify they fail

Run: go test ./internal/console/ -run 'TestImportCommitAbortRace|TestImportAbortRefusals|TestImportDelete' -count=1 Expected: FAIL: 404 for abort; the early delete leaves the record or answers with a claim-free retire.

  • [ ] Step 3: Write abort, the delete refusal and the route
// activeRunOpen is the run series' record field read for abort's rule
// (spec §5.6): a run that is not done, cancelled or expired.
func activeRunOpen(rec *provision.Record) bool {
	if rec.ActiveRun == nil {
		return false
	}
	switch rec.ActiveRun.State {
	case "done", "cancelled", "expired":
		return false
	}
	return true
}

var errRunOpen = errors.New("console: a run is open on the instance")

// handleAPIImportAbort abandons an import before commit (spec §5.6).
func (s *Server) handleAPIImportAbort(w http.ResponseWriter, r *http.Request) {
	principal, acct, app, host, rec, ok := s.importGate(w, r)
	if !ok {
		return
	}
	ctx := r.Context()
	id := rec.Seed.ID
	switch rec.Seed.State {
	case seeds.StateCommitted:
		jsonError(w, http.StatusConflict, fmt.Sprintf("%s is committed. Delete it with carlos instances delete instead.", host))
		return
	case seeds.StateAborted:
		writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateAborted})
		return
	case seeds.StateRolledBack:
		if err := s.finishSeed(ctx, acct.Sqid, app, host, id); err != nil {
			log.Printf("console: import %s: finish: %v", host, err)
		}
		writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateRolledBack})
		return
	}
	_, err := provision.SetRecordSeed(ctx, s.cfg.Releases, acct.Sqid, app, host, func(rec *provision.Record) error {
		if activeRunOpen(rec) {
			return errRunOpen
		}
		return provision.SeedAbort(id)(rec)
	})
	switch {
	case errors.Is(err, errRunOpen):
		jsonError(w, http.StatusConflict, fmt.Sprintf("carlos run is busy on %s. Wait for it to finish, then abort.", host))
		return
	case errors.Is(err, provision.ErrSeedConflict):
		// The commit won the race, or the state moved: answer from the record.
		if cur, gerr := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); gerr == nil && cur.Seed != nil && cur.Seed.State == seeds.StateCommitted {
			jsonError(w, http.StatusConflict, fmt.Sprintf("%s is committed. Delete it with carlos instances delete instead.", host))
			return
		}
		s.importTransitionFailure(w, host, err)
		return
	case err != nil:
		s.importTransitionFailure(w, host, err)
		return
	}
	s.auditActivity(acct, app, principal, Activity{Action: "import-aborted", TargetType: "instance", Target: host,
		Detail: fmt.Sprintf("aborted the import of %s", host)})
	s.bumpSerial(ctx)
	writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateAborted})
}

// refuseImportDelete is the delete door's import rule (spec §5.6, codex
// R2-7). answered: the delete must not go the ordinary way, and either this
// function has answered (a refusal) or, with finished, the import was
// finished here and the caller writes the ordinary delete's audit row and
// answer. A committed import, a --from seed and an unseeded record are
// (false, false).
func (s *Server) refuseImportDelete(w http.ResponseWriter, r *http.Request, acct *Account, app, host string) (answered, finished bool) {
	ctx := r.Context()
	rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
	if err != nil || !rec.Seed.Import() || rec.Seed.State == seeds.StateCommitted {
		return false, false
	}
	id := rec.Seed.ID
	switch rec.Seed.State {
	case seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused:
		if _, err := provision.SetRecordSeed(ctx, s.cfg.Releases, acct.Sqid, app, host, provision.SeedRolledBackBeforeSwap(id)); err != nil {
			s.importTransitionFailure(w, host, err)
			return true, false
		}
		if s.cfg.SeedData != nil {
			if err := s.cfg.SeedData.Delete(ctx, seeds.SnapshotKey(host, id)); err != nil && !errors.Is(err, os.ErrNotExist) {
				log.Printf("console: import %s: delete snapshot: %v", host, err)
			}
		}
		fallthrough
	case seeds.StateRolledBack:
		if err := s.finishSeed(ctx, acct.Sqid, app, host, id); err != nil {
			s.importTransitionFailure(w, host, err)
			return true, false
		}
		return true, true
	}
	jsonError(w, http.StatusConflict, fmt.Sprintf("%s is an import that is not committed. Abort the import instead.", host))
	return true, false
}

In handleAPIInstanceDelete, after the host-shape check and before retireInstanceRecord:

	if answered, finished := s.refuseImportDelete(w, r, acct, app, host); answered {
		if finished {
			s.auditActivity(acct, app, principal, Activity{
				Action: "instance-delete", TargetType: "instance", Target: host, Message: message,
				Detail: "deleted instance " + host + " (cli)",
			})
			s.bumpSerial(ctx)
			writeJSON(w, http.StatusOK, map[string]any{"result": "deleted"})
		}
		return
	}

The Detail and the body are main's own (apiinstances.go:1812-1820), reused byte for byte, so the legacy-exempt file gains no sentence.

Route:

	s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort", svcrole.Allow(svcrole.Instance), s.handleAPIImportAbort)
  • [ ] Step 4: Run the tests to verify they pass

Run: go test ./internal/console/ -run 'TestImport|TestAPIInstanceDelete' -count=1 && go test . -run TestMoves -count=1 Expected: PASS; TestMovesLegacyExemptionsAreLegacy passes (the delete handler gained a call and reused main's own detail string).

  • [ ] Step 5: Wire SD-N56 to SD-N58, SD-N66 and commit
git add internal/console/ approved_seeded_copy_test.go
git commit -m "console: the import's abort and the delete door's import rule (Plan 6 Task 5)

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

Task 6: carlos instances import

Files:

  • Create: instances_import.go, instances_import_test.go
  • Modify: cli.go:362 (runInstances: case "import"), apimode.go (client calls)
  • Modify: docs/site/cli.md (under ### carlos instances)

Interfaces:

  • Consumes: requireAPISession, resolvedAccount, resolvedApp, consoleOption, appFlag, printUsing, sess.instanceGet, sess.postJSON, s.c.doStream, apiErrorFromBody, seedCell.
  • Produces: runInstancesImport(args []string); (s *apiSession) importBegin(ctx, a apiAcct, app, host, environment, sha string, bytes int64, replaces string) (apiInstanceRecord, string, error); (s *apiSession) importUpload(ctx, a apiAcct, app, host string, f *os.File, size int64) error; (s *apiSession) importVerb(ctx, a apiAcct, app, host, verb string) error; verifyImport(ctx context.Context, client *http.Client, host, path, token string) (int, error).

The command (spec §5): hash the file and print SD-N68; begin (keep the token in memory only); upload; stopped and SD-N69; poll the record every 3 seconds for up to 10 minutes, printing SD-N70 once: swapped or placed continues, refused prints SD-N78 and exits 1, the deadline prints SD-N79 and exits 1; print SD-N71. Without --verify: print SD-N72 and SD-N73 and exit 0. With --verify <path>: GET https://<host><path> with Carlos-Import-Verify: <token>, retrying a 503 (the gate's Retry-After while the instance wakes) up to 20 times; a 2xx prints SD-N74, waits (SD-N90) up to 5 minutes for placed, commits, prints SD-N75; anything else prints SD-N76, aborts, prints SD-N77, exits 1. carlos instances import commit|abort --app --host call the verb alone and print SD-N75 or SD-N77.

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

import (
	"context"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"
)

func TestImportVerifyRetriesThroughTheWake(t *testing.T) {
	var calls atomic.Int32
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Carlos-Import-Verify") != "tok" {
			w.WriteHeader(http.StatusForbidden)
			return
		}
		if calls.Add(1) < 3 {
			w.Header().Set("Retry-After", "0")
			w.WriteHeader(http.StatusServiceUnavailable)
			return
		}
		w.WriteHeader(http.StatusNoContent)
	}))
	defer ts.Close()
	code, err := verifyImportURL(context.Background(), ts.Client(), ts.URL+"/up", "tok")
	if err != nil || code != http.StatusNoContent || calls.Load() != 3 {
		t.Fatalf("code %d err %v calls %d", code, err, calls.Load())
	}
}

func TestImportVerifyStopsOnAFailure(t *testing.T) {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }))
	defer ts.Close()
	code, err := verifyImportURL(context.Background(), ts.Client(), ts.URL+"/up", "tok")
	if err != nil || code != http.StatusInternalServerError {
		t.Fatalf("code %d err %v", code, err)
	}
}

func TestImportClientCallsTheDoor(t *testing.T) {
	var seen []string
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seen = append(seen, r.Method+" "+r.URL.Path)
		switch {
		case r.Method == http.MethodPost && r.URL.Path == "/api/cli/apps/bab/titogo/instances":
			w.WriteHeader(http.StatusCreated)
			w.Write([]byte(`{"host":"old.go.tito.io","verify_token":"tok","seed":{"kind":"import","state":"awaiting-upload"}}`))
		default:
			w.WriteHeader(http.StatusOK)
			w.Write([]byte(`{"state":"ok"}`))
		}
	}))
	defer ts.Close()
	s := newTestSession(ts.URL)
	a := apiAcct{Seg: "bab"}
	_, tok, err := s.importBegin(context.Background(), a, "titogo", "old.go.tito.io", "", "aa", 3, "127.0.0.1:8401")
	if err != nil || tok != "tok" {
		t.Fatalf("begin: %q %v", tok, err)
	}
	for _, verb := range []string{"stopped", "commit", "abort"} {
		if err := s.importVerb(context.Background(), a, "titogo", "old.go.tito.io", verb); err != nil {
			t.Fatalf("%s: %v", verb, err)
		}
	}
	want := []string{"POST /api/cli/apps/bab/titogo/instances", "POST /api/cli/apps/bab/titogo/instances/old.go.tito.io/import/stopped",
		"POST /api/cli/apps/bab/titogo/instances/old.go.tito.io/import/commit", "POST /api/cli/apps/bab/titogo/instances/old.go.tito.io/import/abort"}
	if len(seen) != len(want) {
		t.Fatalf("calls = %q", seen)
	}
	for i := range want {
		if seen[i] != want[i] {
			t.Fatalf("call %d = %q, want %q", i, seen[i], want[i])
		}
	}
}

(verifyImport builds "https://" + host + path and calls verifyImportURL; the tests call the URL form.)

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

Run: go test . -run 'TestImportVerify|TestImportClient' -count=1 Expected: FAIL to compile.

  • [ ] Step 3: Write instances_import.go and the client calls
package main

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

const (
	importSwapWait   = 10 * time.Minute
	importPlaceWait  = 5 * time.Minute
	importPoll       = 3 * time.Second
	importVerifyTries = 20
)

// runInstancesImport is `carlos instances import` (seeded-instances spec §5).
func runInstancesImport(args []string) {
	if len(args) > 0 && (args[0] == "commit" || args[0] == "abort") {
		runInstancesImportVerb(args[0], args[1:])
		return
	}
	fs := flag.NewFlagSet("instances import", flag.ExitOnError)
	app := appFlag(fs, "app the instance belongs to")
	host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain")
	file := fs.String("file", "", "the snapshot to import: a consistent SQLite file taken after the old process stopped")
	replaces := fs.String("replaces", "", "the address of the route this import takes over, as host:port")
	environment := fs.String("environment", "", "named config bundle this route binds to (default: the app's default bundle) — carlos env environments lists them")
	verify := fs.String("verify", "", "a path to request with the verify token once swapped. A 2xx commits the import, anything else aborts it")
	consoleFlag := consoleOption(fs, "console to create through (default: the only logged-in one)")
	acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
	acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
	fs.Usage = flagUsage(fs, "")
	fs.Parse(args)
	if *host == "" || *file == "" || *replaces == "" {
		fs.Usage()
		os.Exit(2)
	}
	ctx := context.Background()
	sess := requireAPISession("import", *consoleFlag)
	printUsing(stdout, sess)
	acct := resolvedAccount(sess, acctRaw, acctIDRaw)
	app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
	f, err := os.Open(*file)
	if err != nil {
		log.Fatalf("instances import: %v", err)
	}
	defer f.Close()
	h := sha256.New()
	size, err := io.Copy(h, f)
	if err != nil {
		log.Fatalf("instances import: %v", err)
	}
	sha := hex.EncodeToString(h.Sum(nil))
	fmt.Fprintln(stdout, fmt.Sprintf("uploading %s (%s)", *file, humanBytes(&size)))
	_, token, err := sess.importBegin(ctx, acct, app2, *host, strings.TrimSpace(*environment), sha, size, strings.TrimSpace(*replaces))
	if err != nil {
		log.Fatalf("instances import: %v", err)
	}
	if _, err := f.Seek(0, io.SeekStart); err != nil {
		log.Fatalf("instances import: %v", err)
	}
	if err := sess.importUpload(ctx, acct, app2, *host, f, size); err != nil {
		log.Fatalf("instances import: %v", err)
	}
	if err := sess.importVerb(ctx, acct, app2, *host, "stopped"); err != nil {
		log.Fatalf("instances import: %v", err)
	}
	fmt.Fprintln(stdout, fmt.Sprintf("uploaded. The old process of %s is marked stopped, and the box swaps it on its next pass.", *host))
	fmt.Fprintln(stdout, fmt.Sprintf("waiting for the box to swap %s", *host))
	if !waitImport(ctx, sess, acct, app2, *host, importSwapWait, "swapped", "placed") {
		fmt.Fprintln(stdout, fmt.Sprintf("the box has not swapped %s after %d minutes. Check carlos instances list, then commit or abort.", *host, int(importSwapWait/time.Minute)))
		os.Exit(1)
	}
	fmt.Fprintln(stdout, fmt.Sprintf("swapped. Until it is committed, %s answers only requests that carry the verify token.", *host))
	if *verify == "" {
		fmt.Fprintln(stdout, fmt.Sprintf("verify token: %s. It is shown only this once.", token))
		fmt.Fprintln(stdout, fmt.Sprintf("commit with: carlos instances import commit --app %s --host %s. Abort with: carlos instances import abort --app %s --host %s.", app2, *host, app2, *host))
		return
	}
	code, err := verifyImport(ctx, &http.Client{Timeout: 2 * time.Minute}, *host, *verify, token)
	if err == nil && code >= 200 && code < 300 {
		fmt.Fprintln(stdout, fmt.Sprintf("verify passed (%d). Committing.", code))
		fmt.Fprintln(stdout, "waiting for the box to finish placing the snapshot")
		if !waitImport(ctx, sess, acct, app2, *host, importPlaceWait, "placed", "committed") {
			fmt.Fprintln(stdout, fmt.Sprintf("commit with: carlos instances import commit --app %s --host %s. Abort with: carlos instances import abort --app %s --host %s.", app2, *host, app2, *host))
			os.Exit(1)
		}
		if err := sess.importVerb(ctx, acct, app2, *host, "commit"); err != nil {
			log.Fatalf("instances import: %v", err)
		}
		fmt.Fprintln(stdout, fmt.Sprintf("committed. %s is served by the platform now.", *host))
		return
	}
	status := strconv.Itoa(code)
	if err != nil {
		status = err.Error()
	}
	fmt.Fprintln(stdout, fmt.Sprintf("verify failed (%s). Aborting the import.", status))
	if err := sess.importVerb(ctx, acct, app2, *host, "abort"); err != nil {
		log.Fatalf("instances import: %v", err)
	}
	fmt.Fprintln(stdout, fmt.Sprintf("aborted. The box puts the old route of %s back on its next pass.", *host))
	os.Exit(1)
}

// waitImport polls the record until its seed is in one of states, or the
// import was refused (printed, exit 1), or wait runs out (false).
func waitImport(ctx context.Context, sess *apiSession, acct apiAcct, app, host string, wait time.Duration, states ...string) bool {
	deadline := time.Now().Add(wait)
	for time.Now().Before(deadline) {
		rec, found, err := sess.instanceGet(ctx, acct, app, host)
		if err == nil && found && rec.Seed != nil {
			if rec.Seed.State == "refused" {
				fmt.Fprintln(stdout, fmt.Sprintf("the box refused the import: %s", rec.Seed.Reason))
				os.Exit(1)
			}
			for _, s := range states {
				if rec.Seed.State == s {
					return true
				}
			}
		}
		time.Sleep(importPoll)
	}
	return false
}

func runInstancesImportVerb(verb string, args []string) {
	fs := flag.NewFlagSet("instances import "+verb, flag.ExitOnError)
	app := appFlag(fs, "app the instance belongs to")
	host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain")
	consoleFlag := consoleOption(fs, "console to create through (default: the only logged-in one)")
	acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
	acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
	fs.Usage = flagUsage(fs, "")
	fs.Parse(args)
	if *host == "" {
		fs.Usage()
		os.Exit(2)
	}
	sess := requireAPISession("import", *consoleFlag)
	acct := resolvedAccount(sess, acctRaw, acctIDRaw)
	app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
	if err := sess.importVerb(context.Background(), acct, app2, *host, verb); err != nil {
		log.Fatalf("instances import %s: %v", verb, err)
	}
	if verb == "commit" {
		fmt.Fprintln(stdout, fmt.Sprintf("committed. %s is served by the platform now.", *host))
		return
	}
	fmt.Fprintln(stdout, fmt.Sprintf("aborted. The box puts the old route of %s back on its next pass.", *host))
}

// verifyImport is the verify request through the edge (spec §5.5): the
// gate answers 503 with Retry-After while the instance wakes, so a 503 is
// retried; any other answer is the verdict.
func verifyImport(ctx context.Context, client *http.Client, host, path, token string) (int, error) {
	if !strings.HasPrefix(path, "/") {
		path = "/" + path
	}
	return verifyImportURL(ctx, client, "https://"+host+path, token)
}

func verifyImportURL(ctx context.Context, client *http.Client, url, token string) (int, error) {
	for try := 0; ; try++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
		if err != nil {
			return 0, err
		}
		req.Header.Set("Carlos-Import-Verify", token)
		res, err := client.Do(req)
		if err != nil {
			return 0, err
		}
		io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
		res.Body.Close()
		if res.StatusCode != http.StatusServiceUnavailable || try >= importVerifyTries {
			return res.StatusCode, nil
		}
		wait := 3 * time.Second
		if n, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && n >= 0 && n <= 30 {
			wait = time.Duration(n) * time.Second
		}
		select {
		case <-ctx.Done():
			return 0, ctx.Err()
		case <-time.After(wait):
		}
	}
}

Every flag help string above except --file, --replaces and --verify is instances create's existing text (instances_cli.go:288-301), reused byte for byte; requireAPISession's refusal is main's own. humanBytes(v *int64) string is cli.go:3668.

In apimode.go:

// importBegin is the import door's begin: the ordinary create body plus
// "import". The verify token comes back once and is returned, never stored.
func (s *apiSession) importBegin(ctx context.Context, a apiAcct, app, host, environment, sha string, bytes int64, replaces string) (apiInstanceRecord, string, error) {
	in := map[string]any{"host": host, "import": map[string]any{"sha256": sha, "bytes": bytes, "replaces": replaces}}
	if environment != "" {
		in["environment"] = environment
	}
	status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances"), in)
	if err != nil {
		return apiInstanceRecord{}, "", err
	}
	if status != http.StatusCreated {
		return apiInstanceRecord{}, "", apiErrorFromBody(status, body)
	}
	var out struct {
		apiInstanceRecord
		VerifyToken string `json:"verify_token"`
	}
	if err := json.Unmarshal(body, &out); err != nil || out.VerifyToken == "" {
		return apiInstanceRecord{}, "", fmt.Errorf("instances import: unreadable response: %v", err)
	}
	return out.apiInstanceRecord, out.VerifyToken, nil
}

// importUpload streams the snapshot (spec §5.2) with its own deadline: a
// 4 GiB upload outlives any JSON timeout.
func (s *apiSession) importUpload(ctx context.Context, a apiAcct, app, host string, f *os.File, size int64) error {
	ctx, cancel := context.WithTimeout(ctx, 65*time.Minute)
	defer cancel()
	res, err := s.c.doStream(ctx, http.MethodPut, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/import"), f, size,
		http.Header{"Content-Type": {"application/octet-stream"}})
	if err != nil {
		return err
	}
	defer res.Body.Close()
	raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
	if res.StatusCode != http.StatusOK {
		return apiErrorFromBody(res.StatusCode, raw)
	}
	return nil
}

// importVerb is stopped, commit or abort.
func (s *apiSession) importVerb(ctx context.Context, a apiAcct, app, host, verb string) error {
	status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/import/"+verb), struct{}{})
	if err != nil {
		return err
	}
	if status != http.StatusOK {
		return apiErrorFromBody(status, body)
	}
	return nil
}

In cli.go's runInstances switch: case "import": runInstancesImport(args[1:]); return.

In docs/site/cli.md, under ### carlos instances, a paragraph with SD-N89's approved text and:

````markdown

carlos instances import --app titogo --host acme.go.tito.io --file acme.db --replaces 127.0.0.1:8401 --verify /up
carlos instances import commit --app titogo --host acme.go.tito.io
carlos instances import abort --app titogo --host acme.go.tito.io

````

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

Run: go test . -run 'TestImport|TestInstances|TestSeeded' -count=1 Expected: PASS.

  • [ ] Step 5: Wire SD-N68 to SD-N82, SD-N89, SD-N90 and commit
git add instances_import.go instances_import_test.go cli.go apimode.go docs/site/cli.md approved_seeded_copy_test.go
git commit -m "carlos instances import, with --verify, and import commit and abort (Plan 6 Task 6)

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

Task 7: Plan gate

  • [ ] Step 1: Cross-compile and run the touched packages under -race

Run:

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

Expected: builds clean; tests PASS, including the census and both copy gates.

---

Self-review

  • Spec coverage: §5.1 (Task 1: caller, floor, not a platform record, storage, bytes, token once and hashed), §5.2 (Task 2: reservation and limits, MaxBytesReader, hash as it spools, 422s, CheckDBContext under 30 minutes, one PutFrom, spool removed on every path and at start through Plan 5's clearSeedScratch), §5.3 (Task 3), §5.4's commit (Task 3) and gate-clearing hand-off to the box (Plan 7), §5.5 (Task 6's --verify), §5.6's abort, run rule, commit/abort race, delete refusal and pre-swap delete, and the console finish (Tasks 4, 5), §6.1's callers (every route), §7's audit rows (instance-seed, import-uploaded, import-stopped, import-committed, import-aborted).
  • Moved from Plan 8 to here: finishSeed, because the delete door's pre-swap path needs it ("finishes as below"). Plan 8's sweep calls it.
  • Additions not in the spec: carlos instances import commit and import abort (the spec's CLI prints "the commit command" without naming it), and SD-N115 (commit of a refused or abandoned import), both in the deck Plan 1 reviews.