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 7: The Swap and the Rollback on the Box 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: On the box that holds a legacy addr route, an import whose record is ready replaces that exact route with the platform's own nested exec row in one registry transaction, journaled with the complete original row, behind an import gate that only the verify token passes; commit clears the gate; abort (or a record that vanished or names another seed) rolls back, fenced and idempotent across any crash, to the original route exactly.

Architecture: The registry gains an import_gate column and an import_journal table, with transactions that change the route and the journal together (SwapForImport, CloseImportGate, RestoreImportRoute, CommitImport, DeleteImportJournal). The provision pass reads the journal before the bucket records on every pass: a journal row drives its host (resume the swap's record CAS, clear the gate after commit, or roll back), and the removal phase never touches a journaled host. A record whose import seed is ready and has no journal row goes through the swap's predicates (claims off, nested, shape, protected columns, addr, app, ownership with the operator assignment) and is either swapped or refused with the condition named. The rollback follows internal/layout/pass.go's fence handling through a host-supplied hook. The edge and the edge door answer 503 to every request on a gated row except a matching Carlos-Import-Verify; the schedule runner skips gated rows. This plan's last task raises seeds.ProtocolVersion to 1.

Tech Stack: Go 1.25, modernc sqlite (registry transactions), internal/activator's maintenance fence API, internal/instancefs, release.Store.

Spec: docs/superpowers/specs/2026-10-08-seeded-instances-design.md rev 7: §3.2's dispatch (codex 13) for an import's ready and the journal-first rule, §5.4 (codex 10, 11, 12, 14, 15, R1-10, R2-3), §5.6's rollback (codex R2-4, R2-7, R3-1, R4-1), §5.7, §6.3's box half, §6.4's use of the assignment (codex 9). Index: 2026-10-08-seeded-instances-0-index.md. Copy deck rows SD-N91 to SD-N99.

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/provision/seedswap.go, internal/edge/importgate.go), never in a file listed in movesScanFiles (registry.go, reconcile.go, edge.go, runner.go are listed; they gain only calls and columns).
  • 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.
  • Every registry change that touches both a route and the journal is one SQLite transaction. Every rollback step is idempotent, and the journal's state says where to resume (spec §5.6).
  • The import door refuses a region with more than one box (Plan 3's oneBox), so the box that holds the legacy row is the region's only box. This plan does not handle multi-box regions.

Review Focus

  1. A transient read failure of the bucket (or a record body that cannot be parsed) must never read as "the record is gone" and start a rollback: the journal-first pass rolls back for a missing record only when the pass read every record cleanly and the host is not protected. Test in Task 5 (TestJournalDoesNotRollBackOnAReadFailure), against Task 4's pass.
  2. A row that changed between the predicates and the transaction (an operator re-added it with another addr) must make the swap transaction fail, not replace the changed row. SwapForImport compares the stored row to the one the predicates read, inside the transaction. Test in Task 1 (TestSwapForImportRefusesAChangedRow).
  3. Rolling back after an agent restart (the activator's in-memory fence is gone, the host is cold and has no stop history) must proceed only when the box swept its instances at start and the host is quiesced, and must otherwise lift its fence and defer. Test in Task 5 (TestRollbackFenceAfterRestart).
  4. A resumed cleanup-pending must delete only this seed's snapshot and must never fence or stop the restored legacy process. Test in Task 5 (TestCleanupPendingNeverFences).
  5. A verify request whose token matches must reach the instance without the Carlos-Import-Verify header (the token never reaches the app's logs), and every other request on a gated row gets 503 with Retry-After: 5, including through the edge door. Test in Task 6 (TestImportGateAtTheEdgeAndTheDoor).

---

File structure

| path | responsibility | |---|---| | internal/registry/registry.go | Route.ImportGate, column import_gate, Fingerprint, SnapshotFingerprint, routeArgs shared by Upsert and the transactions, OpsAccountSqid | | internal/registry/importjournal.go | the import_journal table, ImportJournal, the five transactions | | internal/seeds/legacyowners.go | control/legacy-route-owners.json: type, read, lookup | | internal/provision/seedreconcile.go | SeedHooks gains the swap and rollback hooks; the journal-first pass | | internal/provision/seedswap.go | the predicates and the swap | | internal/provision/seedrollback.go | the rollback, steps 1 to 8 | | internal/provision/reconcile.go | calls: the journal pass, the import branch, the removal exclusion | | internal/edge/importgate.go | the gate | | internal/edge/edge.go | one call in ServeHTTP | | internal/schedule/runner.go | gated rows skipped | | internal/host/seedwiring.go, internal/host/agent.go | the fence hook, the swept flag, local removal, replica delete, owners, nested probe | | internal/seeds/floor.go | ProtocolVersion = 1 |

---

Task 1: The import gate column and the journal

Files:

  • Modify: internal/registry/registry.go (Route; migrate; routeCols; scanRoute; Upsert; Fingerprint :2427; SnapshotFingerprint :2657)
  • Create: internal/registry/importjournal.go, internal/registry/importjournal_test.go

Interfaces:

  • Produces: Route.ImportGate string (column import_gate; Upsert's insert writes it, its conflict path never changes it); OpsAccountSqid = "bab" (the pinned ops account, migrateAccounts's INSERT OR IGNORE … (1, 'bab', 'ops')); type ImportJournal struct{ Host, SeedID, RecordKey, State string; Original Route; FenceGen uint64; At int64 }; JournalSwapped, JournalRollingBack, JournalCleanupPending (the spec's strings); (*Registry).ImportJournal(host string) (*ImportJournal, error); (*Registry).ImportJournals() ([]ImportJournal, error); (*Registry).SwapForImport(j ImportJournal, next Route) error; (*Registry).CloseImportGate(host, seedID string, fenceGen uint64) error; (*Registry).RestoreImportRoute(host, seedID string) error; (*Registry).CommitImport(host, seedID string) error; (*Registry).DeleteImportJournal(host, seedID string) error; ErrImportJournal (a transaction's precondition failed).

SwapForImport checks, inside one transaction: no journal row for the host; the stored route equals j.Original field for field (reflect.DeepEqual after a scan); then deletes the route, inserts next and inserts the journal row (state = swapped). CloseImportGate sets the journal row (which must name seedID and be swapped or rolling-back) to rolling-back with fenceGen and the route's import_gate to seeds.ClosedGate's value ("closed"; the registry does not import seeds, so the literal lives in a registry constant ImportGateClosed = "closed" that seeds.ClosedGate must equal, pinned by a test in Task 7). RestoreImportRoute deletes the current route, inserts the journal's Original, and sets the journal to cleanup-pending. CommitImport clears import_gate and deletes the journal row. Each names the seed ID and fails with ErrImportJournal when the journal does not.

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

import (
	"errors"
	"reflect"
	"testing"
)

func legacyRow() Route {
	return Route{Host: "old.go.tito.io", Kind: "service", Addr: "127.0.0.1:8401", Status: "ready",
		App: "titogo", Account: "bab", Channel: "stable", Unit: "titogo@old.service", Caps: "", CreatedAt: 1700000000}
}

func execRow() Route {
	return Route{Host: "old.go.tito.io", Kind: "instance", Socket: "/run/carlos/instances/old/app.sock",
		DBPath: "/data/instances/old/old.go.tito.io.db", Status: "ready", App: "titogo", Account: "bae",
		Channel: "stable", Hibernate: true, Provisioned: true, ImportGate: "abc123", CreatedAt: 1700000001}
}

func TestSwapCommitAndRestore(t *testing.T) {
	reg := openTestRegistry(t)
	old := legacyRow()
	if err := reg.Upsert(old); err != nil {
		t.Fatal(err)
	}
	stored, _ := reg.Get(old.Host)
	j := ImportJournal{Host: old.Host, SeedID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", RecordKey: "apps/bae/titogo/instances/old.go.tito.io.json", Original: *stored, At: 1}
	if err := reg.SwapForImport(j, execRow()); err != nil {
		t.Fatal(err)
	}
	got, _ := reg.Get(old.Host)
	if got.Kind != "instance" || got.ImportGate != "abc123" || got.Account != "bae" {
		t.Fatalf("after swap: %+v", got)
	}
	jr, _ := reg.ImportJournal(old.Host)
	if jr == nil || jr.State != JournalSwapped || !reflect.DeepEqual(jr.Original, *stored) {
		t.Fatalf("journal = %+v", jr)
	}
	// A second swap for the host is refused: the journal is there.
	if err := reg.SwapForImport(j, execRow()); !errors.Is(err, ErrImportJournal) {
		t.Fatalf("second swap: %v", err)
	}
	// Close the gate, restore the route exactly, and the journal says where it is.
	if err := reg.CloseImportGate(old.Host, j.SeedID, 77); err != nil {
		t.Fatal(err)
	}
	if got, _ := reg.Get(old.Host); got.ImportGate != ImportGateClosed {
		t.Fatalf("gate = %q, want closed", got.ImportGate)
	}
	if err := reg.RestoreImportRoute(old.Host, j.SeedID); err != nil {
		t.Fatal(err)
	}
	if got, _ := reg.Get(old.Host); !reflect.DeepEqual(*got, *stored) {
		t.Fatalf("restored %+v, want %+v", got, stored)
	}
	if jr, _ := reg.ImportJournal(old.Host); jr == nil || jr.State != JournalCleanupPending || jr.FenceGen != 77 {
		t.Fatalf("journal after restore = %+v", jr)
	}
	if err := reg.DeleteImportJournal(old.Host, "sd_bbbbbbbbbbbbbbbbbbbbbbbbbb"); !errors.Is(err, ErrImportJournal) {
		t.Fatalf("delete with another id: %v", err)
	}
	if err := reg.DeleteImportJournal(old.Host, j.SeedID); err != nil {
		t.Fatal(err)
	}
	if jr, _ := reg.ImportJournal(old.Host); jr != nil {
		t.Fatalf("journal after delete = %+v", jr)
	}
}

func TestCommitImportClearsTheGateAndTheJournal(t *testing.T) {
	reg := openTestRegistry(t)
	old := legacyRow()
	reg.Upsert(old)
	stored, _ := reg.Get(old.Host)
	j := ImportJournal{Host: old.Host, SeedID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", Original: *stored}
	if err := reg.SwapForImport(j, execRow()); err != nil {
		t.Fatal(err)
	}
	before, _ := reg.Fingerprint()
	if err := reg.CommitImport(old.Host, j.SeedID); err != nil {
		t.Fatal(err)
	}
	got, _ := reg.Get(old.Host)
	jr, _ := reg.ImportJournal(old.Host)
	after, _ := reg.Fingerprint()
	if got.ImportGate != "" || jr != nil || before == after {
		t.Fatalf("after commit: gate %q journal %+v fingerprint moved %v", got.ImportGate, jr, before != after)
	}
}

func TestSwapForImportRefusesAChangedRow(t *testing.T) {
	reg := openTestRegistry(t)
	old := legacyRow()
	reg.Upsert(old)
	stored, _ := reg.Get(old.Host)
	changed := *stored
	changed.Addr = "127.0.0.1:9999"
	if err := reg.Upsert(changed); err != nil {
		t.Fatal(err)
	}
	j := ImportJournal{Host: old.Host, SeedID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", Original: *stored}
	if err := reg.SwapForImport(j, execRow()); !errors.Is(err, ErrImportJournal) {
		t.Fatalf("swap over a changed row: %v", err)
	}
	if got, _ := reg.Get(old.Host); got.Addr != "127.0.0.1:9999" {
		t.Fatal("the changed row was replaced")
	}
}

func TestUpsertNeverChangesTheImportGate(t *testing.T) {
	reg := openTestRegistry(t)
	row := execRow()
	reg.Upsert(row)
	row.ImportGate = ""
	if err := reg.Upsert(row); err != nil {
		t.Fatal(err)
	}
	if got, _ := reg.Get(row.Host); got.ImportGate != "abc123" {
		t.Fatalf("an Upsert cleared the gate: %q", got.ImportGate)
	}
}
  • [ ] Step 2: Run the tests to verify they fail

Run: go test ./internal/registry/ -run 'TestSwap|TestCommitImport|TestUpsertNeverChangesTheImportGate' -count=1 Expected: FAIL to compile.

  • [ ] Step 3: Add the column

In Route, after SeedSource:

	// ImportGate is an uncommitted import's gate (seeded-instances spec
	// §5.4): the sha256 of its verify token, or ImportGateClosed during a
	// rollback. Set: the edge answers 503 to every request without the
	// matching token, and ticks skip the row. Only the import transactions
	// (importjournal.go) write it; Upsert's conflict path never changes it.
	// In Fingerprint (the edge must reload on a change), folded only when
	// non-empty.
	ImportGate string

migrate: ` ALTER TABLE routes ADD COLUMN importgate TEXT NOT NULL DEFAULT '', . routeCols: append , importgate (49). scanRoute: &t.ImportGate. Upsert: one more ?, t.ImportGate appended to the arguments; no SET entry, so a conflict keeps the stored value. Factor the argument list into func routeArgs(t Route) []any (after Upsert's two normalisations) so the journal's transactions insert a row with exactly Upsert's columns: Upsert becomes r.sql.Exec(upsertSQL, routeArgs(t)...)`.

Fingerprint: add import_gate to the SELECT and fold it like hold_op: if importGate != "" { fmt.Fprintf(h, "\x1fimport_gate=%q", importGate) }. SnapshotFingerprint: give import_gate hold_op's rule.

Add beside opsAccountID:

// OpsAccountSqid is the ops account's pinned sqid (migrateAccounts inserts
// it): the account a direct-mode `carlos add` files a route under.
const OpsAccountSqid = "bab"

// ImportGateClosed is the import gate a rollback installs: it matches no
// token's hash (seeds.ClosedGate holds the same value).
const ImportGateClosed = "closed"
  • [ ] Step 4: Write importjournal.go
package registry

import (
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"reflect"
)

// The import journal (seeded-instances spec §5.7): one row per host this
// box has swapped for an import, holding the complete original route, so
// the box always knows what it replaced, whatever happened to the record.
const (
	JournalSwapped        = "swapped"
	JournalRollingBack    = "rolling-back"
	JournalCleanupPending = "cleanup-pending"
)

// ErrImportJournal is a journal transaction whose precondition did not
// hold: no row or another seed's, a row already there, a changed route.
var ErrImportJournal = errors.New("registry: the import journal does not allow this")

type ImportJournal struct {
	Host      string
	SeedID    string
	Original  Route
	RecordKey string
	State     string
	FenceGen  uint64
	At        int64
}

func (r *Registry) migrateImportJournal() error {
	_, err := r.sql.Exec(`CREATE TABLE IF NOT EXISTS import_journal (
  host       TEXT PRIMARY KEY,
  seed_id    TEXT NOT NULL,
  original   TEXT NOT NULL,
  record_key TEXT NOT NULL DEFAULT '',
  state      TEXT NOT NULL,
  fence_gen  INTEGER NOT NULL DEFAULT 0,
  at         INTEGER NOT NULL DEFAULT 0
);`)
	return err
}

type queryer interface {
	QueryRow(query string, args ...any) *sql.Row
}

func scanJournal(q queryer, host string) (*ImportJournal, error) {
	var j ImportJournal
	var orig string
	err := q.QueryRow(`SELECT host, seed_id, original, record_key, state, fence_gen, at FROM import_journal WHERE host = ?`, host).
		Scan(&j.Host, &j.SeedID, &orig, &j.RecordKey, &j.State, &j.FenceGen, &j.At)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	if err := json.Unmarshal([]byte(orig), &j.Original); err != nil {
		return nil, fmt.Errorf("registry: import journal %s: original row: %w", host, err)
	}
	return &j, nil
}

func (r *Registry) ImportJournal(host string) (*ImportJournal, error) { return scanJournal(r.sql, host) }

func (r *Registry) ImportJournals() ([]ImportJournal, error) {
	rows, err := r.sql.Query(`SELECT host FROM import_journal ORDER BY host`)
	if err != nil {
		return nil, err
	}
	var hosts []string
	for rows.Next() {
		var h string
		if err := rows.Scan(&h); err != nil {
			rows.Close()
			return nil, err
		}
		hosts = append(hosts, h)
	}
	rows.Close()
	out := make([]ImportJournal, 0, len(hosts))
	for _, h := range hosts {
		j, err := r.ImportJournal(h)
		if err != nil {
			return nil, err
		}
		if j != nil {
			out = append(out, *j)
		}
	}
	return out, nil
}

// inTx runs fn in one transaction. The registry's pool holds one
// connection (Open), so in-process writers serialise on it; another
// process's write waits on busy_timeout.
func (r *Registry) inTx(fn func(tx *sql.Tx) error) error {
	tx, err := r.sql.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if err := fn(tx); err != nil {
		return err
	}
	return tx.Commit()
}

func getRouteTx(tx *sql.Tx, host string) (*Route, error) {
	t, err := scanRoute(tx.QueryRow(`SELECT `+routeCols+` FROM routes WHERE host = ?`, host).Scan)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	return &t, nil
}

func insertRouteTx(tx *sql.Tx, t Route) error {
	_, err := tx.Exec(`INSERT INTO routes (`+routeCols+`) VALUES (`+placeholders(routeColCount)+`)`, routeArgs(t)...)
	return err
}

// SwapForImport is the swap's one transaction (spec §5.4, codex 14).
func (r *Registry) SwapForImport(j ImportJournal, next Route) error {
	orig, err := json.Marshal(j.Original)
	if err != nil {
		return err
	}
	return r.inTx(func(tx *sql.Tx) error {
		if cur, err := scanJournal(tx, j.Host); err != nil {
			return err
		} else if cur != nil {
			return fmt.Errorf("%w: %s already has a journal row for seed %s", ErrImportJournal, j.Host, cur.SeedID)
		}
		stored, err := getRouteTx(tx, j.Host)
		if err != nil {
			return err
		}
		if stored == nil || !reflect.DeepEqual(*stored, j.Original) {
			return fmt.Errorf("%w: the route at %s changed since it was checked", ErrImportJournal, j.Host)
		}
		if _, err := tx.Exec(`DELETE FROM routes WHERE host = ?`, j.Host); err != nil {
			return err
		}
		if err := insertRouteTx(tx, next); err != nil {
			return err
		}
		_, err = tx.Exec(`INSERT INTO import_journal (host, seed_id, original, record_key, state, fence_gen, at) VALUES (?,?,?,?,?,0,?)`,
			j.Host, j.SeedID, string(orig), j.RecordKey, JournalSwapped, j.At)
		return err
	})
}

func journalFor(tx *sql.Tx, host, seedID string, states ...string) (*ImportJournal, error) {
	j, err := scanJournal(tx, host)
	if err != nil {
		return nil, err
	}
	if j == nil || j.SeedID != seedID {
		return nil, fmt.Errorf("%w: no journal row for %s seed %s", ErrImportJournal, host, seedID)
	}
	for _, s := range states {
		if j.State == s {
			return j, nil
		}
	}
	return nil, fmt.Errorf("%w: %s's journal is %s", ErrImportJournal, host, j.State)
}

// CloseImportGate is the rollback's step 1 (spec §5.6): the journal says
// rolling-back with the fence generation, and the gate refuses even verify
// requests, in one transaction.
func (r *Registry) CloseImportGate(host, seedID string, fenceGen uint64) error {
	return r.inTx(func(tx *sql.Tx) error {
		if _, err := journalFor(tx, host, seedID, JournalSwapped, JournalRollingBack); err != nil {
			return err
		}
		if _, err := tx.Exec(`UPDATE import_journal SET state = ?, fence_gen = ? WHERE host = ?`, JournalRollingBack, fenceGen, host); err != nil {
			return err
		}
		_, err := tx.Exec(`UPDATE routes SET import_gate = ? WHERE host = ?`, ImportGateClosed, host)
		return err
	})
}

// RestoreImportRoute is the rollback's step 4 (spec §5.6, codex R3-1): the
// original route back exactly, and the journal at cleanup-pending.
func (r *Registry) RestoreImportRoute(host, seedID string) error {
	return r.inTx(func(tx *sql.Tx) error {
		j, err := journalFor(tx, host, seedID, JournalRollingBack)
		if err != nil {
			return err
		}
		if _, err := tx.Exec(`DELETE FROM routes WHERE host = ?`, host); err != nil {
			return err
		}
		if err := insertRouteTx(tx, j.Original); err != nil {
			return err
		}
		_, err = tx.Exec(`UPDATE import_journal SET state = ? WHERE host = ?`, JournalCleanupPending, host)
		return err
	})
}

// CommitImport clears the gate and retires the journal in one transaction
// (spec §5.4 "Commit").
func (r *Registry) CommitImport(host, seedID string) error {
	return r.inTx(func(tx *sql.Tx) error {
		if _, err := journalFor(tx, host, seedID, JournalSwapped); err != nil {
			return err
		}
		if _, err := tx.Exec(`UPDATE routes SET import_gate = '' WHERE host = ?`, host); err != nil {
			return err
		}
		_, err := tx.Exec(`DELETE FROM import_journal WHERE host = ?`, host)
		return err
	})
}

// DeleteImportJournal is the rollback's step 7.
func (r *Registry) DeleteImportJournal(host, seedID string) error {
	return r.inTx(func(tx *sql.Tx) error {
		if _, err := journalFor(tx, host, seedID, JournalCleanupPending); err != nil {
			return err
		}
		_, err := tx.Exec(`DELETE FROM import_journal WHERE host = ?`, host)
		return err
	})
}

Add routeColCount = 49 and placeholders(n int) string (n comma-joined ?) beside routeCols, and use placeholders(routeColCount) in Upsert's VALUES too, so a column added later changes one number. Call r.migrateImportJournal() from migrate before migrateAccounts. Registry.sql uses MaxOpenConns(1), so the transaction serialises with every other write on this handle; another process's write waits on busy_timeout.

  • [ ] Step 5: Run the registry package

Run: go test ./internal/registry/ -count=1 Expected: PASS, including TestUpsertRoundTripsAllColumns with ImportGate added to its want and the snapshot digest's stability test.

  • [ ] Step 6: Commit
git add internal/registry/
git commit -m "registry: the import gate column and the import journal's transactions (Plan 7 Task 1)

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

Task 2: The operator assignment reader

Files:

  • Create: internal/seeds/legacyowners.go, internal/seeds/legacyowners_test.go

Interfaces:

  • Produces: LegacyOwnersKey = "control/legacy-route-owners.json"; type LegacyOwner struct{ Account, App string }; type LegacyOwners struct{ V int; Names map[string]LegacyOwner } (json v, names, account, app); ReadLegacyOwners(ctx, s release.Store) (*LegacyOwners, string, error) (absent: an empty set, "", nil); (*LegacyOwners).Lookup(name string) (LegacyOwner, bool). Plan 8 writes the file.
  • [ ] Step 1: Write the failing test
package seeds

import (
	"context"
	"testing"

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

func TestLegacyOwners(t *testing.T) {
	ctx := context.Background()
	s := release.NewDirStore(t.TempDir())
	o, etag, err := ReadLegacyOwners(ctx, s)
	if err != nil || etag != "" || o == nil || len(o.Names) != 0 {
		t.Fatalf("absent = %+v %q %v", o, etag, err)
	}
	if _, err := s.Put(ctx, LegacyOwnersKey, []byte(`{"v":1,"names":{"titogo":{"account":"bae","app":"titogo"}}}`), ""); err != nil {
		t.Fatal(err)
	}
	o, etag, err = ReadLegacyOwners(ctx, s)
	if err != nil || etag == "" {
		t.Fatal(err)
	}
	if got, ok := o.Lookup("titogo"); !ok || got != (LegacyOwner{Account: "bae", App: "titogo"}) {
		t.Fatalf("lookup = %+v %v", got, ok)
	}
	if _, ok := o.Lookup("kass"); ok {
		t.Fatal("an unassigned name resolved")
	}
	if _, err := s.Put(ctx, LegacyOwnersKey, []byte(`{garbage`), etag); err != nil {
		t.Fatal(err)
	}
	if _, _, err := ReadLegacyOwners(ctx, s); err == nil {
		t.Fatal("garbage read as no assignments")
	}
}
  • [ ] Step 2: Run it to verify it fails

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

  • [ ] Step 3: Write legacyowners.go
package seeds

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"

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

// LegacyOwnersKey is the operator assignment of legacy route names
// (seeded-instances spec §6.4, codex 9): which account and app a route
// filed under the operator account belongs to.
const LegacyOwnersKey = "control/legacy-route-owners.json"

type LegacyOwner struct {
	Account string `json:"account"`
	App     string `json:"app"`
}

type LegacyOwners struct {
	V     int                    `json:"v"`
	Names map[string]LegacyOwner `json:"names"`
}

// ReadLegacyOwners reads the assignments. Absent is an empty set; present
// and unreadable is an error, never "no assignment".
func ReadLegacyOwners(ctx context.Context, s release.Store) (*LegacyOwners, string, error) {
	body, etag, err := s.Get(ctx, LegacyOwnersKey)
	if errors.Is(err, os.ErrNotExist) {
		return &LegacyOwners{V: 1, Names: map[string]LegacyOwner{}}, "", nil
	}
	if err != nil {
		return nil, "", fmt.Errorf("seeds: read legacy route owners: %w", err)
	}
	var o LegacyOwners
	if err := json.Unmarshal(body, &o); err != nil {
		return nil, "", fmt.Errorf("seeds: parse legacy route owners: %w", err)
	}
	if o.Names == nil {
		o.Names = map[string]LegacyOwner{}
	}
	return &o, etag, nil
}

func (o *LegacyOwners) Lookup(name string) (LegacyOwner, bool) {
	if o == nil {
		return LegacyOwner{}, false
	}
	v, ok := o.Names[name]
	return v, ok
}
  • [ ] Step 4: Run it to verify it passes, and commit

Run: go test ./internal/seeds/ -count=1 (PASS).

git add internal/seeds/legacyowners.go internal/seeds/legacyowners_test.go
git commit -m "seeds: the legacy route owners reader (Plan 7 Task 2)

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

Task 3: The swap

Files:

  • Create: internal/provision/seedswap.go, internal/provision/seedswap_test.go
  • Modify: internal/provision/seedreconcile.go (SeedHooks fields; seedAction's import ready case)
  • Modify: internal/provision/reconcile.go (the record loop calls swapImport for seedSwap)

Interfaces:

  • Consumes: Task 1's registry calls; Task 2's ReadLegacyOwners; routeFor, SeedColumn, RecordKey, SetRecordSeed, SeedSwapped, SeedRefuse; mintLayoutReader.
  • Produces: SeedHooks gains LegacyOwners func(ctx context.Context) (*seeds.LegacyOwners, error), NestedOK func() error, Now func() time.Time; seedVerdict gains seedSwap; swapImport(ctx, s release.Store, reg *registry.Registry, cfg ReconcileConfig, rec Record, existing *registry.Route, nested func() (bool, error)); importRefusal(rec Record, existing *registry.Route, cfg ReconcileConfig, owners *seeds.LegacyOwners, nested bool) string (the reason, "" to swap).

The predicates, in order, each refused with its reason (spec §5.4, §6.3, §6.4):

  1. Claims armed: SD-N98.
  2. Not nested (the layout intent through mintLayoutReader, and SeedHooks.NestedOK when set): SD-N97. A layout read error defers (no refusal).
  3. No row for the host: SD-N91.
  4. Shape: kind other than instance or service (<what>: kind <kind>), a socket (socket), no addr and no addrs (no address), provisioned (provisioned), and any of backing, tunnel, caps, platform, canary (the column's name): SD-N96.
  5. The address: addr equals Replaces, or addr is empty and addrs is exactly Replaces; otherwise SD-N92 naming the row's address.
  6. The app: SD-N93 unless the row's app is the record's.
  7. The account: the record's account passes; registry.OpsAccountSqid passes only when the assignment maps the row's app name to the record's account and app (SD-N94 otherwise; an unreadable assignment file defers); any other account: SD-N95.

Then: the exec row is routeFor(rec, cfg.DataDir, "", true) (nested explicitly, never NestedPaths(existing), codex 12) with ImportGate = rec.Seed.VerifyHash; cfg.ConfigGate must admit it (a refusal defers); the journal row carries the complete stored row, the record key, the seed ID and the time; SwapForImport; then SetRecordSeed(SeedSwapped(id)). A failed CAS is redone by Task 4's journal pass.

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

import (
	"context"
	"strings"
	"testing"
	"time"

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

func importRecord(t *testing.T, s release.Store, sqid string) Record {
	t.Helper()
	rec := acmeRecord(sqid)
	rec.Pool = ""
	id, _ := seeds.NewID()
	rec.Seed = &SeedSource{ID: id, Kind: seeds.KindImport, State: seeds.StateReady, SHA256: strings.Repeat("a", 64), Bytes: 10,
		Replaces: "127.0.0.1:8401", VerifyHash: strings.Repeat("b", 64), By: "t", At: time.Now()}
	if err := CreateRecord(ctx, s, rec); err != nil {
		t.Fatal(err)
	}
	return rec
}

func addrRow(rec Record, account string) registry.Route {
	return registry.Route{Host: rec.Host, Kind: "service", Addr: "127.0.0.1:8401", App: rec.App, Account: account,
		Channel: "stable", Status: "ready", Unit: "titogo@acme.service"}
}

func importCfg(t *testing.T, owners map[string]seeds.LegacyOwner) ReconcileConfig {
	cfg := nestedSeedCfg(dirStore(t))
	cfg.Pools = nil
	cfg.Seeds.LegacyOwners = func(context.Context) (*seeds.LegacyOwners, error) {
		return &seeds.LegacyOwners{V: 1, Names: owners}, nil
	}
	return cfg
}

func TestSwapReplacesTheAddrRowAndJournalsIt(t *testing.T) {
	s, sqid := setup(t)
	rec := importRecord(t, s, sqid)
	reg := regWith(t, addrRow(rec, sqid))
	original, _ := reg.Get(rec.Host)
	if err := Reconcile(ctx, s, reg, importCfg(t, nil)); err != nil {
		t.Fatal(err)
	}
	got := mustGet(t, reg, rec.Host)
	if got.Kind != "instance" || got.Socket == "" || got.Addr != "" || got.ImportGate != rec.Seed.VerifyHash ||
		got.DBPath != NestedDBPath("/data", rec.Host) || got.Account != sqid || !got.Provisioned || got.Seed != "" {
		t.Fatalf("swapped row = %+v", got)
	}
	j, _ := reg.ImportJournal(rec.Host)
	if j == nil || j.SeedID != rec.Seed.ID || j.State != registry.JournalSwapped || j.Original != *original ||
		j.RecordKey != RecordKey(rec.Account, rec.App, rec.Host) {
		t.Fatalf("journal = %+v", j)
	}
	r, _ := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
	if r.Seed.State != seeds.StateSwapped {
		t.Fatalf("record = %s", r.Seed.State)
	}
}

func TestSwapRefusesEachCondition(t *testing.T) {
	for _, c := range []struct {
		name   string
		row    func(Record, string) *registry.Route
		owners map[string]seeds.LegacyOwner
		cfg    func(*ReconcileConfig)
		want   string
	}{
		{"no row", func(Record, string) *registry.Route { return nil }, nil, nil, "there is no route at"},
		{"addr changed", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Addr = "127.0.0.1:9"; return &x }, nil, nil, "the route's address is 127.0.0.1:9, not 127.0.0.1:8401"},
		{"another app", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.App = "kass"; return &x }, nil, nil, "belongs to app kass"},
		{"a third account", func(r Record, _ string) *registry.Route { x := addrRow(r, "zzz"); return &x }, nil, nil, "filed under another account"},
		{"operator row, no assignment", func(r Record, _ string) *registry.Route { x := addrRow(r, registry.OpsAccountSqid); return &x }, nil, nil, "no assignment maps tito"},
		{"operator row, assigned elsewhere", func(r Record, _ string) *registry.Route { x := addrRow(r, registry.OpsAccountSqid); return &x },
			map[string]seeds.LegacyOwner{"tito": {Account: "other", App: "tito"}}, nil, "no assignment maps tito"},
		{"socket backed", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Socket = "/run/x.sock"; return &x }, nil, nil, "(socket)"},
		{"provisioned", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Provisioned = true; return &x }, nil, nil, "(provisioned)"},
		{"static", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Kind = "static"; return &x }, nil, nil, "(kind static)"},
		{"backing", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Backing = registry.BackingUnit; return &x }, nil, nil, "(backing)"},
		{"tunnel", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Tunnel = "t"; return &x }, nil, nil, "(tunnel)"},
		{"caps", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Caps = "host-role"; return &x }, nil, nil, "(caps)"},
		{"platform", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Platform = true; return &x }, nil, nil, "(platform)"},
		{"canary", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Canary = true; return &x }, nil, nil, "(canary)"},
		{"flat", func(r Record, a string) *registry.Route { x := addrRow(r, a); return &x }, nil, func(c *ReconcileConfig) { c.Layout = nil }, "does not run instances nested"},
		{"claimed", func(r Record, a string) *registry.Route { x := addrRow(r, a); return &x }, nil, func(c *ReconcileConfig) { c.ClaimEnabled = true }, "claim control is on"},
	} {
		t.Run(c.name, func(t *testing.T) {
			s, sqid := setup(t)
			rec := importRecord(t, s, sqid)
			var reg *registry.Registry
			if row := c.row(rec, sqid); row != nil {
				reg = regWith(t, *row)
			} else {
				reg = regWith(t)
			}
			cfg := importCfg(t, c.owners)
			if c.cfg != nil {
				c.cfg(&cfg)
			}
			if err := Reconcile(ctx, s, reg, cfg); err != nil {
				t.Fatal(err)
			}
			r, _ := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
			if r.Seed.State != seeds.StateRefused || !strings.Contains(r.Seed.Reason, c.want) {
				t.Fatalf("seed = %s %q, want refused with %q", r.Seed.State, r.Seed.Reason, c.want)
			}
			if j, _ := reg.ImportJournal(rec.Host); j != nil {
				t.Fatalf("a refused import left a journal row: %+v", j)
			}
		})
	}
}

