carlos / platform Public

copy gate for the moves series: the pinned inventory, the ship check (§20)

The record is a markdown table, so it has its own parser. Checks: the pinned
inventory both ways, approved rows applied in their files, a pending row keeps
moves.DefaultEnabled off, no em dash in any row, and a mechanical scan that
every jsonError, Check Reason and phase 1 copy table sentence has a row.
Negative fixtures under testdata/moves-copy. One pending row added by the
scan: MV-N147 (invalid region).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c2312e732c18751e6d5ee52fccf6b242632333ce parent 214539f
7 files changed, +1233 −0
  • approved_moves_copy_test.go +1200 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +1 −0
  • testdata/moves-copy/changed-text/applied.go +7 −0
  • testdata/moves-copy/changed-text/record.md +7 −0
  • testdata/moves-copy/em-dash.md +6 −0
  • testdata/moves-copy/missing-id.md +6 −0
  • testdata/moves-copy/pending-default-on.md +6 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
new file mode 100644
index 000000000..fbb3b6661
--- /dev/null
+++ b/approved_moves_copy_test.go
@@ -0,0 +1,1200 @@
+// approved_moves_copy_test.go
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/moves"
+)
+
+// The copy gate of the moves series (Phase 1 plan 1, Task 15), beside the
+// nearest-region gate in approved_copy_test.go. The record is a markdown
+// table with a status column, not JSON, so it has its own parser; the checks
+// are the same in spirit:
+//
+// - TestMovesCopyRecordIsComplete: the record names exactly the ids the
+// pinned inventory names, so a deleted row is caught;
+// - TestMovesApprovedCopyIsApplied: an approved row's text is in the file
+// the row names;
+// - TestPendingCopyKeepsTheSwitchOff: while a row is pending,
+// moves.DefaultEnabled is false;
+// - TestMovesStringsAreRecorded: every operator-visible sentence the moves
+// code emits has a row (the mechanical half of review finding F13).
+
+const movesCopyRecordPath = "docs/superpowers/plans/2026-10-06-moves-phase1-copy.md"
+
+// movesCopyIDs is the record's inventory, pinned as a literal: the spec's
+// M-C1 to M-C22 (rev 7.21) and every MV-N<n> the plans introduce. Each plan's
+// "Gate and copy" task appends its ids here as it appends its rows to the
+// record. The record and this literal must agree both ways.
+var movesCopyIDs = []string{
+ "M-C1", "M-C2", "M-C3", "M-C4", "M-C5", "M-C6", "M-C7", "M-C8", "M-C9",
+ "M-C10", "M-C11", "M-C12", "M-C13", "M-C14", "M-C15", "M-C16", "M-C17",
+ "M-C18", "M-C19", "M-C20", "M-C21", "M-C22", "MV-N1", "MV-N2", "MV-N3",
+ "MV-N4", "MV-N5", "MV-N6", "MV-N7", "MV-N8", "MV-N9", "MV-N10", "MV-N11",
+ "MV-N12", "MV-N13", "MV-N14", "MV-N15", "MV-N16", "MV-N17", "MV-N18",
+ "MV-N19", "MV-N20", "MV-N21", "MV-N22", "MV-N23", "MV-N24", "MV-N25",
+ "MV-N26", "MV-N27", "MV-N28", "MV-N29", "MV-N30", "MV-N31", "MV-N32",
+ "MV-N33", "MV-N34", "MV-N35", "MV-N36", "MV-N37", "MV-N38", "MV-N39",
+ "MV-N40", "MV-N41", "MV-N42", "MV-N43", "MV-N44", "MV-N45", "MV-N46",
+ "MV-N47", "MV-N48", "MV-N49", "MV-N50", "MV-N51", "MV-N52", "MV-N53",
+ "MV-N54", "MV-N55", "MV-N56", "MV-N57", "MV-N58", "MV-N59", "MV-N60",
+ "MV-N61", "MV-N62", "MV-N63", "MV-N64", "MV-N65", "MV-N66", "MV-N67",
+ "MV-N68", "MV-N69", "MV-N70", "MV-N71", "MV-N72", "MV-N73", "MV-N74",
+ "MV-N75", "MV-N76", "MV-N77", "MV-N78", "MV-N79", "MV-N80", "MV-N81",
+ "MV-N82", "MV-N83", "MV-N84", "MV-N85", "MV-N86", "MV-N87", "MV-N88",
+ "MV-N89", "MV-N90", "MV-N91", "MV-N92", "MV-N93", "MV-N94", "MV-N95",
+ "MV-N96", "MV-N97", "MV-N98", "MV-N99", "MV-N100", "MV-N101", "MV-N102",
+ "MV-N103", "MV-N104", "MV-N105", "MV-N106", "MV-N107", "MV-N108",
+ "MV-N109", "MV-N110", "MV-N111", "MV-N112", "MV-N113", "MV-N114",
+ "MV-N115", "MV-N116", "MV-N117", "MV-N118", "MV-N119", "MV-N120",
+ "MV-N121", "MV-N122", "MV-N123", "MV-N124", "MV-N125", "MV-N126",
+ "MV-N127", "MV-N128", "MV-N129", "MV-N130", "MV-N131", "MV-N132",
+ "MV-N133", "MV-N134", "MV-N135", "MV-N136", "MV-N137", "MV-N138",
+ "MV-N139", "MV-N140", "MV-N141", "MV-N142", "MV-N143", "MV-N144",
+ "MV-N145", "MV-N146", "MV-N147",
+}
+
+// copyRow is one row of the record.
+type copyRow struct {
+ ID, File, Text, Status string
+}
+
+var movesCopyIDPattern = regexp.MustCompile(`^(M-C|MV-N)[0-9]+$`)
+
+// parseMovesCopyRecord reads the record's tables. A data row is a line that
+// follows a `| id | file | text | status |` header; the separator line is
+// skipped, and prose and headings are not rows. The text cell may hold a
+// pipe: the id and file are the first two cells and the status is the last.
+func parseMovesCopyRecord(path string) ([]copyRow, error) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ return parseMovesCopyTable(string(raw), path)
+}
+
+func parseMovesCopyTable(src, name string) ([]copyRow, error) {
+ var rows []copyRow
+ seen := map[string]int{}
+ inTable := false
+ for n, line := range strings.Split(src, "\n") {
+ line = strings.TrimSpace(line)
+ if !strings.HasPrefix(line, "|") {
+ inTable = false
+ continue
+ }
+ at := fmt.Sprintf("%s:%d", name, n+1)
+ if !strings.HasSuffix(line, "|") || len(line) < 2 {
+ return nil, fmt.Errorf("%s: a table row must end with a pipe: %q", at, line)
+ }
+ raw := strings.Split(line[1:len(line)-1], "|")
+ cells := make([]string, len(raw))
+ for i := range raw {
+ cells[i] = strings.TrimSpace(raw[i])
+ }
+ if len(cells) >= 4 && cells[0] == "id" {
+ if cells[1] != "file" || cells[2] != "text" || cells[len(cells)-1] != "status" {
+ return nil, fmt.Errorf("%s: the header must be | id | file | text | status |: %q", at, line)
+ }
+ inTable = true
+ continue
+ }
+ if strings.Trim(strings.Join(cells, ""), "-: ") == "" {
+ continue // the separator line
+ }
+ if !inTable {
+ return nil, fmt.Errorf("%s: a row outside a table that starts with an id/file/text/status header: %q", at, line)
+ }
+ if len(cells) < 4 {
+ return nil, fmt.Errorf("%s: a row needs id, file, text and status cells, got %d: %q", at, len(cells), line)
+ }
+ last := len(cells) - 1
+ r := copyRow{
+ ID: cells[0],
+ File: cells[1],
+ Text: strings.TrimSpace(strings.Join(raw[2:last], "|")),
+ Status: cells[last],
+ }
+ if !movesCopyIDPattern.MatchString(r.ID) {
+ return nil, fmt.Errorf("%s: %q is not an M-C<n> or MV-N<n> id", at, r.ID)
+ }
+ if r.File == "" || r.Text == "" {
+ return nil, fmt.Errorf("%s: %s has an empty file or text cell", at, r.ID)
+ }
+ if r.Status != "pending" && r.Status != "approved" {
+ return nil, fmt.Errorf("%s: %s has status %q, which is neither pending nor approved", at, r.ID, r.Status)
+ }
+ if first, dup := seen[r.ID]; dup {
+ return nil, fmt.Errorf("%s: %s is already a row at line %d", at, r.ID, first)
+ }
+ seen[r.ID] = n + 1
+ rows = append(rows, r)
+ }
+ return rows, nil
+}
+
+// checkMovesInventory compares the record's ids with the pinned inventory,
+// both ways, and returns what differs.
+func checkMovesInventory(rows []copyRow, want []string) []string {
+ have := map[string]bool{}
+ for _, r := range rows {
+ have[r.ID] = true
+ }
+ pinned := map[string]bool{}
+ var out []string
+ for _, id := range want {
+ if pinned[id] {
+ out = append(out, fmt.Sprintf("the pinned inventory names %s twice", id))
+ }
+ pinned[id] = true
+ if !have[id] {
+ out = append(out, fmt.Sprintf("the record has no row %s, which the pinned inventory names", id))
+ }
+ }
+ var extra []string
+ for id := range have {
+ if !pinned[id] {
+ extra = append(extra, id)
+ }
+ }
+ sort.Strings(extra)
+ for _, id := range extra {
+ out = append(out, fmt.Sprintf("the record has row %s, which the pinned inventory does not name (add it to movesCopyIDs)", id))
+ }
+ return out
+}
+
+var copyPlaceholder = regexp.MustCompile(`<[^<>]*>`)
+
+// checkMovesApplied checks every approved row against the file it names under
+// root. A row's text may carry <placeholders>; the fixed text between them
+// must be present, in order, inside the string literals of a Go file (a
+// short fixed text must be a whole literal, as in copyApplied) or raw in any
+// other file.
+func checkMovesApplied(rows []copyRow, root string) []string {
+ var out []string
+ for _, r := range rows {
+ if r.Status != "approved" {
+ continue
+ }
+ body, err := os.ReadFile(filepath.Join(root, r.File))
+ if err != nil {
+ out = append(out, fmt.Sprintf("%s: %v", r.ID, err))
+ continue
+ }
+ if !movesTextApplied(r.File, r.Text, body) {
+ out = append(out, fmt.Sprintf("%s: %s does not contain the approved text %q", r.ID, r.File, r.Text))
+ }
+ }
+ return out
+}
+
+func movesTextApplied(file, text string, body []byte) bool {
+ segs := copyPlaceholder.Split(text, -1)
+ if len(segs) == 1 {
+ if !strings.HasSuffix(file, ".go") {
+ return bytes.Contains(body, []byte(text))
+ }
+ // In a Go file the text must be inside a string literal's decoded
+ // value (a whole literal when it is short), never in a comment.
+ return goLiteralMatches(body, func(v string) bool {
+ if len(text) >= shortCopyBytes {
+ return strings.Contains(v, text)
+ }
+ return v == text
+ })
+ }
+ var fixed []string
+ for _, s := range segs {
+ if s != "" {
+ fixed = append(fixed, s)
+ }
+ }
+ if !strings.HasSuffix(file, ".go") {
+ rest := string(body)
+ for _, s := range fixed {
+ i := strings.Index(rest, s)
+ if i < 0 {
+ return false
+ }
+ rest = rest[i+len(s):]
+ }
+ return true
+ }
+ next := 0
+ goLiteralMatches(body, func(v string) bool {
+ for next < len(fixed) {
+ i := strings.Index(v, fixed[next])
+ if i < 0 {
+ break
+ }
+ v = v[i+len(fixed[next]):]
+ next++
+ }
+ return next == len(fixed)
+ })
+ return next == len(fixed)
+}
+
+// checkPendingKeepsSwitchOff is D13: a pending row means the compiled default
+// must be off. When the default is on, every row must be approved.
+func checkPendingKeepsSwitchOff(rows []copyRow, defaultEnabled bool) []string {
+ if !defaultEnabled {
+ return nil
+ }
+ var out []string
+ for _, r := range rows {
+ if r.Status != "approved" {
+ out = append(out, fmt.Sprintf("%s is %s, but moves.DefaultEnabled is true: a pending sentence can reach an operator", r.ID, r.Status))
+ }
+ }
+ return out
+}
+
+// checkMovesNoEmDash fails on an em dash in any row's text. The house voice
+// is a full stop and a new sentence.
+func checkMovesNoEmDash(rows []copyRow) []string {
+ var out []string
+ for _, r := range rows {
+ if strings.ContainsRune(r.Text, '\u2014') {
+ out = append(out, fmt.Sprintf("%s: the text holds an em dash", r.ID))
+ }
+ }
+ return out
+}
+
+func mustParseMovesRecord(t *testing.T) []copyRow {
+ t.Helper()
+ rows, err := parseMovesCopyRecord(movesCopyRecordPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) == 0 {
+ t.Fatal("the record has no rows")
+ }
+ return rows
+}
+
+func reportAll(t *testing.T, problems []string) {
+ t.Helper()
+ for _, p := range problems {
+ t.Error(p)
+ }
+}
+
+// The record names every id the pinned inventory names, once, and nothing
+// else. A record missing from the tree fails here at parse.
+func TestMovesCopyRecordIsComplete(t *testing.T) {
+ reportAll(t, checkMovesInventory(mustParseMovesRecord(t), movesCopyIDs))
+}
+
+// Every approved row's text is applied in the file the row names. While every
+// row is pending this passes vacuously; the fixture test proves it bites.
+func TestMovesApprovedCopyIsApplied(t *testing.T) {
+ reportAll(t, checkMovesApplied(mustParseMovesRecord(t), "."))
+}
+
+// While any row is pending, a default deployment must not speak. The day the
+// record is approved, one commit flips moves.DefaultEnabled and this check's
+// condition inverts: every row must be approved.
+func TestPendingCopyKeepsTheSwitchOff(t *testing.T) {
+ reportAll(t, checkPendingKeepsSwitchOff(mustParseMovesRecord(t), moves.DefaultEnabled))
+}
+
+func TestMovesCopyHasNoEmDash(t *testing.T) {
+ reportAll(t, checkMovesNoEmDash(mustParseMovesRecord(t)))
+}
+
+// ---- the parser ----
+
+func TestParseMovesCopyTable(t *testing.T) {
+ const header = "| id | file | text | status |\n|---|---|---|---|\n"
+ good := header +
+ "| M-C1 | move.go | Moving <host>. | pending |\n" +
+ "| MV-N2 | a/b.go | a | b is kept | approved |\n"
+ rows, err := parseMovesCopyTable("# prose\n\n"+good+"\nprose again\n", "x.md")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []copyRow{
+ {"M-C1", "move.go", "Moving <host>.", "pending"},
+ {"MV-N2", "a/b.go", "a | b is kept", "approved"},
+ }
+ if len(rows) != len(want) {
+ t.Fatalf("rows = %+v", rows)
+ }
+ for i := range want {
+ if rows[i] != want[i] {
+ t.Errorf("row %d = %+v, want %+v", i, rows[i], want[i])
+ }
+ }
+
+ bad := []struct{ name, src, wantErr string }{
+ {"too few cells", header + "| M-C1 | move.go | pending |\n", "needs id, file, text and status"},
+ {"no closing pipe", header + "| M-C1 | move.go | text | pending\n", "must end with a pipe"},
+ {"duplicate id", header + "| M-C1 | a.go | one | pending |\n| M-C1 | a.go | two | pending |\n", "already a row"},
+ {"unknown status", header + "| M-C1 | a.go | one | maybe |\n", "neither pending nor approved"},
+ {"empty status", header + "| M-C1 | a.go | one | |\n", "neither pending nor approved"},
+ {"empty text", header + "| M-C1 | a.go | | pending |\n", "empty file or text"},
+ {"empty file", header + "| M-C1 | | one | pending |\n", "empty file or text"},
+ {"bad id", header + "| X1 | a.go | one | pending |\n", "is not an M-C"},
+ {"row with no header", "| M-C1 | a.go | one | pending |\n", "outside a table"},
+ {"row after the table ended", good + "\nprose\n| M-C3 | a.go | one | pending |\n", "outside a table"},
+ {"a header that is not ours", "| id | place | text | status |\n|---|---|---|---|\n", "the header must be"},
+ }
+ for _, c := range bad {
+ _, err := parseMovesCopyTable(c.src, "x.md")
+ if err == nil || !strings.Contains(err.Error(), c.wantErr) {
+ t.Errorf("%s: err = %v, want one containing %q", c.name, err, c.wantErr)
+ }
+ }
+}
+
+func TestParseMovesCopyRecordMissingFile(t *testing.T) {
+ if _, err := parseMovesCopyRecord("testdata/moves-copy/no-such-record.md"); err == nil {
+ t.Error("a record missing from the tree must fail to parse")
+ }
+}
+
+// ---- the negative fixtures ----
+
+// Each fixture is a record the gate must refuse, and for the right reason.
+func TestMovesCopyGateRefusesTheNegativeFixtures(t *testing.T) {
+ dir := filepath.Join("testdata", "moves-copy")
+
+ t.Run("a record missing an id", func(t *testing.T) {
+ rows, err := parseMovesCopyRecord(filepath.Join(dir, "missing-id.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := checkMovesInventory(rows, []string{"M-C1", "M-C2", "MV-N1"})
+ if len(got) != 1 || !strings.Contains(got[0], "no row MV-N1") {
+ t.Errorf("problems = %q, want exactly the missing MV-N1", got)
+ }
+ })
+
+ t.Run("a record with a row the inventory does not name", func(t *testing.T) {
+ rows, err := parseMovesCopyRecord(filepath.Join(dir, "missing-id.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := checkMovesInventory(rows, []string{"M-C1"})
+ if len(got) != 1 || !strings.Contains(got[0], "does not name") {
+ t.Errorf("problems = %q, want exactly the extra M-C2", got)
+ }
+ })
+
+ t.Run("a row whose text changed after approval", func(t *testing.T) {
+ root := filepath.Join(dir, "changed-text")
+ rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := checkMovesApplied(rows, root)
+ if len(got) != 1 || !strings.Contains(got[0], "MV-N2") {
+ t.Errorf("problems = %q, want exactly MV-N2 (MV-N1 is applied, MV-N3 is pending)", got)
+ }
+ })
+
+ t.Run("a pending row beside a default that is on", func(t *testing.T) {
+ rows, err := parseMovesCopyRecord(filepath.Join(dir, "pending-default-on.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := checkPendingKeepsSwitchOff(rows, true)
+ if len(got) != 1 || !strings.Contains(got[0], "MV-N2 is pending") {
+ t.Errorf("problems = %q, want exactly MV-N2", got)
+ }
+ if got := checkPendingKeepsSwitchOff(rows, false); len(got) != 0 {
+ t.Errorf("a pending row with the default off is the intended state: %q", got)
+ }
+ })
+
+ t.Run("an approved record may have the default on", func(t *testing.T) {
+ rows, err := parseMovesCopyRecord(filepath.Join(dir, "changed-text", "record.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := range rows {
+ rows[i].Status = "approved"
+ }
+ if got := checkPendingKeepsSwitchOff(rows, true); len(got) != 0 {
+ t.Errorf("every row approved: %q", got)
+ }
+ })
+
+ t.Run("an em dash in a row", func(t *testing.T) {
+ rows, err := parseMovesCopyRecord(filepath.Join(dir, "em-dash.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := checkMovesNoEmDash(rows)
+ if len(got) != 1 || !strings.Contains(got[0], "MV-N2") {
+ t.Errorf("problems = %q, want exactly MV-N2", got)
+ }
+ })
+}
+
+func TestMovesTextAppliedPlaceholders(t *testing.T) {
+ const src = "package p\n\nvar a = \"region \" + to + \" is not one of this app's regions: \"\n"
+ cases := []struct {
+ name, file, text string
+ body string
+ want bool
+ }{
+ {"placeholders between literals", "x.go", "region <to> is not one of this app's regions: <list>", src, true},
+ {"fixed text altered", "x.go", "region <to> is not one of this app's zones: <list>", src, false},
+ {"fixed text in the wrong order", "x.go", "regions: <list> region <to>", src, false},
+ {"in a comment only", "x.go", "region <to> is gone", "package p\n\n// region x is gone\nvar a = \"\"\n", false},
+ {"a doc matches raw", "x.md", "region <to> is here", "region x is here", true},
+ {"a doc, altered", "x.md", "region <to> is here", "region x is there", false},
+ }
+ for _, c := range cases {
+ if got := movesTextApplied(c.file, c.text, []byte(c.body)); got != c.want {
+ t.Errorf("%s: = %v, want %v", c.name, got, c.want)
+ }
+ }
+}
+
+// ---- the mechanical scan (F13) ----
+
+// movesScanFiles are the non-test Go files the moves plans touched, from
+// `git diff --name-only c17f39d5..HEAD -- '*.go'` (c17f39d5 is the base of
+// moves-phase1). Each later plan's "Gate and copy" task appends the files it
+// touched; TestMovesScanFilesCoverTheDiff fails when the list falls behind.
+var movesScanFiles = []string{
+ "cmd/console/main.go",
+ "internal/activator/backend_exec.go",
+ "internal/appconfig/configack.go",
+ "internal/appconfig/environments.go",
+ "internal/appconfig/instances.go",
+ "internal/appconfig/renderheld.go",
+ "internal/appconfig/syncexec.go",
+ "internal/boxid/boxidtest/boxidtest.go",
+ "internal/boxid/internal/signtest/signtest.go",
+ "internal/cgroup/strict_linux.go",
+ "internal/cgroup/strict_other.go",
+ "internal/console/apicli.go",
+ "internal/console/apiinstancemove.go",
+ "internal/console/apiinstances.go",
+ "internal/console/authcli.go",
+ "internal/console/boxselect.go",
+ "internal/console/instancesplacement.go",
+ "internal/console/server.go",
+ "internal/edge/door.go",
+ "internal/edge/edge.go",
+ "internal/genselect/select.go",
+ "internal/host/agent.go",
+ "internal/host/edgesfile.go",
+ "internal/host/heldstage.go",
+ "internal/host/movesfloor.go",
+ "internal/host/movesfloor_linux.go",
+ "internal/host/movesfloor_other.go",
+ "internal/host/movesguard.go",
+ "internal/host/movesmeasure.go",
+ "internal/host/movesmeasure_linux.go",
+ "internal/host/movesmeasure_other.go",
+ "internal/host/movespass.go",
+ "internal/host/movesphase1.go",
+ "internal/host/movesreport.go",
+ "internal/host/movesstage.go",
+ "internal/host/systemapply.go",
+ "internal/hostbox/writer.go",
+ "internal/hostclaim/move.go",
+ "internal/lease/endpoint.go",
+ "internal/lease/health.go",
+ "internal/lease/lease.go",
+ "internal/lease/leasetest/bucket.go",
+ "internal/lease/leasetest/s3server.go",
+ "internal/lease/mirror.go",
+ "internal/lease/staging.go",
+ "internal/moves/archive/archive.go",
+ "internal/moves/archive/extract.go",
+ "internal/moves/archive/limits.go",
+ "internal/moves/archive/open_other.go",
+ "internal/moves/archive/open_unix.go",
+ "internal/moves/archive/seal.go",
+ "internal/moves/archive/sync_linux.go",
+ "internal/moves/archive/sync_other.go",
+ "internal/moves/archive/verify.go",
+ "internal/moves/budget.go",
+ "internal/moves/check.go",
+ "internal/moves/envreport.go",
+ "internal/moves/floor.go",
+ "internal/moves/ids.go",
+ "internal/moves/lease.go",
+ "internal/moves/readiness.go",
+ "internal/moves/source.go",
+ "internal/moves/staging.go",
+ "internal/moves/switch.go",
+ "internal/moves/timers.go",
+ "internal/provision/provision.go",
+ "internal/registry/registry.go",
+ "internal/release/adopt.go",
+ "internal/release/materializeheld.go",
+}
+
+// movesScanBase is the commit moves-phase1 branched from.
+const movesScanBase = "c17f39d5"
+
+// What the scan treats as an emission. A call to jsonError emits its message
+// (the third argument). A Check literal's Reason emits. A function in
+// movesCopyTables is a table of sentences, and every value in it emits. In a
+// file in movesLocalSinks, a call to the named local helper emits its
+// string argument (readiness.go's fail and reasonOr). In a file in
+// movesReturnedSentences, the string results of a function are sentences
+// (Ready and Passing return (bool, string), sourceRefusal (string, error));
+// its error results are not.
+var movesLocalSinks = map[string]map[string]int{
+ "internal/moves/readiness.go": {"fail": 0, "reasonOr": 1},
+ "internal/host/movesreport.go": {"fail": 0},
+ "internal/console/apiinstancemove.go": {"moveTransitionSaying": 8},
+}
+
+var movesCopyTables = map[string][]string{
+ // phase1MoveCopy is the Phase 1 move door's table (MV-N47 to MV-N54).
+ // mainMoveCopy and mainGateCopy are main's bytes and are exempt.
+ "internal/console/apiinstancemove.go": {"phase1MoveCopy"},
+}
+
+var movesReturnedSentences = map[string]bool{
+ "internal/moves/readiness.go": true,
+ "internal/host/movesphase1.go": true,
+ "internal/host/movesreport.go": true,
+ "internal/host/movesstage.go": true,
+}
+
+// movesLiteralFuncs are functions whose every string literal is a sentence
+// or a piece of one: phase1Conflict passes provision's refusals through to
+// the operator, and the literals it compares are exactly the ones allowed
+// through (anything else answers one fixed sentence).
+var movesLiteralFuncs = map[string][]string{
+ "internal/console/apiinstancemove.go": {"phase1Conflict"},
+}
+
+// movesLegacyFiles are files the series touched only for plumbing (a copy
+// table threaded through a shared gate), whose sentences are main's own,
+// reviewed before this series and spoken by main's doors with the switch off.
+// Every jsonError in them is exempt. TestMovesLegacyExemptionsAreLegacy
+// proves, where git can, that the series added none of them.
+var movesLegacyFiles = map[string]string{
+ "internal/console/apicli.go": "the shared CLI gates; the series added only the gateCopy table (mainGateCopy is main's bytes)",
+ "internal/console/apiinstances.go": "main's instance doors; the series only routed their gates through a table",
+ "internal/console/authcli.go": "main's auth doors; the series added one helper",
+ "internal/console/server.go": "main's console; the series wired the switch and the routes, and added no sentence",
+}
+
+// movesLegacyFuncs are main's functions inside files the series also wrote
+// into. Their sentences are main's own.
+var movesLegacyFuncs = map[string]map[string]string{
+ "internal/console/apiinstancemove.go": {
+ "legacyHandleAPIInstanceMoveRequest": "main's move door, kept for the switch off",
+ "moveTransition": "main's transition wrapper, which passes main's bytes to the shared body",
+ "moveGate": "main's shared gate; its own sentence is main's, and the class rails come from the copy table",
+ },
+}
+
+// wild stands for the part of a sentence the code fills in.
+const wild = "\x00"
+
+var formatVerb = regexp.MustCompile(`%[-+# 0-9.*]*[a-zA-Z%]`)
+
+// splitVerbs turns a format string into a pattern.
+func formatPattern(f string) string {
+ return formatVerb.ReplaceAllStringFunc(f, func(v string) string {
+ if v == "%%" {
+ return "%"
+ }
+ return wild
+ })
+}
+
+// movesPkgIndex is one directory's package-level strings and functions, so a
+// Reason that names a constant or a one-line helper is read through.
+type movesPkgIndex struct {
+ vars map[string]ast.Expr
+ funcs map[string]*ast.FuncDecl
+ noCalls bool // read a call to a package function as a wildcard
+}
+
+var movesPkgCache = map[string]*movesPkgIndex{}
+
+func loadMovesPkg(t *testing.T, dir string) *movesPkgIndex {
+ t.Helper()
+ if p, ok := movesPkgCache[dir]; ok {
+ return p
+ }
+ p := &movesPkgIndex{vars: map[string]ast.Expr{}, funcs: map[string]*ast.FuncDecl{}}
+ movesPkgCache[dir] = p
+ ents, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range ents {
+ n := e.Name()
+ if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") {
+ continue
+ }
+ f, err := parser.ParseFile(token.NewFileSet(), filepath.Join(dir, n), nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range f.Decls {
+ switch d := d.(type) {
+ case *ast.FuncDecl:
+ if d.Recv == nil {
+ p.funcs[d.Name.Name] = d
+ }
+ case *ast.GenDecl:
+ for _, s := range d.Specs {
+ if vs, ok := s.(*ast.ValueSpec); ok && len(vs.Names) == len(vs.Values) {
+ for i, nm := range vs.Names {
+ p.vars[nm.Name] = vs.Values[i]
+ }
+ }
+ }
+ }
+ }
+ }
+ return p
+}
+
+// patternsOf returns the sentence patterns an expression can produce:
+// literals, concatenations, fmt.Sprintf formats, package constants and
+// package functions that return strings. Anything it cannot read is a
+// wildcard.
+func patternsOf(e ast.Expr, pkg *movesPkgIndex, depth int) []string {
+ switch e := e.(type) {
+ case *ast.BasicLit:
+ if e.Kind == token.STRING {
+ if v, err := strconv.Unquote(e.Value); err == nil {
+ return []string{v}
+ }
+ }
+ case *ast.ParenExpr:
+ return patternsOf(e.X, pkg, depth)
+ case *ast.Ident:
+ if x, ok := pkg.vars[e.Name]; ok && depth < 4 {
+ return patternsOf(x, pkg, depth+1)
+ }
+ case *ast.BinaryExpr:
+ if e.Op != token.ADD {
+ break
+ }
+ var out []string
+ for _, l := range patternsOf(e.X, pkg, depth) {
+ for _, r := range patternsOf(e.Y, pkg, depth) {
+ out = append(out, l+r)
+ }
+ }
+ return out
+ case *ast.CallExpr:
+ switch fn := e.Fun.(type) {
+ case *ast.SelectorExpr:
+ if id, ok := fn.X.(*ast.Ident); ok && len(e.Args) > 0 &&
+ ((id.Name == "fmt" && (fn.Sel.Name == "Sprintf" || fn.Sel.Name == "Errorf")) ||
+ (id.Name == "errors" && fn.Sel.Name == "New")) {
+ var out []string
+ for _, f := range patternsOf(e.Args[0], pkg, depth) {
+ out = append(out, formatPattern(f))
+ }
+ return out
+ }
+ case *ast.Ident:
+ if d, ok := pkg.funcs[fn.Name]; ok && !pkg.noCalls && depth < 4 && returnsOneString(d) {
+ var out []string
+ ast.Inspect(d.Body, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.FuncLit:
+ return false
+ case *ast.ReturnStmt:
+ if len(n.Results) == 1 {
+ out = append(out, patternsOf(n.Results[0], pkg, depth+1)...)
+ }
+ }
+ return true
+ })
+ return out
+ }
+ }
+ }
+ return []string{wild}
+}
+
+func returnsOneString(d *ast.FuncDecl) bool {
+ r := d.Type.Results
+ if r == nil || len(r.List) != 1 || len(r.List[0].Names) > 1 {
+ return false
+ }
+ id, ok := r.List[0].Type.(*ast.Ident)
+ return ok && id.Name == "string"
+}
+
+type movesEmission struct {
+ file string
+ line int // where the call or literal starts
+ endLine int
+ fn string // the enclosing function
+ kind string
+ pattern string
+}
+
+func (e movesEmission) String() string {
+ return fmt.Sprintf("%s:%d (%s) %q", e.file, e.line, e.kind, strings.ReplaceAll(e.pattern, wild, "%"))
+}
+
+// informative reports whether a pattern says anything: at least three letters
+// outside the parts the code fills in (a bare "a " or "S2: " is a piece of
+// a sentence, not one).
+func informative(p string) bool {
+ n := 0
+ for _, r := range strings.ReplaceAll(p, wild, "") {
+ if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' {
+ if n++; n == 3 {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// scanMovesFile returns the emissions of one file.
+func scanMovesFile(t *testing.T, file string) []movesEmission {
+ t.Helper()
+ return scanMovesFileWith(t, ".", file, false)
+}
+
+// scanMovesFileWith is scanMovesFile; noCalls reads calls to package
+// functions as wildcards, so a legacy function's sentence is only the text
+// written in that function.
+func scanMovesFileWith(t *testing.T, root, file string, noCalls bool) []movesEmission {
+ t.Helper()
+ at := func(p string) string {
+ if root == "." {
+ return p
+ }
+ return filepath.Join(root, p)
+ }
+ fset := token.NewFileSet()
+ f, err := parser.ParseFile(fset, at(file), nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ idx := *loadMovesPkg(t, at(filepath.Dir(file)))
+ idx.noCalls = noCalls
+ pkg := &idx
+ var out []movesEmission
+ var fn string
+ emit := func(pos token.Pos, kind string, e ast.Expr) {
+ for _, p := range patternsOf(e, pkg, 0) {
+ if informative(p) {
+ out = append(out, movesEmission{file, fset.Position(pos).Line, fset.Position(e.End()).Line, fn, kind, p})
+ }
+ }
+ }
+ isCheck := func(x ast.Expr) bool {
+ switch x := x.(type) {
+ case *ast.Ident:
+ return x.Name == "Check"
+ case *ast.SelectorExpr:
+ return x.Sel.Name == "Check"
+ }
+ return false
+ }
+ tables := map[string]bool{}
+ for _, n := range movesCopyTables[file] {
+ tables[n] = true
+ }
+ literals := map[string]bool{}
+ for _, n := range movesLiteralFuncs[file] {
+ literals[n] = true
+ }
+ for _, d := range f.Decls {
+ fd, ok := d.(*ast.FuncDecl)
+ if !ok || fd.Body == nil {
+ continue
+ }
+ fn = fd.Name.Name
+ inTable := tables[fn]
+ strs := stringResults(fd)
+ returnsSentence := movesReturnedSentences[file] && len(strs) > 0
+ ast.Inspect(fd.Body, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.BasicLit:
+ // A literal with an em dash is main's wording, which phase1Conflict
+ // matches and rewrites; it is never spoken.
+ if literals[fn] && n.Kind == token.STRING && !strings.Contains(n.Value, `\u2014`) && !strings.ContainsRune(n.Value, '\u2014') {
+ emit(n.Pos(), fn, n)
+ }
+ case *ast.CallExpr:
+ name := ""
+ switch fn := n.Fun.(type) {
+ case *ast.Ident:
+ name = fn.Name
+ case *ast.SelectorExpr:
+ name = fn.Sel.Name // a method: only local sinks, below
+ }
+ i, sink := movesLocalSinks[file][name]
+ if id, ok := n.Fun.(*ast.Ident); ok && id.Name == "jsonError" {
+ i, sink = 2, true
+ }
+ if sink && i < len(n.Args) {
+ emit(n.Pos(), name, n.Args[i])
+ }
+ case *ast.CompositeLit:
+ if n.Type != nil && isCheck(n.Type) {
+ for _, el := range n.Elts {
+ if kv, ok := el.(*ast.KeyValueExpr); ok {
+ if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "Reason" {
+ emit(kv.Pos(), "Check.Reason", kv.Value)
+ }
+ }
+ }
+ }
+ if inTable {
+ for _, el := range n.Elts {
+ if kv, ok := el.(*ast.KeyValueExpr); ok {
+ emit(kv.Pos(), fd.Name.Name, kv.Value)
+ }
+ }
+ }
+ case *ast.AssignStmt:
+ if len(n.Lhs) == 1 && len(n.Rhs) == 1 {
+ if s, ok := n.Lhs[0].(*ast.SelectorExpr); ok && s.Sel.Name == "Reason" && isCheck(s.X) {
+ emit(n.Pos(), "Check.Reason", n.Rhs[0])
+ }
+ }
+ case *ast.ReturnStmt:
+ if inTable {
+ for _, r := range n.Results {
+ emit(r.Pos(), fd.Name.Name, r)
+ }
+ } else if returnsSentence {
+ for i, r := range n.Results {
+ if strs[i] && !isIdentOrCall(r) {
+ emit(r.Pos(), "returned sentence", r)
+ }
+ }
+ }
+ }
+ return true
+ })
+ }
+ return out
+}
+
+// stringResults is the set of a function's result positions that are strings.
+func stringResults(d *ast.FuncDecl) map[int]bool {
+ out := map[int]bool{}
+ if d.Type.Results == nil {
+ return out
+ }
+ i := 0
+ for _, f := range d.Type.Results.List {
+ n := len(f.Names)
+ if n == 0 {
+ n = 1
+ }
+ if id, ok := f.Type.(*ast.Ident); ok && id.Name == "string" {
+ for k := 0; k < n; k++ {
+ out[i+k] = true
+ }
+ }
+ i += n
+ }
+ return out
+}
+
+// isIdentOrCall is true for a returned value that is a variable or a call to
+// something other than fmt.Sprintf: those carry no sentence of their own.
+func isIdentOrCall(e ast.Expr) bool {
+ switch e := e.(type) {
+ case *ast.Ident, *ast.SelectorExpr:
+ return true
+ case *ast.CallExpr:
+ if s, ok := e.Fun.(*ast.SelectorExpr); ok {
+ if id, ok := s.X.(*ast.Ident); ok && id.Name == "fmt" {
+ return false
+ }
+ }
+ return true
+ }
+ return false
+}
+
+// movesRowMatches reports whether a row carries an emitted sentence: the
+// sentence's fixed text appears in the row (the parts the code fills in match
+// anything), or the row's own <placeholders> are filled by the sentence (the
+// row says "a <role> does not move" and the code says "a replica does not
+// move"). A prefix a caller adds around a reason ("S2: ") is outside the
+// sentence, so containment is enough.
+func movesRowMatches(text, pattern string) bool {
+ parts := strings.Split(pattern, wild)
+ for i := range parts {
+ parts[i] = regexp.QuoteMeta(parts[i])
+ }
+ if re, err := regexp.Compile(`(?s)` + strings.Join(parts, `.*`)); err == nil && re.MatchString(text) {
+ return true
+ }
+ rowParts := copyPlaceholder.Split(text, -1)
+ for i := range rowParts {
+ rowParts[i] = regexp.QuoteMeta(rowParts[i])
+ }
+ re, err := regexp.Compile(`(?s)^` + strings.Join(rowParts, `.*`) + `$`)
+ return err == nil && re.MatchString(strings.ReplaceAll(pattern, wild, "%"))
+}
+
+// movesLegacy reports whether an emission is main's own.
+func movesLegacy(e movesEmission) bool {
+ if _, ok := movesLegacyFiles[e.file]; ok {
+ return true
+ }
+ _, ok := movesLegacyFuncs[e.file][e.fn]
+ return ok
+}
+
+// findUnrecorded returns the emissions no row covers.
+func findUnrecorded(ems []movesEmission, rows []copyRow) []movesEmission {
+ var out []movesEmission
+ for _, e := range ems {
+ if movesLegacy(e) {
+ continue
+ }
+ found := false
+ for _, r := range rows {
+ if movesRowMatches(r.Text, e.pattern) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ out = append(out, e)
+ }
+ }
+ return out
+}
+
+// Every sentence the moves code emits to an operator (an HTTP error body, a
+// check's Reason, a phase 1 copy table's text) has a row in the record. A
+// sentence with no row is a finding: add it as a pending row. It is not a
+// reason to loosen this test.
+func TestMovesStringsAreRecorded(t *testing.T) {
+ rows := mustParseMovesRecord(t)
+ var all []movesEmission
+ for _, f := range movesScanFiles {
+ all = append(all, scanMovesFile(t, f)...)
+ }
+ if len(all) < 100 {
+ t.Fatalf("the scan found only %d emissions; it has stopped reading the code", len(all))
+ }
+ for _, e := range findUnrecorded(all, rows) {
+ t.Errorf("no row of the record carries %s", e)
+ }
+}
+
+func TestMovesScanFindsWhatItShould(t *testing.T) {
+ const src = `package p
+
+import "fmt"
+
+const noKey = "this box has no key"
+
+func why(n int) string { return fmt.Sprintf("%d boxes publish it", n) }
+
+func f(w int, c Check) {
+ jsonError(w, 400, "bad region "+c.Region+" given")
+ _ = Check{Reason: noKey}
+ _ = moves.Check{Reason: why(3)}
+ _ = Check{Reason: "-"}
+ _ = Check{Reason: c.Other}
+ c.Check.Reason = fmt.Sprintf("took %d of %d", 1, 2)
+}
+`
+ dir := t.TempDir()
+ file := filepath.Join(dir, "p.go")
+ if err := os.WriteFile(file, []byte(src), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var got []string
+ for _, e := range scanMovesFile(t, file) {
+ got = append(got, strings.ReplaceAll(e.pattern, wild, "%"))
+ }
+ sort.Strings(got)
+ want := []string{"% boxes publish it", "bad region % given", "this box has no key", "took % of %"}
+ if strings.Join(got, "|") != strings.Join(want, "|") {
+ t.Errorf("found %q, want %q", got, want)
+ }
+
+ rows := []copyRow{
+ {ID: "MV-N1", Text: `bad region <to> given`},
+ {ID: "MV-N2", Text: `<n> boxes publish <region>`},
+ {ID: "MV-N3", Text: `this box has no key`},
+ }
+ ems := scanMovesFile(t, file)
+ un := findUnrecorded(ems, rows)
+ if len(un) != 1 || !strings.Contains(un[0].pattern, "took") {
+ t.Errorf("unrecorded = %v, want only the 'took' sentence", un)
+ }
+ // A reworded row no longer covers its sentence.
+ rows[2].Text = "this box has no secrets key"
+ if un := findUnrecorded(ems, rows); len(un) != 2 {
+ t.Errorf("unrecorded after a reword = %v, want 2", un)
+ }
+}
+
+// The scan reads the files listed in movesScanFiles. When moves-phase1's diff
+// names a Go file the list does not, the scan has a blind spot: add it. The
+// check is skipped where git or the base commit is not available.
+func TestMovesScanFilesCoverTheDiff(t *testing.T) {
+ out, err := exec.Command("git", "diff", "--name-only", movesScanBase+"..HEAD", "--", "*.go").Output()
+ if err != nil {
+ t.Skipf("git diff %s..HEAD: %v", movesScanBase, err)
+ }
+ listed := map[string]bool{}
+ for _, f := range movesScanFiles {
+ listed[f] = true
+ }
+ for _, f := range strings.Fields(string(out)) {
+ if strings.HasSuffix(f, "_test.go") {
+ continue
+ }
+ if _, err := os.Stat(f); err != nil {
+ continue // deleted
+ }
+ if !listed[f] {
+ t.Errorf("%s changed since %s and is not in movesScanFiles", f, movesScanBase)
+ }
+ }
+ for _, f := range movesScanFiles {
+ if _, err := os.Stat(f); err != nil {
+ t.Errorf("movesScanFiles names %s, which does not exist", f)
+ }
+ }
+}
+
+// The exemptions in movesLegacyFiles and movesLegacyFuncs are only for
+// sentences main already had. Where git can say, every exempt emission's text
+// is already a string literal of the file at the base commit; a sentence the
+// series writes into one of these files must get a row, so this fails and
+// the sentence moves out of the exemption.
+func TestMovesLegacyExemptionsAreLegacy(t *testing.T) {
+ files := map[string]bool{}
+ for f := range movesLegacyFiles {
+ files[f] = true
+ }
+ for f := range movesLegacyFuncs {
+ files[f] = true
+ }
+ var names []string
+ for f := range files {
+ names = append(names, f)
+ }
+ sort.Strings(names)
+ for _, f := range names {
+ // The base commit's package, written out so the same scan reads it.
+ root := t.TempDir()
+ dir := filepath.Dir(f)
+ list, err := exec.Command("git", "ls-tree", "--name-only", movesScanBase, dir+"/").Output()
+ if err != nil {
+ t.Skipf("git ls-tree %s: %v", movesScanBase, err)
+ }
+ if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range strings.Fields(string(list)) {
+ if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
+ continue
+ }
+ body, err := exec.Command("git", "show", movesScanBase+":"+name).Output()
+ if err != nil {
+ t.Skipf("git show %s:%s: %v", movesScanBase, name, err)
+ }
+ if err := os.WriteFile(filepath.Join(root, name), body, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ inBase := map[string]bool{}
+ for _, e := range scanMovesFileWith(t, root, f, true) {
+ inBase[e.pattern] = true
+ }
+ exempt := 0
+ for _, e := range scanMovesFileWith(t, ".", f, true) {
+ if !movesLegacy(e) {
+ continue
+ }
+ exempt++
+ if !inBase[e.pattern] {
+ t.Errorf("%s is exempt as main's own sentence, but %s at %s did not say it", e, f, movesScanBase)
+ }
+ }
+ if exempt == 0 {
+ t.Errorf("%s has an exemption but emits nothing under it: drop it", f)
+ }
+ }
+ for f, fns := range movesLegacyFuncs {
+ have := map[string]bool{}
+ for _, e := range scanMovesFile(t, f) {
+ have[e.fn] = true
+ }
+ for fn := range fns {
+ if !have[fn] {
+ t.Errorf("movesLegacyFuncs names %s in %s, which emits nothing: drop the entry", fn, f)
+ }
+ }
+ }
+}
+
+// The checks bite on the real record too: each of these edits to it, made in
+// memory, is a failure the gate must report.
+func TestMovesGateBitesOnTheRealRecord(t *testing.T) {
+ rows := mustParseMovesRecord(t)
+
+ if got := checkMovesInventory(rows[1:], movesCopyIDs); len(got) != 1 || !strings.Contains(got[0], "no row "+rows[0].ID) {
+ t.Errorf("a deleted first row: problems = %q", got)
+ }
+ if got := checkMovesInventory(rows[:len(rows)-1], movesCopyIDs); len(got) != 1 {
+ t.Errorf("a deleted last row: problems = %q", got)
+ }
+ extra := append(append([]copyRow{}, rows...), copyRow{ID: "MV-N9999", File: "x.go", Text: "x", Status: "pending"})
+ if got := checkMovesInventory(extra, movesCopyIDs); len(got) != 1 || !strings.Contains(got[0], "MV-N9999") {
+ t.Errorf("an added row: problems = %q", got)
+ }
+
+ // A row approved with text the file does not carry.
+ changed := append([]copyRow{}, rows...)
+ for i := range changed {
+ if changed[i].ID == "MV-N6" {
+ changed[i].Status = "approved"
+ changed[i].Text = `cut must be "automatic" or "manual"`
+ }
+ }
+ if got := checkMovesApplied(changed, "."); len(got) != 1 || !strings.Contains(got[0], "MV-N6") {
+ t.Errorf("an approved row whose text is not in its file: problems = %q", got)
+ }
+ // The same row with the text the code speaks is applied.
+ for i := range changed {
+ if changed[i].ID == "MV-N6" {
+ changed[i].Text = `cut must be "auto" or "manual"`
+ }
+ }
+ if got := checkMovesApplied(changed, "."); len(got) != 0 {
+ t.Errorf("an approved row whose text is in its file: problems = %q", got)
+ }
+
+ if got := checkPendingKeepsSwitchOff(rows, true); len(got) == 0 {
+ t.Error("a default that is on beside pending rows must fail")
+ }
+}
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 3729f0c81..7fe55e22d 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -186,6 +186,7 @@ Columns: id, file, text, status.
| MV-N144 | internal/host/movesreport.go | no replica has been staged yet | pending |
| MV-N145 | internal/host/movesreport.go | the instance left <region> recently and can move back after <RFC 3339 time> | pending |
| MV-N146 | internal/moves/readiness.go | no fresh rate measurement for the disk's per-file sync (MV-N68 with a new <which>) | pending |
+| MV-N147 | internal/console/apiinstancemove.go | invalid region | pending |
## Plan 2
diff --git a/testdata/moves-copy/changed-text/applied.go b/testdata/moves-copy/changed-text/applied.go
new file mode 100644
index 000000000..51a4f70c3
--- /dev/null
+++ b/testdata/moves-copy/changed-text/applied.go
@@ -0,0 +1,7 @@
+package fixture
+
+// Not compiled: testdata is ignored by the go tool.
+var ready = "the move is ready"
+
+// The row for MV-N2 was approved as "the move is not ready yet, try again".
+var notReady = "the move is not ready, try again"
diff --git a/testdata/moves-copy/changed-text/record.md b/testdata/moves-copy/changed-text/record.md
new file mode 100644
index 000000000..241d98b3d
--- /dev/null
+++ b/testdata/moves-copy/changed-text/record.md
@@ -0,0 +1,7 @@
+# Negative fixture: MV-N2 was approved with one text and the code says another
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N1 | applied.go | the move is ready | approved |
+| MV-N2 | applied.go | the move is not ready yet, try again | approved |
+| MV-N3 | applied.go | a sentence still waiting for review | pending |
diff --git a/testdata/moves-copy/em-dash.md b/testdata/moves-copy/em-dash.md
new file mode 100644
index 000000000..bab7f0ff8
--- /dev/null
+++ b/testdata/moves-copy/em-dash.md
@@ -0,0 +1,6 @@
+# Negative fixture: an em dash in a row
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N1 | applied.go | the move is ready | pending |
+| MV-N2 | applied.go | the move is not ready — try again | pending |
diff --git a/testdata/moves-copy/missing-id.md b/testdata/moves-copy/missing-id.md
new file mode 100644
index 000000000..11f439eb8
--- /dev/null
+++ b/testdata/moves-copy/missing-id.md
@@ -0,0 +1,6 @@
+# Negative fixture: the record lacks MV-N1
+
+| id | file | text | status |
+|---|---|---|---|
+| M-C1 | move.go | Moving <host>. | pending |
+| M-C2 | move.go | Copying data. | pending |
diff --git a/testdata/moves-copy/pending-default-on.md b/testdata/moves-copy/pending-default-on.md
new file mode 100644
index 000000000..a02d5fa36
--- /dev/null
+++ b/testdata/moves-copy/pending-default-on.md
@@ -0,0 +1,6 @@
+# Negative fixture: a pending row beside a default that is on
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N1 | applied.go | the move is ready | approved |
+| MV-N2 | applied.go | the move is not ready yet | pending |