func TestSwapAcceptsAnAssignedOperatorRow(t *testing.T) {
	s, sqid := setup(t)
	rec := importRecord(t, s, sqid)
	reg := regWith(t, addrRow(rec, registry.OpsAccountSqid))
	if err := Reconcile(ctx, s, reg, importCfg(t, map[string]seeds.LegacyOwner{"tito": {Account: sqid, App: "tito"}})); err != nil {
		t.Fatal(err)
	}
	if got := mustGet(t, reg, rec.Host); got.Account != sqid || got.Kind != "instance" {
		t.Fatalf("row = %+v; want it filed under the record's account", got)
	}
}

The setup manifest's app is tito (setup, reconcile_test.go:127), so the row's app and the assignment's name are tito.

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

Run: go test ./internal/provision/ -run 'TestSwap' -count=1 Expected: FAIL: the import is skipped (Plan 4's seedAction), so no row changes and no refusal is written.

  • [ ] Step 3: Write seedswap.go
package provision

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

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

// importShape is the legacy row's shape check (spec §5.4, codex 10, R1-10):
// "" when the row is an addr-backed instance or service this import may
// take over, else what makes it not one.
func importShape(rt registry.Route) string {
	switch {
	case rt.Kind != "instance" && rt.Kind != "service":
		return "kind " + rt.Kind
	case rt.Socket != "":
		return "socket"
	case rt.Addr == "" && rt.Addrs == "":
		return "no address"
	case rt.Provisioned:
		return "provisioned"
	case rt.Backing != "":
		return "backing"
	case rt.Tunnel != "":
		return "tunnel"
	case rt.Caps != "":
		return "caps"
	case rt.Platform:
		return "platform"
	case rt.Canary:
		return "canary"
	}
	return ""
}

func importAddr(rt registry.Route) string {
	if rt.Addr != "" {
		return rt.Addr
	}
	return rt.Addrs
}

// importRefusal is §5.4's predicates in order: "" to swap, else the
// refusal reason the record keeps. owners is consulted only for an
// operator-filed row.
func importRefusal(rec Record, existing *registry.Route, cfg ReconcileConfig, owners *seeds.LegacyOwners, nested bool) string {
	switch {
	case cfg.ClaimEnabled:
		return "claim control is on for this box"
	case !nested:
		return "this box does not run instances nested"
	case existing == nil:
		return fmt.Sprintf("there is no route at %s to replace", rec.Host)
	}
	if what := importShape(*existing); what != "" {
		return fmt.Sprintf("the route is not an address route this import can take over (%s)", what)
	}
	if importAddr(*existing) != rec.Seed.Replaces {
		return fmt.Sprintf("the route's address is %s, not %s", importAddr(*existing), rec.Seed.Replaces)
	}
	if existing.App != rec.App {
		return fmt.Sprintf("the route belongs to app %s", existing.App)
	}
	switch existing.Account {
	case rec.Account:
		return ""
	case registry.OpsAccountSqid:
		if o, ok := owners.Lookup(existing.App); ok && o.Account == rec.Account && o.App == rec.App {
			return ""
		}
		return fmt.Sprintf("the route is filed under the operator account, and no assignment maps %s to this app", existing.App)
	}
	return "the route is filed under another account"
}

// swapImport is the swap (spec §5.4): the predicates, then one registry
// transaction, then the record's CAS to swapped. Transient failures defer
// to the next pass; a failed predicate is a durable refusal.
func swapImport(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, rec Record, existing *registry.Route, nested func() (bool, error)) {
	if cfg.DryRun {
		log.Printf("provision: dry-run: would consider swapping %s for import %s", rec.Host, rec.Seed.ID)
		return
	}
	isNested := false
	if !cfg.ClaimEnabled {
		ok, err := nested()
		if err != nil {
			log.Printf("provision: %s: import deferred: the box's layout intent could not be read: %v", rec.Host, err)
			return
		}
		isNested = ok
		if ok && cfg.Seeds.NestedOK != nil {
			isNested = cfg.Seeds.NestedOK() == nil
		}
	}
	var owners *seeds.LegacyOwners
	if existing != nil && existing.Account == registry.OpsAccountSqid && cfg.Seeds.LegacyOwners != nil {
		o, err := cfg.Seeds.LegacyOwners(ctx)
		if err != nil {
			log.Printf("provision: %s: import deferred: the legacy route assignments could not be read: %v", rec.Host, err)
			return
		}
		owners = o
	}
	if reason := importRefusal(rec, existing, cfg, owners, isNested); reason != "" {
		if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRefuse(rec.Seed.ID, reason)); err != nil {
			log.Printf("provision: %s: record the import's refusal (%s): %v", rec.Host, reason, err)
			return
		}
		log.Printf("provision: %s: import %s refused: %s", rec.Host, rec.Seed.ID, reason)
		return
	}
	next := routeFor(rec, cfg.DataDir, "", true)
	next.ImportGate = rec.Seed.VerifyHash
	if cfg.ConfigGate != nil {
		if err := cfg.ConfigGate(ctx, next); err != nil {
			return // the gate logs its own refusal; retried every pass
		}
	}
	now := time.Now
	if cfg.Seeds.Now != nil {
		now = cfg.Seeds.Now
	}
	j := registry.ImportJournal{Host: rec.Host, SeedID: rec.Seed.ID, Original: *existing,
		RecordKey: RecordKey(rec.Account, rec.App, rec.Host), At: now().Unix()}
	if err := reg.SwapForImport(j, next); err != nil {
		if errors.Is(err, registry.ErrImportJournal) {
			log.Printf("provision: %s: import swap deferred: %v", rec.Host, err)
			return
		}
		log.Printf("provision: %s: import swap: %v", rec.Host, err)
		return
	}
	log.Printf("provision: %s: swapped for import %s (the original route is journaled)", rec.Host, rec.Seed.ID)
	if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedSwapped(rec.Seed.ID)); err != nil {
		log.Printf("provision: %s: import %s swapped, record CAS deferred to the next pass: %v", rec.Host, rec.Seed.ID, err)
	}
}

In seedreconcile.go: add the three SeedHooks fields, the verdict seedSwap, and in seedAction's StateReady case replace if src.Import() { return seedSkip } with if src.Import() { return seedSwap }. In reconcile.go's dispatch:

			case seedSwap:
				var row *registry.Route
				if ok {
					row = &existing
				}
				swapImport(ctx, s, reg, cfg, rec, row, mintLayout)
				continue

Plan 4's TestReconcileSkipsSeedsThatAreNotReady lists an import ready as skipped; move that case to this task's tests (it is now a swap or a refusal) by deleting it from Plan 4's table.

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

Run: go test ./internal/provision/ -run 'TestSwap|TestReconcile|TestSeed' -count=1 Expected: PASS.

  • [ ] Step 5: Wire SD-N91 to SD-N98 and commit
git add internal/provision/ approved_seeded_copy_test.go
git commit -m "provision: the import swap, its predicates and its refusals (Plan 7 Task 3)

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

Task 4: The journal comes first

Files:

  • Modify: internal/provision/seedreconcile.go (add journalPass), internal/provision/reconcile.go (call it after byHost is built; skip its hosts in the record loop and the removal phase; seedAction refuses import work when the journal could not be read)
  • Test: internal/provision/seedjournal_test.go

Interfaces:

  • Consumes: reg.ImportJournals, reg.CommitImport, SetRecordSeed, SeedSwapped; Task 5's rollbackImport (this task calls a stub that Task 5 fills).
  • Produces: journalPass(ctx, s release.Store, reg *registry.Registry, cfg ReconcileConfig, recs map[string]Record, readFailed bool, protect map[string]bool) (handled map[string]bool, journaled map[string]bool, ok bool).

For each journal row (spec §5.4 "Crashes", §5.6, §5.7):

| journal | record | action | |---|---|---| | any | the pass could not read every record, or the host is protected | nothing this pass (handled) | | cleanup-pending | any | resume the rollback at step 6 (handled) | | rolling-back | any | resume the rollback at step 1's check, then 2 (handled) | | swapped | gone, no seed, another seed ID, or this seed aborted | roll back (handled) | | swapped | this seed ready | redo the CAS to swapped (handled) | | swapped | this seed committed | CommitImport (not handled: the loop converges the row) | | swapped | this seed swapped or placed | nothing (not handled: ordinary handling) |

journaled is every journal host; the removal phase skips them (the journal owns the row until it is gone). If ImportJournals fails, ok is false: every import seed is skipped this pass (seedAction returns seedSkip for an import when the journal is unknown), so the no-journal finish (Plan 4) can never run while a journal row might exist.

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

import (
	"testing"

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

func swappedImport(t *testing.T) (*registry.Registry, Record, ReconcileConfig, func() error) {
	t.Helper()
	s, sqid := setup(t)
	rec := importRecord(t, s, sqid)
	reg := regWith(t, addrRow(rec, sqid))
	cfg := importCfg(t, nil)
	run := func() error { return Reconcile(ctx, s, reg, cfg) }
	if err := run(); err != nil {
		t.Fatal(err)
	}
	return reg, rec, cfg, run
}

func TestJournalResumesTheRecordCAS(t *testing.T) {
	s, sqid := setup(t)
	rec := importRecord(t, s, sqid)
	reg := regWith(t, addrRow(rec, sqid))
	stored, _ := reg.Get(rec.Host)
	// The box crashed after the transaction and before the record CAS.
	next := routeFor(rec, "/data", "", true)
	next.ImportGate = rec.Seed.VerifyHash
	if err := reg.SwapForImport(registry.ImportJournal{Host: rec.Host, SeedID: rec.Seed.ID, Original: *stored}, next); err != nil {
		t.Fatal(err)
	}
	if err := Reconcile(ctx, s, reg, importCfg(t, nil)); err != nil {
		t.Fatal(err)
	}
	r, _ := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
	if r.Seed.State != seeds.StateSwapped {
		t.Fatalf("state = %s, want the CAS redone", r.Seed.State)
	}
	if got := mustGet(t, reg, rec.Host); got.Kind != "instance" {
		t.Fatalf("the swapped row was refused or changed: %+v", got)
	}
}

func TestCommitClearsTheGateAndTheJournal(t *testing.T) {
	s, sqid := setup(t)
	rec := importRecord(t, s, sqid)
	reg := regWith(t, addrRow(rec, sqid))
	cfg := importCfg(t, nil)
	Reconcile(ctx, s, reg, cfg)
	for _, step := range []func(string) func(*Record) error{SeedPlace, SeedCommit} {
		if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, step(rec.Seed.ID)); err != nil {
			t.Fatal(err)
		}
	}
	if err := Reconcile(ctx, s, reg, cfg); err != nil {
		t.Fatal(err)
	}
	if got := mustGet(t, reg, rec.Host); got.ImportGate != "" {
		t.Fatalf("gate after commit = %q", got.ImportGate)
	}
	if j, _ := reg.ImportJournal(rec.Host); j != nil {
		t.Fatalf("journal after commit = %+v", j)
	}
}

Task 5 adds TestJournalDoesNotRollBackOnAReadFailure, once the rollback exists to be wrongly started.

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

Run: go test ./internal/provision/ -run 'TestJournalResumes|TestCommitClears' -count=1 Expected: FAIL: the CAS is never redone (the row now fails the predicates and the seed is refused), and the gate is never cleared.

  • [ ] Step 3: Write journalPass and wire it

In seedreconcile.go:

// journalPass is spec §5.7: the box's import journal is read before the
// bucket records, every pass, and each row drives its host. handled hosts
// are skipped by the record loop; journaled hosts by the removal phase.
func journalPass(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig,
	recs map[string]Record, readFailed bool, protect map[string]bool) (handled, journaled map[string]bool, ok bool) {
	handled, journaled = map[string]bool{}, map[string]bool{}
	if cfg.Seeds == nil {
		return handled, journaled, true
	}
	rows, err := reg.ImportJournals()
	if err != nil {
		log.Printf("provision: import journal unreadable: %v (no import is acted on this pass)", err)
		return handled, journaled, false
	}
	for _, j := range rows {
		journaled[j.Host] = true
		rec, found := recs[j.Host]
		if !found && (readFailed || protect[j.Host]) {
			handled[j.Host] = true
			continue
		}
		switch j.State {
		case registry.JournalCleanupPending, registry.JournalRollingBack:
			rollbackImport(ctx, s, reg, cfg, j, recordOrNil(found, rec))
			handled[j.Host] = true
			continue
		}
		var seed *SeedSource
		if found {
			seed = rec.Seed
		}
		switch {
		case seed == nil || seed.ID != j.SeedID || seed.State == seeds.StateAborted:
			rollbackImport(ctx, s, reg, cfg, j, recordOrNil(found, rec))
			handled[j.Host] = true
		case seed.State == seeds.StateReady:
			if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedSwapped(seed.ID)); err != nil {
				log.Printf("provision: %s: redo the swap's record CAS: %v", j.Host, err)
			}
			handled[j.Host] = true
		case seed.State == seeds.StateCommitted:
			if cfg.DryRun {
				log.Printf("provision: dry-run: would clear %s's import gate", j.Host)
			} else if err := reg.CommitImport(j.Host, j.SeedID); err != nil {
				log.Printf("provision: %s: clear the import gate: %v", j.Host, err)
			}
		}
	}
	return handled, journaled, true
}

func recordOrNil(found bool, rec Record) *Record {
	if !found {
		return nil
	}
	return &rec
}

// rollbackImport is Task 5's. Until then, a stub that logs.
func rollbackImport(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, j registry.ImportJournal, rec *Record) {
	log.Printf("provision: %s: import %s needs a rollback (not built yet)", j.Host, j.SeedID)
}

In Reconcile, after byHost is built and mintLayout is created:

	journalHandled, journaled, journalOK := journalPass(ctx, s, reg, cfg, recs, readFailed, protect)

In the record loop, before the seed dispatch: if journalHandled[rec.Host] { continue }, and pass journalOK into seedAction (signature gains journalOK bool; an import seed with !journalOK returns seedSkip; aborted with a journal row never reaches seedAction because it is handled). In the removal phase, before the protect check: if journaled[rt.Host] { continue }.

A journal row whose route was minted afterwards in the same pass (the ordinary owned branch): it is the swapped exec row, Provisioned and filed under the record's account, so the owned branch converges its columns and leaves import_gate alone.

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

Run: go test ./internal/provision/ -run 'TestJournal|TestCommitClears|TestSwap|TestReconcile' -count=1 Expected: PASS.

  • [ ] Step 5: Commit
git add internal/provision/
git commit -m "provision: the import journal drives its hosts before the records (Plan 7 Task 4)

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

Task 5: The rollback

Files:

  • Create: internal/provision/seedrollback.go (replaces Task 4's stub), internal/provision/seedrollback_test.go
  • Modify: internal/provision/seedreconcile.go (SeedHooks gains FenceForRollback, LiftFence, RemoveLocal, DeleteReplica, NextFenceGen)

Interfaces:

  • Consumes: reg.CloseImportGate, reg.RestoreImportRoute, reg.DeleteImportJournal, cfg.Seeds.Data, SetRecordSeed, SeedRolledBack.
  • Produces: SeedHooks.FenceForRollback func(ctx context.Context, host string, gen uint64) (proceed bool, err error) (the layout pass's rule, implemented by the host in Task 7); SeedHooks.LiftFence func(host string, gen uint64); SeedHooks.RemoveLocal func(rt registry.Route) error; SeedHooks.DeleteReplica func(ctx context.Context, host string) error; SeedHooks.NextFenceGen func() uint64; rollbackImport(...).

The steps (spec §5.6), each idempotent, resumed by the journal's state:

  1. swapped: CloseImportGate(host, id, NextFenceGen()). The journal is now rolling-back with that generation.
  2. rolling-back: FenceForRollback(host, j.FenceGen); not proceeding: defer (the hook lifted any fence it installed). Proceeding:
  3. RemoveLocal(current row): the database, -wal, -shm, <db>.seed, litestream's metadata.
  4. RestoreImportRoute(host, id): journal cleanup-pending.
  5. LiftFence(host, j.FenceGen).
  6. cleanup-pending: delete seeds/<host>/<id>/snapshot.db (this ID only) and DeleteReplica(host) (the host's instances/ replica, which is this box's to delete while the journal row exists). Never fences or stops anything: the route is the legacy row and its process is the app's own.
  7. DeleteImportJournal(host, id).
  8. When the record still names this ID: CAS to rolled-back (a conflict or a missing record is done).
  • [ ] Step 1: Write the failing tests
package provision

import (
	"context"
	"errors"
	"os"
	"reflect"
	"testing"

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

type rollbackRecorder struct {
	steps    []string
	proceed  bool
	fenceErr error
	gen      uint64
}

func (r *rollbackRecorder) hooks(h *SeedHooks) {
	h.NextFenceGen = func() uint64 { r.gen++; return r.gen }
	h.FenceForRollback = func(_ context.Context, host string, gen uint64) (bool, error) {
		r.steps = append(r.steps, "fence")
		return r.proceed, r.fenceErr
	}
	h.LiftFence = func(host string, gen uint64) { r.steps = append(r.steps, "lift") }
	h.RemoveLocal = func(rt registry.Route) error { r.steps = append(r.steps, "remove-local:"+rt.Kind); return nil }
	h.DeleteReplica = func(_ context.Context, host string) error { r.steps = append(r.steps, "delete-replica"); return nil }
}

func TestRollbackAfterAbortRestoresTheRowExactly(t *testing.T) {
	s, sqid := setup(t)
	rec := importRecord(t, s, sqid)
	reg := regWith(t, addrRow(rec, sqid))
	original, _ := reg.Get(rec.Host)
	cfg := importCfg(t, nil)
	rr := &rollbackRecorder{proceed: true}
	rr.hooks(cfg.Seeds)
	cfg.Seeds.Data.Put(ctx, seeds.SnapshotKey(rec.Host, rec.Seed.ID), []byte("db"), "")
	other, _ := seeds.NewID()
	cfg.Seeds.Data.Put(ctx, seeds.SnapshotKey(rec.Host, other), []byte("db"), "")
	Reconcile(ctx, s, reg, cfg) // swap
	if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(rec.Seed.ID)); err != nil {
		t.Fatal(err)
	}
	if err := Reconcile(ctx, s, reg, cfg); err != nil {
		t.Fatal(err)
	}
	if got := mustGet(t, reg, rec.Host); !reflect.DeepEqual(*got, *original) {
		t.Fatalf("restored %+v, want %+v", got, original)
	}
	if j, _ := reg.ImportJournal(rec.Host); j != nil {
		t.Fatalf("journal = %+v", j)
	}
	want := []string{"fence", "remove-local:instance", "lift", "delete-replica"}
	if !reflect.DeepEqual(rr.steps, want) {
		t.Fatalf("steps = %q, want %q", rr.steps, want)
	}
	if _, _, err := cfg.Seeds.Data.Get(ctx, seeds.SnapshotKey(rec.Host, rec.Seed.ID)); !errors.Is(err, os.ErrNotExist) {
		t.Fatal("this seed's snapshot survived")
	}
	if _, _, err := cfg.Seeds.Data.Get(ctx, seeds.SnapshotKey(rec.Host, other)); err != nil {
		t.Fatal("another seed's snapshot was deleted")
	}
	r, _ := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
	if r.Seed.State != seeds.StateRolledBack {
		t.Fatalf("record = %s", r.Seed.State)
	}
}

func TestRollbackForAGoneOrReplacedRecord(t *testing.T) {
	for _, mutate := range []string{"deleted", "another seed"} {
		t.Run(mutate, func(t *testing.T) {
			s, sqid := setup(t)
			rec := importRecord(t, s, sqid)
			reg := regWith(t, addrRow(rec, sqid))
			original, _ := reg.Get(rec.Host)
			cfg := importCfg(t, nil)
			(&rollbackRecorder{proceed: true}).hooks(cfg.Seeds)
			Reconcile(ctx, s, reg, cfg)
			if err := DeleteRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil {
				t.Fatal(err)
			}
			if mutate == "another seed" {
				importRecord(t, s, sqid) // the same host, a new seed id, state ready
			}
			if err := Reconcile(ctx, s, reg, cfg); err != nil {
				t.Fatal(err)
			}
			if got := mustGet(t, reg, rec.Host); !reflect.DeepEqual(*got, *original) {
				t.Fatalf("restored %+v", got)
			}
		})
	}
}

func TestRollbackDefersWhenTheFenceIsNotOurs(t *testing.T) {
	s, sqid := setup(t)
	rec := importRecord(t, s, sqid)
	reg := regWith(t, addrRow(rec, sqid))
	cfg := importCfg(t, nil)
	rr := &rollbackRecorder{proceed: false}
	rr.hooks(cfg.Seeds)
	Reconcile(ctx, s, reg, cfg)
	SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(rec.Seed.ID))
	Reconcile(ctx, s, reg, cfg)
	j, _ := reg.ImportJournal(rec.Host)
	got := mustGet(t, reg, rec.Host)
	if j == nil || j.State != registry.JournalRollingBack || got.ImportGate != registry.ImportGateClosed || got.Kind != "instance" {
		t.Fatalf("after a deferred fence: journal %+v row %+v; want rolling-back, gate closed, row kept", j, got)
	}
	// Next pass the fence proceeds, with the SAME generation.
	gen := j.FenceGen
	rr.proceed = true
	Reconcile(ctx, s, reg, cfg)
	if rr.gen != gen {
		t.Fatalf("a resumed rollback minted a new generation: %d, journal had %d", rr.gen, gen)
	}
	if j, _ := reg.ImportJournal(rec.Host); j != nil {
		t.Fatalf("journal = %+v", j)
	}
}

func TestCleanupPendingNeverFences(t *testing.T) {
	s, sqid := setup(t)
	rec := importRecord(t, s, sqid)
	reg := regWith(t, addrRow(rec, sqid))
	cfg := importCfg(t, nil)
	rr := &rollbackRecorder{proceed: true}
	rr.hooks(cfg.Seeds)
	Reconcile(ctx, s, reg, cfg)
	SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(rec.Seed.ID))
	// Crash after the route restore, before the bucket cleanup.
	if err := reg.CloseImportGate(rec.Host, rec.Seed.ID, 9); err != nil {
		t.Fatal(err)
	}
	if err := reg.RestoreImportRoute(rec.Host, rec.Seed.ID); err != nil {
		t.Fatal(err)
	}
	rr.steps = nil
	if err := Reconcile(ctx, s, reg, cfg); err != nil {
		t.Fatal(err)
	}
	if !reflect.DeepEqual(rr.steps, []string{"delete-replica"}) {
		t.Fatalf("steps = %q; a cleanup-pending resume fenced, stopped or removed something", rr.steps)
	}
}

func TestJournalDoesNotRollBackOnAReadFailure(t *testing.T) {
	s, sqid := setup(t)
	rec := importRecord(t, s, sqid)
	reg := regWith(t, addrRow(rec, sqid))
	cfg := importCfg(t, nil)
	rr := &rollbackRecorder{proceed: true}
	rr.hooks(cfg.Seeds)
	Reconcile(ctx, s, reg, cfg)
	// The record body becomes unreadable: protected, never "gone".
	putReplace(t, s, RecordKey(rec.Account, rec.App, rec.Host), []byte("{not json"))
	if err := Reconcile(ctx, s, reg, cfg); err != nil {
		t.Fatal(err)
	}
	if len(rr.steps) != 0 || rr.gen != 0 {
		t.Fatalf("an unreadable record started a rollback: steps %q", rr.steps)
	}
	if j, _ := reg.ImportJournal(rec.Host); j == nil || j.State != registry.JournalSwapped {
		t.Fatalf("journal = %+v", j)
	}
	if got := mustGet(t, reg, rec.Host); got.Kind != "instance" {
		t.Fatal("the removal phase touched a journaled host")
	}
}
  • [ ] Step 2: Run the tests to verify they fail

Run: go test ./internal/provision/ -run 'TestRollback|TestCleanupPending|TestJournalDoesNotRollBack' -count=1 Expected: FAIL: the stub logs and nothing is restored (the read-failure test passes already; it guards Task 4's pass from here on).

  • [ ] Step 3: Write seedrollback.go (delete the stub from seedreconcile.go)
package provision

import (
	"context"
	"errors"
	"log"
	"os"

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

// rollbackImport is spec §5.6's rollback, driven by the journal and not the
// record (codex R2-7): each step idempotent, the journal's state the
// resume point. rec is nil when the record is gone.
func rollbackImport(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, j registry.ImportJournal, rec *Record) {
	h := cfg.Seeds
	if cfg.DryRun {
		log.Printf("provision: dry-run: would roll back import %s of %s (%s)", j.SeedID, j.Host, j.State)
		return
	}
	if h.FenceForRollback == nil || h.LiftFence == nil || h.RemoveLocal == nil || h.DeleteReplica == nil || h.NextFenceGen == nil {
		log.Printf("provision: %s: import %s cannot roll back on this box: the activator is not wired", j.Host, j.SeedID)
		return
	}
	if j.State == registry.JournalSwapped {
		gen := h.NextFenceGen()
		if err := reg.CloseImportGate(j.Host, j.SeedID, gen); err != nil {
			log.Printf("provision: %s: close the import gate: %v", j.Host, err)
			return
		}
		j.State, j.FenceGen = registry.JournalRollingBack, gen
	}
	if j.State == registry.JournalRollingBack {
		proceed, err := h.FenceForRollback(ctx, j.Host, j.FenceGen)
		if !proceed {
			log.Printf("provision: %s: import %s rollback deferred: the host is not fenced and stopped yet (%v)", j.Host, j.SeedID, err)
			return
		}
		row, err := reg.Get(j.Host)
		if err != nil {
			log.Printf("provision: %s: read the swapped row: %v", j.Host, err)
			return
		}
		if row != nil && row.Kind == "instance" && row.DBPath != "" && row.Socket != "" {
			if err := h.RemoveLocal(*row); err != nil {
				log.Printf("provision: %s: remove the import's local data: %v", j.Host, err)
				return
			}
		}
		if err := reg.RestoreImportRoute(j.Host, j.SeedID); err != nil {
			log.Printf("provision: %s: restore the original route: %v", j.Host, err)
			return
		}
		h.LiftFence(j.Host, j.FenceGen)
		j.State = registry.JournalCleanupPending
		log.Printf("provision: %s: import %s rolled back to its original route", j.Host, j.SeedID)
	}
	if j.State != registry.JournalCleanupPending {
		return
	}
	if err := h.Data.Delete(ctx, seeds.SnapshotKey(j.Host, j.SeedID)); err != nil && !errors.Is(err, os.ErrNotExist) {
		log.Printf("provision: %s: delete import %s's snapshot: %v", j.Host, j.SeedID, err)
		return
	}
	if err := h.DeleteReplica(ctx, j.Host); err != nil {
		log.Printf("provision: %s: delete the import's replica: %v", j.Host, err)
		return
	}
	if err := reg.DeleteImportJournal(j.Host, j.SeedID); err != nil {
		log.Printf("provision: %s: retire the import journal: %v", j.Host, err)
		return
	}
	if rec != nil && rec.Seed != nil && rec.Seed.ID == j.SeedID {
		if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(j.SeedID)); err != nil &&
			!errors.Is(err, ErrSeedConflict) && !errors.Is(err, os.ErrNotExist) {
			log.Printf("provision: %s: mark import %s rolled back: %v (the no-journal path finishes it)", j.Host, j.SeedID, err)
		}
	}
}

A crash after step 7 leaves an aborted record and no journal row; Plan 4's finishAbortedSeed then deletes this seed's snapshot (already gone) and CASes rolled-back. A record whose seed is still swapped or placed but whose journal row was rolled back because the record named another ID keeps its own state; SeedRolledBack is only applied to a record naming this ID.

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

Run: go test ./internal/provision/ -run 'TestRollback|TestCleanupPending|TestJournal|TestSwap|TestReconcile' -count=1 Expected: PASS.

  • [ ] Step 5: Commit
git add internal/provision/
git commit -m "provision: the fenced, journal-driven import rollback (Plan 7 Task 5)

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

Task 6: The gate at the edge and in the schedule runner

Files:

  • Create: internal/edge/importgate.go, internal/edge/importgate_test.go
  • Modify: internal/edge/edge.go:1559-1562 (one call after the door-scope check), internal/schedule/runner.go:196 (skip gated rows)
  • Test: internal/schedule/runner_test.go

Interfaces:

  • Consumes: seeds.TokenMatches, registry.Route.ImportGate, MarkAnswered (internal/edge/answered.go).
  • Produces: ImportVerifyHeader = "Carlos-Import-Verify"; (e *Edge) importGateAdmit(w http.ResponseWriter, r *http.Request, rt registry.Route) bool.

A row with ImportGate set: a request whose Carlos-Import-Verify hashes to it passes, with the header removed; every other request gets 503, Retry-After: 5, Cache-Control: no-store and SD-N99. Placed after the door's scope check and before the meter, the password gate, the cache and the activator, so nothing ordinary is served, cached or woken. The edge door re-enters ServeHTTP (door.go:91), so the same check covers it. The schedule runner skips a gated row in its pre-filter whether or not the hold wiring is armed.

  • [ ] Step 1: Write the failing tests

internal/edge/importgate_test.go:

package edge

import (
	"net/http"
	"net/http/httptest"
	"testing"

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

func TestImportGateAtTheEdgeAndTheDoor(t *testing.T) {
	var sawHeader string
	backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		sawHeader = r.Header.Get(ImportVerifyHeader)
		w.Write([]byte("served"))
	}))
	defer backend.Close()
	token, hash, _ := seeds.NewVerifyToken()
	reg := openReg(t)
	reg.Upsert(registry.Route{Host: "old.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready"})
	// The gate is set by the swap transaction; for the edge test, set the
	// column directly through the registry's swap with an original row.
	stored, _ := reg.Get("old.example.com")
	next := *stored
	next.ImportGate = hash
	if err := reg.SwapForImport(registry.ImportJournal{Host: "old.example.com", SeedID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", Original: *stored}, next); err != nil {
		t.Fatal(err)
	}
	e, err := New(reg, nil)
	if err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(e)
	defer srv.Close()
	get := func(tok string) *http.Response {
		req, _ := http.NewRequest(http.MethodGet, srv.URL+"/up", nil)
		req.Host = "old.example.com"
		if tok != "" {
			req.Header.Set(ImportVerifyHeader, tok)
		}
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			t.Fatal(err)
		}
		return res
	}
	for _, tok := range []string{"", "wrong"} {
		res := get(tok)
		if res.StatusCode != http.StatusServiceUnavailable || res.Header.Get("Retry-After") != "5" {
			t.Fatalf("token %q = %d retry %q, want 503 and 5", tok, res.StatusCode, res.Header.Get("Retry-After"))
		}
	}
	res := get(token)
	if res.StatusCode != http.StatusOK || sawHeader != "" {
		t.Fatalf("verify = %d, header at the app %q; want served without the header", res.StatusCode, sawHeader)
	}
	// Closed during a rollback: even the token is refused.
	if err := reg.CloseImportGate("old.example.com", "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", 1); err != nil {
		t.Fatal(err)
	}
	if err := e.Reload(); err != nil {
		t.Fatal(err)
	}
	if res := get(token); res.StatusCode != http.StatusServiceUnavailable {
		t.Fatalf("a closed gate let the token through: %d", res.StatusCode)
	}
}

The edge door path goes through the same ServeHTTP (door.go:91); add a case to door_test.go with newDoorScene(t) that gates one of its routes the same way and asserts wantUnknownHost is not what comes back but the 503 (the door caller passes the scope check, then meets the gate).

In internal/schedule/runner_test.go, beside the hold tests (runnerFixture :69, holdHost :984, deliverFunc :1100):

func TestTickSkipsGatedRows(t *testing.T) {
	reg, r := runnerFixture(t, nil)
	now := at("2026-08-23T13:07:00Z")
	r.Now = func() time.Time { return now }
	delivered := 0
	r.Deliver = deliverFunc(func(registry.Route, *http.Request) (int, error) { delivered++; return 204, nil })
	stored, _ := reg.Get(holdHost)
	next := *stored
	next.ImportGate = strings.Repeat("a", 64)
	if err := reg.SwapForImport(registry.ImportJournal{Host: holdHost, SeedID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", Original: *stored}, next); err != nil {
		t.Fatal(err)
	}
	reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1})
	r.tick() // r.Gate is nil: the hold wiring is off, and a gated row is still skipped
	if delivered != 0 {
		t.Fatalf("delivered %d ticks to a gated row", delivered)
	}
	if err := reg.CommitImport(holdHost, "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"); err != nil {
		t.Fatal(err)
	}
	r.tick()
	if delivered != 1 {
		t.Fatalf("after commit delivered %d, want 1", delivered)
	}
}

(add strings to the file's imports if it lacks it).

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

Run: go test ./internal/edge/ -run 'TestImportGate' -count=1; go test ./internal/schedule/ -run 'TestTickSkipsGatedRows' -count=1 Expected: FAIL.

  • [ ] Step 3: Write the gate and the two calls

internal/edge/importgate.go:

package edge

import (
	"net/http"

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

// ImportVerifyHeader carries an uncommitted import's verify token
// (seeded-instances spec §5.4).
const ImportVerifyHeader = "Carlos-Import-Verify"

// importGateAdmit is the import gate (spec §5.4, codex 15). A row without a
// gate passes. A gated row passes only a request whose verify token hashes
// to the gate, and that request reaches the app without the header. Every
// other request is answered 503 with Retry-After: 5, before the meter, the
// password gate, the cache and the activator: nothing ordinary is served,
// cached or woken until commit.
func (e *Edge) importGateAdmit(w http.ResponseWriter, r *http.Request, rt registry.Route) bool {
	if rt.ImportGate == "" {
		return true
	}
	if seeds.TokenMatches(rt.ImportGate, r.Header.Get(ImportVerifyHeader)) {
		r.Header.Del(ImportVerifyHeader)
		return true
	}
	w.Header().Set("Retry-After", "5")
	w.Header().Set("Cache-Control", "no-store")
	http.Error(w, "this instance is being imported. Try again in a few seconds.", http.StatusServiceUnavailable)
	return false
}

In edge.go, right after the door-scope check (if isDoor && !doorOwnsRoute(door, up.route) { … }):

	if !e.importGateAdmit(w, r, up.route) {
		return
	}

In runner.go Tick, before the if r.holdsArmed() && registry.IsHold(rt.Status) pre-filter:

		// An uncommitted import's row gets no ticks (seeded-instances spec
		// §5.4): only verify requests reach it until commit.
		if rt.ImportGate != "" {
			continue
		}
  • [ ] Step 4: Run the tests to verify they pass

Run: go test ./internal/edge/ -run 'TestImportGate|TestDoor|TestGate' -count=1 && go test ./internal/schedule/ -count=1 Expected: PASS.

  • [ ] Step 5: Wire SD-N99 and commit
git add internal/edge/importgate.go internal/edge/importgate_test.go internal/edge/edge.go internal/edge/door_test.go internal/schedule/ approved_seeded_copy_test.go
git commit -m "edge and schedule: the import gate (Plan 7 Task 6)

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

Task 7: The box's wiring, and the protocol raised

Files:

  • Modify: internal/host/seedwiring.go (fenceForRollback, removeSeedLocal, deleteReplica, seedSweptAtStart), internal/host/agent.go (the arming of Plan 4 Task 6 gains the new hooks; the recoverLayout closure records swept)
  • Modify: internal/seeds/floor.go (ProtocolVersion = 1), internal/seeds/floor_test.go
  • Test: internal/host/seedwiring_test.go, internal/seeds/seeds_test.go (the gate constant)

Interfaces:

  • Consumes: activator.Service's FenceAndStopIfUnfenced, LiftFence, FenceGeneration, Quiesced, StopUncertain; activator.ErrFenceHeld, activator.ErrStopUnproven; activator.PurgeLocalDBArtifacts, activator.SeedMarkerSuffix; instancefs.RemoveBeneath; eb.NestedOK; eb.Cfg.hibernatePrefix (unexported: use activator.HibernatePrefix(cfg.Prefix, host), activator.go:141).
  • Produces: type rollbackFence interface{ FenceAndStopIfUnfenced(ctx context.Context, host string, generation uint64) (*activator.StopEvidence, error); LiftFence(host string, generation uint64) bool; FenceGeneration(host string) uint64; Quiesced(host string) bool; StopUncertain(host string) bool }; fenceForRollback(f rollbackFence, swept func() bool) func(ctx context.Context, host string, gen uint64) (bool, error); var seedSweptAtStart atomic.Bool.

fenceForRollback is internal/layout/pass.go:654-680's rule (spec §5.6 step 2): our fence already (by generation): proceed; otherwise FenceAndStopIfUnfenced under a 30-second deadline; nil: proceed; ErrFenceHeld: defer, touching nothing; ErrStopUnproven with the box swept at start, the host quiesced and its stop not uncertain: proceed; anything else: lift this attempt's fence and defer.

  • [ ] Step 1: Write the failing tests

Append to internal/host/seedwiring_test.go:

type fakeFence struct {
	gens        map[string]uint64
	err         error
	quiesced    bool
	uncertain   bool
	lifted      []uint64
}

func (f *fakeFence) FenceAndStopIfUnfenced(_ context.Context, host string, gen uint64) (*activator.StopEvidence, error) {
	if f.err == nil || errors.Is(f.err, activator.ErrStopUnproven) {
		f.gens[host] = gen
	}
	return nil, f.err
}
func (f *fakeFence) LiftFence(host string, gen uint64) bool {
	f.lifted = append(f.lifted, gen)
	delete(f.gens, host)
	return true
}
func (f *fakeFence) FenceGeneration(host string) uint64 { return f.gens[host] }
func (f *fakeFence) Quiesced(string) bool               { return f.quiesced }
func (f *fakeFence) StopUncertain(string) bool          { return f.uncertain }

func TestRollbackFenceAfterRestart(t *testing.T) {
	ctx := context.Background()
	swept := true
	f := &fakeFence{gens: map[string]uint64{}, err: activator.ErrStopUnproven, quiesced: true}
	fence := fenceForRollback(f, func() bool { return swept })
	if ok, _ := fence(ctx, "h", 7); !ok {
		t.Fatal("cold, swept, quiesced: want proceed")
	}
	// Ours already (an earlier pass installed it): proceed without asking.
	f.err = errors.New("must not be called")
	if ok, _ := fence(ctx, "h", 7); !ok {
		t.Fatal("our own fence: want proceed")
	}
	// Not swept at start: lift and defer.
	f = &fakeFence{gens: map[string]uint64{}, err: activator.ErrStopUnproven, quiesced: true}
	swept = false
	fence = fenceForRollback(f, func() bool { return swept })
	if ok, _ := fence(ctx, "h", 8); ok || len(f.lifted) != 1 || f.lifted[0] != 8 {
		t.Fatalf("not swept: proceed %v lifted %v", ok, f.lifted)
	}
	// Another operation's fence: defer and touch nothing.
	f = &fakeFence{gens: map[string]uint64{}, err: activator.ErrFenceHeld}
	fence = fenceForRollback(f, func() bool { return true })
	if ok, _ := fence(ctx, "h", 9); ok || len(f.lifted) != 0 {
		t.Fatalf("held: proceed %v lifted %v", ok, f.lifted)
	}
}

internal/seeds/seeds_test.go: if ClosedGate != registry.ImportGateClosed { t.Fatal(…) } (seeds may import registry in a test only if registry does not import seeds; it does not).

internal/seeds/floor_test.go: replace TestProtocolStartsBelowWhatTheConsoleRequires with TestProtocolMeetsWhatTheConsoleRequires asserting ProtocolVersion >= RequiredProtocol.

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

Run: go test ./internal/host/ -run TestRollbackFenceAfterRestart -count=1; go test ./internal/seeds/ -count=1 Expected: FAIL.

  • [ ] Step 3: Write the hooks and arm them

Append to seedwiring.go:

// seedSweptAtStart is whether the startup unit sweep succeeded, for the
// import rollback's stop proof (the layout pass reads the same fact).
var seedSweptAtStart atomic.Bool

type rollbackFence interface {
	FenceAndStopIfUnfenced(ctx context.Context, host string, generation uint64) (*activator.StopEvidence, error)
	LiftFence(host string, generation uint64) bool
	FenceGeneration(host string) uint64
	Quiesced(host string) bool
	StopUncertain(host string) bool
}

// fenceForRollback is the import rollback's step 2 (seeded-instances spec
// §5.6), internal/layout/pass.go's rule for the same API.
func fenceForRollback(f rollbackFence, swept func() bool) func(ctx context.Context, host string, gen uint64) (bool, error) {
	return func(ctx context.Context, host string, gen uint64) (bool, error) {
		if f.FenceGeneration(host) == gen {
			return true, nil
		}
		fctx, cancel := context.WithTimeout(ctx, 30*time.Second)
		defer cancel()
		_, err := f.FenceAndStopIfUnfenced(fctx, host, gen)
		switch {
		case err == nil:
			return true, nil
		case errors.Is(err, activator.ErrFenceHeld):
			return false, err
		case errors.Is(err, activator.ErrStopUnproven) && swept() && f.Quiesced(host) && !f.StopUncertain(host):
			return true, nil
		}
		f.LiftFence(host, gen)
		return false, err
	}
}

// removeSeedLocal is the rollback's step 3: the database, its WAL and SHM,
// the seed marker and litestream's metadata. The files go through
// instancefs; litestream's metadata directories through
// PurgeLocalDBArtifacts (instancefs removes files only).
func removeSeedLocal(rt registry.Route) error {
	dir, base := filepath.Dir(rt.DBPath), filepath.Base(rt.DBPath)
	for _, name := range []string{base, base + "-wal", base + "-shm", base + activator.SeedMarkerSuffix} {
		if err := instancefs.RemoveBeneath(dir, name); err != nil && !errors.Is(err, os.ErrNotExist) {
			return err
		}
	}
	activator.PurgeLocalDBArtifacts(rt.DBPath, false)
	return nil
}

// deleteReplica is the rollback's step 6 for the host's instances/ replica.
func deleteReplica(data release.Store, prefix string) func(ctx context.Context, host string) error {
	return func(ctx context.Context, host string) error {
		keys, err := data.List(ctx, activator.HibernatePrefix(prefix, host)+"/")
		if err != nil {
			return err
		}
		for _, k := range keys {
			if err := data.Delete(ctx, k); err != nil && !errors.Is(err, os.ErrNotExist) {
				return err
			}
		}
		return nil
	}
}

(imports: path/filepath, time, internal/activator, internal/instancefs, internal/registry.) instancefs is Linux-only at run time; removeSeedLocal compiles everywhere and returns errors.ErrUnsupported off Linux, where no box runs.

In agent.go, in Plan 4's seed arming block, replace armSeedHooks(&provision.SeedHooks{Data: data}) with:

			armSeedHooks(&provision.SeedHooks{
				Data:             data,
				NestedOK:         eb.NestedOK,
				LegacyOwners:     func(ctx context.Context) (*seeds.LegacyOwners, error) { o, _, err := seeds.ReadLegacyOwners(ctx, deploy); return o, err },
				FenceForRollback: fenceForRollback(svc, seedSweptAtStart.Load),
				LiftFence:        func(host string, gen uint64) { svc.LiftFence(host, gen) },
				RemoveLocal:      removeSeedLocal,
				DeleteReplica:    deleteReplica(data, cfg.Prefix),
				NextFenceGen:     func() uint64 { return uint64(time.Now().UnixNano()) },
			})

In the recoverLayout closure passed to startActivator (agent.go:427), first line: seedSweptAtStart.Store(swept). With claims armed the agent starts the activator without a sweep (agent.go:418); seedSweptAtStart stays false there, and the seed doors refuse claim-armed boxes anyway.

internal/seeds/floor.go: const ProtocolVersion = 1, its comment: "1: this build places, swaps and rolls back a seed (Plans 4 and 7)."

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

Run: go test ./internal/host/ -run 'TestRollbackFence|TestSeed|TestWithSeedsFields' -count=1 && go test ./internal/seeds/ -count=1 && go test ./internal/console/ -run 'TestSeedRegionReady|TestFrom|TestImport' -count=1 Expected: PASS. The console tests write their own edges files, so the raised protocol changes nothing there.

  • [ ] Step 5: Commit
git add internal/host/ internal/seeds/
git commit -m "host: wire the import swap and rollback; seeds protocol 1 (Plan 7 Task 7)

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

Task 8: 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/registry/ ./internal/seeds/ ./internal/provision/ ./internal/edge/ ./internal/schedule/ ./internal/host/ . -count=1

Expected: builds clean; tests PASS; the moves copy gate passes.

---

Self-review

  • Spec coverage: §5.4's journal first (Task 4), shape with the shapeMismatch protected columns (Task 3), ownership with the assignment (Tasks 2, 3), layout with nested derived explicitly (Task 3), the swap transaction with the complete original row (Tasks 1, 3), crash resume (Task 4), the gate at the edge, the door and the runner (Task 6; carlos run is the run series'), commit's clear in one transaction (Tasks 1, 4); §5.6's rollback triggers (aborted, another ID, gone), steps 1 to 8 with the layout pass's fence handling, cleanup-pending resume that never fences (Task 5), record CAS last and the no-journal finish after a crash (Plan 4); §5.7 (Tasks 1, 4); §6.3 box half (Task 3); §9's reconcile and gate tests (Tasks 3 to 6).
  • Not built here: the claim CAS at the swap (claims are refused, §5.4), and multi-box regions (refused by the import door).
  • The registry's ImportGateClosed and seeds.ClosedGate are one value, pinned by Task 7's test.