carlos / platform Public

copy gate: list templates, quoted %q and File-cell notes; MV-N54, N129, N146 approved

Paul adopted three notation rules on 2026-10-10 (preflight L14). The gate
now reads a final `<element>, ...` as one runtime list placeholder: the
head must be spoken with the list as the code's own final fill-in, and the
element is checked where the code builds it (movesListElementFiles), which
must join the list with ", ". formatPattern renders %q as a quoted fill-in.
MV-N146's note moves to its File cell and its text becomes the fill-in.

MV-N54, MV-N129 and MV-N146 had their wording approved in the 2026-10-09
review and were pending only because the gate could not read them; they
flip to approved verbatim. Negative fixtures for each rule, each shown to
catch a mutation of the rule it guards.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2530985c6cb335afff397950c1ef6fff1318c973 parent ebf60d9
11 files changed, +433 −23
  • approved_moves_copy_test.go +289 −16
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +7 −7
  • testdata/moves-copy/file-note/readiness.go +13 −0
  • testdata/moves-copy/file-note/record.md +11 −0
  • testdata/moves-copy/list-join/head.go +11 −0
  • testdata/moves-copy/list-join/record.md +5 −0
  • testdata/moves-copy/list-template/elem.go +17 −0
  • testdata/moves-copy/list-template/head.go +42 −0
  • testdata/moves-copy/list-template/record.md +15 −0
  • testdata/moves-copy/quoted-verb/quoted.go +13 −0
  • testdata/moves-copy/quoted-verb/record.md +10 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index db587b302..e74242707 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -214,6 +214,12 @@ var copyPlaceholder = regexp.MustCompile(`<[^<>]*>`)
// short fixed text must be a whole literal, as in copyApplied) or raw in any
// other file.
func checkMovesApplied(rows []copyRow, root string) []string {
+ return checkMovesAppliedWith(rows, root, movesListElementFiles)
+}
+
+// checkMovesAppliedWith is checkMovesApplied with the map of where each list
+// template's element is built (movesListElementFiles for the real record).
+func checkMovesAppliedWith(rows []copyRow, root string, elementFiles map[string]string) []string {
var out []string
for _, r := range rows {
if r.Status != "approved" {
@@ -224,6 +230,12 @@ func checkMovesApplied(rows []copyRow, root string) []string {
out = append(out, fmt.Sprintf("%s: %v", r.ID, err))
continue
}
+ if strings.HasSuffix(r.Path(), ".go") {
+ if _, _, list, _ := movesListTemplate(r.Text); list {
+ out = append(out, checkMovesListApplied(r, root, body, elementFiles)...)
+ continue
+ }
+ }
if !movesTextApplied(r.Path(), r.Text, body) {
out = append(out, fmt.Sprintf("%s: %s does not contain the approved text %q", r.ID, r.Path(), r.Text))
}
@@ -231,6 +243,131 @@ func checkMovesApplied(rows []copyRow, root string) []string {
return out
}
+// ---- list templates (preflight L14, adopted by Paul 2026-10-10) ----
+//
+// A row whose text ends in `<element>, ...` describes a list the code builds
+// at run time: "pass --account-id with one of: <sqid> ("<name>"), ...". No
+// literal in the code ends in ", ...", so the gate reads the template as two
+// things and checks each where the code makes it:
+//
+// - the head, the row's text with the whole list as one placeholder, is
+// spoken by one expression of the row's file, whose final part is the
+// code's own fill-in (the list is never fixed text);
+// - the element is built where the code builds it (the row's file, or the
+// file movesListElementFiles names), and that file joins the list with
+// ", ", which is what the row's ", ..." shows.
+//
+// The list follows the head's last ": ", as both template rows do; a row
+// that ends in ", ..." with no such colon, or whose element does not start
+// with a placeholder, cannot be read and is refused. An element that is one
+// placeholder (MV-N129's <NAME (kind)>) has no fixed text to check: only its
+// join is.
+
+// movesListTail is how a row's text ends when its last part is a list.
+const movesListTail = ", ..."
+
+// movesListElementFiles names, per row, the file that builds the element of
+// its list when that is not the row's own file. MV-N54's sentence is the move
+// door's (apiinstancemove.go), and its alternatives are built and joined by
+// the account gate it shares with every CLI door (apicli.go, main's own).
+var movesListElementFiles = map[string]string{
+ "MV-N54": "internal/console/apicli.go",
+}
+
+// movesListTemplate splits a row's text that ends in a list template into
+// its head (through the colon that introduces the list) and its element.
+// list reports whether the text ends in one at all; err says why one cannot
+// be read.
+func movesListTemplate(text string) (head, element string, list bool, err error) {
+ if !strings.HasSuffix(text, movesListTail) {
+ return "", "", false, nil
+ }
+ body := strings.TrimSuffix(text, movesListTail)
+ i := strings.LastIndex(body, ": ")
+ if i < 0 {
+ return "", "", true, fmt.Errorf("no \": \" introduces the list")
+ }
+ head, element = body[:i+2], body[i+2:]
+ if !strings.HasPrefix(element, "<") {
+ return "", "", true, fmt.Errorf("the element %q does not start with a placeholder", element)
+ }
+ return head, element, true, nil
+}
+
+// checkMovesListApplied checks a list-template row: its head in its own
+// file, and its element and join where the element is built.
+func checkMovesListApplied(r copyRow, root string, body []byte, elementFiles map[string]string) []string {
+ head, element, _, err := movesListTemplate(r.Text)
+ if err != nil {
+ return []string{fmt.Sprintf("%s: the gate cannot read the list template in %q: %v", r.ID, r.Text, err)}
+ }
+ var out []string
+ headSpoken := false
+ for _, p := range goTextPatterns(r.Path(), body) {
+ if informative(p) && strings.HasSuffix(p, wild) && movesRowSpoken(head+"<list>", p) {
+ headSpoken = true
+ break
+ }
+ }
+ if !headSpoken {
+ out = append(out, fmt.Sprintf("%s: %s does not say %q with the list as its final fill-in", r.ID, r.Path(), head+"<list>"))
+ }
+ elemFile := r.Path()
+ if f, ok := elementFiles[r.ID]; ok {
+ elemFile = f
+ }
+ elemBody, err := os.ReadFile(filepath.Join(root, elemFile))
+ if err != nil {
+ return append(out, fmt.Sprintf("%s: the list's element file: %v", r.ID, err))
+ }
+ if copyPlaceholder.ReplaceAllString(element, "") != "" {
+ built := false
+ for _, p := range goTextPatterns(elemFile, elemBody) {
+ if movesRowSpoken(element, p) {
+ built = true
+ break
+ }
+ }
+ if !built {
+ out = append(out, fmt.Sprintf("%s: %s does not build the list's element %q", r.ID, elemFile, element))
+ }
+ }
+ if !joinsWithComma(elemFile, elemBody) {
+ out = append(out, fmt.Sprintf("%s: %s does not join the list with \", \"", r.ID, elemFile))
+ }
+ return out
+}
+
+// joinsWithComma reports whether a Go file calls strings.Join with the
+// literal separator ", ".
+func joinsWithComma(file string, body []byte) bool {
+ f, err := parser.ParseFile(token.NewFileSet(), file, body, 0)
+ if err != nil {
+ return false
+ }
+ found := false
+ ast.Inspect(f, func(n ast.Node) bool {
+ c, ok := n.(*ast.CallExpr)
+ if !ok || found || len(c.Args) != 2 {
+ return !found
+ }
+ sel, ok := c.Fun.(*ast.SelectorExpr)
+ if !ok || sel.Sel.Name != "Join" {
+ return true
+ }
+ if id, ok := sel.X.(*ast.Ident); !ok || id.Name != "strings" {
+ return true
+ }
+ if lit, ok := c.Args[1].(*ast.BasicLit); ok && lit.Kind == token.STRING {
+ if v, err := strconv.Unquote(lit.Value); err == nil && v == ", " {
+ found = true
+ }
+ }
+ return !found
+ })
+ return found
+}
+
// movesTextApplied reports whether file's body speaks the row's text. In a Go
// file the text must be one expression: a string literal, a concatenation of
// literals, or one Sprintf or errors.New format. The row's <placeholders>
@@ -249,9 +386,22 @@ func movesTextApplied(file, text string, body []byte) bool {
}
return true
}
+ for _, p := range goTextPatterns(file, body) {
+ if informative(p) && movesRowSpoken(text, p) {
+ return true
+ }
+ }
+ return false
+}
+
+// goTextPatterns returns the pattern of every expression in a Go file that
+// could hold a sentence whole: a string literal, a concatenation, or one
+// Sprintf or errors.New format, with the file's consts and vars read in
+// place. A file that does not parse has none.
+func goTextPatterns(file string, body []byte) []string {
f, err := parser.ParseFile(token.NewFileSet(), file, body, 0)
if err != nil {
- return false
+ return nil
}
vars := map[string]ast.Expr{}
for _, d := range f.Decls {
@@ -265,11 +415,8 @@ func movesTextApplied(file, text string, body []byte) bool {
}
}
}
- found := false
+ var out []string
ast.Inspect(f, func(n ast.Node) bool {
- if found {
- return false
- }
var e ast.Expr
switch n := n.(type) {
case *ast.BasicLit:
@@ -285,17 +432,12 @@ func movesTextApplied(file, text string, body []byte) bool {
e = n
}
}
- if e == nil {
- return true
- }
- for _, p := range literalPatterns(e, vars, 0) {
- if informative(p) && movesRowSpoken(text, p) {
- found = true
- }
+ if e != nil {
+ out = append(out, literalPatterns(e, vars, 0)...)
}
- return !found
+ return true
})
- return found
+ return out
}
// checkPendingKeepsSwitchOff is D13: a pending row means the compiled default
@@ -521,6 +663,131 @@ func TestMovesCopyGateRefusesTheNegativeFixtures(t *testing.T) {
t.Errorf("problems = %q, want exactly MV-N2", got)
}
})
+
+ // The three notation rules Paul adopted for the list templates
+ // (preflight L14, 2026-10-10). Each fixture holds a row the rule lets
+ // through and rows it must refuse, each refused for its own reason.
+ t.Run("list templates: the head with the list as its fill-in, the element where it is built", func(t *testing.T) {
+ root := filepath.Join(dir, "list-template")
+ rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := checkMovesAppliedWith(rows, root, map[string]string{"MV-N2": "elem.go", "MV-N3": "elem.go"})
+ sort.Strings(got)
+ want := []struct{ id, why string }{
+ {"MV-N3", "does not build the list's element \"<id> [<zone>]\""},
+ {"MV-N4", "with the list as its final fill-in"},
+ {"MV-N5", "cannot read the list template"},
+ {"MV-N6", "with the list as its final fill-in"},
+ }
+ if len(got) != len(want) {
+ t.Fatalf("problems = %q, want exactly MV-N3 to MV-N6 (MV-N1 and MV-N2 are spoken)", got)
+ }
+ for i, w := range want {
+ if !strings.HasPrefix(got[i], w.id+":") || !strings.Contains(got[i], w.why) {
+ t.Errorf("problem %d = %q, want %s refused because it %s", i, got[i], w.id, w.why)
+ }
+ }
+ })
+
+ t.Run("a list template joined with anything but a comma", func(t *testing.T) {
+ root := filepath.Join(dir, "list-join")
+ rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := checkMovesAppliedWith(rows, root, nil)
+ if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N1:") || !strings.Contains(got[0], `does not join the list with ", "`) {
+ t.Errorf("problems = %q, want exactly MV-N1, for its join", got)
+ }
+ })
+
+ t.Run("%q prints quotes, so a row shows them and a %s does not satisfy them", func(t *testing.T) {
+ root := filepath.Join(dir, "quoted-verb")
+ rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := checkMovesApplied(rows, root)
+ if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N2:") {
+ t.Errorf("problems = %q, want exactly MV-N2 (its row shows quotes the code's %%s does not print)", got)
+ }
+ })
+
+ t.Run("a note belongs in the File cell, never the Text cell", func(t *testing.T) {
+ root := filepath.Join(dir, "file-note")
+ rows, err := parseMovesCopyRecord(filepath.Join(root, "record.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := checkMovesApplied(rows, root)
+ if len(got) != 1 || !strings.HasPrefix(got[0], "MV-N1:") || !strings.Contains(got[0], "does not contain the approved text") {
+ t.Errorf("problems = %q, want exactly MV-N1 (its note is in the Text cell)", got)
+ }
+ })
+}
+
+func TestMovesFormatPatternQuotesQ(t *testing.T) {
+ cases := map[string]string{
+ "account %q is ambiguous": `account "` + wild + `" is ambiguous`,
+ "%s (%q)": wild + ` ("` + wild + `")`,
+ "%-10q left": `"` + wild + `" left`,
+ "took %d of %d": "took " + wild + " of " + wild,
+ "85%% full": "85% full",
+ "%v and %s": wild + " and " + wild,
+ }
+ for in, want := range cases {
+ if got := formatPattern(in); got != want {
+ t.Errorf("formatPattern(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestMovesListTemplate(t *testing.T) {
+ cases := []struct {
+ text, head, element string
+ list, readable bool
+ }{
+ {`account "<segment>" is ambiguous. pass --account-id with one of: <sqid> ("<name>"), ...`,
+ `account "<segment>" is ambiguous. pass --account-id with one of: `, `<sqid> ("<name>")`, true, true},
+ {"the launch environment differs from the source's: <NAME (kind)>, ...",
+ "the launch environment differs from the source's: ", "<NAME (kind)>", true, true},
+ {"no list here", "", "", false, true},
+ {"a list without a colon <a>, ...", "", "", true, false},
+ {"a colon then words: the <a>, ...", "", "", true, false},
+ {"an empty element: , ...", "", "", true, false},
+ }
+ for _, c := range cases {
+ head, element, list, err := movesListTemplate(c.text)
+ if list != c.list || (err == nil) != c.readable || head != c.head || element != c.element {
+ t.Errorf("movesListTemplate(%q) = %q, %q, %v, %v; want %q, %q, list %v, readable %v",
+ c.text, head, element, list, err, c.head, c.element, c.list, c.readable)
+ }
+ }
+}
+
+// Every entry of movesListElementFiles is a list-template row of the real
+// record whose element file exists, so the map never names a row that has no
+// element to check.
+func TestMovesListElementFilesAreHonest(t *testing.T) {
+ byID := map[string]copyRow{}
+ for _, r := range mustParseMovesRecord(t) {
+ byID[r.ID] = r
+ }
+ for id, file := range movesListElementFiles {
+ r, ok := byID[id]
+ if !ok {
+ t.Errorf("movesListElementFiles names %s, which the record has no row for", id)
+ continue
+ }
+ if _, _, list, err := movesListTemplate(r.Text); !list || err != nil {
+ t.Errorf("movesListElementFiles names %s, whose text %q is not a readable list template", id, r.Text)
+ }
+ if _, err := os.Stat(file); err != nil {
+ t.Errorf("movesListElementFiles: %s's element file: %v", id, err)
+ }
+ }
}
func TestMovesTextAppliedPlaceholders(t *testing.T) {
@@ -830,11 +1097,17 @@ const wild = "\x00"
var formatVerb = regexp.MustCompile(`%[-+# 0-9.*]*[a-zA-Z%]`)
-// splitVerbs turns a format string into a pattern.
+// formatPattern turns a format string into a pattern. A verb is the code's
+// fill-in, except that %q prints its quotes, so it is a quoted fill-in and a
+// row shows the quotes ("<segment>"), preflight L14 as Paul adopted it on
+// 2026-10-10. %#q may print backquotes instead, so it stays a bare fill-in.
func formatPattern(f string) string {
return formatVerb.ReplaceAllStringFunc(f, func(v string) string {
- if v == "%%" {
+ switch {
+ case v == "%%":
return "%"
+ case strings.HasSuffix(v, "q") && !strings.Contains(v, "#"):
+ return `"` + wild + `"`
}
return wild
})
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 fa112854c..5f0aabc70 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -93,7 +93,7 @@ Columns: id, file, text, status.
| MV-N51 | internal/console/apiinstancemove.go | a non-hibernating instance runs outside the activator. nothing manages its data, so nothing can move it | approved |
| MV-N52 | internal/console/apiinstancemove.go | too many instance requests. wait a minute and try again | approved |
| MV-N53 | internal/console/apiinstancemove.go | this console has no deployment bucket, so it cannot answer this | approved |
-| MV-N54 | internal/console/apiinstancemove.go | account "<segment>" is ambiguous. pass --account-id with one of: <sqid> ("<name>"), ... | pending |
+| MV-N54 | internal/console/apiinstancemove.go | account "<segment>" is ambiguous. pass --account-id with one of: <sqid> ("<name>"), ... | approved |
| MV-N55 | internal/moves/readiness.go | the last round moved <bytes> bytes and the bar is <bytes> | approved |
| MV-N56 | internal/moves/readiness.go | the target has not published a readiness report yet | approved |
| MV-N57 | internal/moves/readiness.go | the target's readiness report is stale | approved |
@@ -168,7 +168,7 @@ Columns: id, file, text, status.
| MV-N126 | internal/host/movesreport.go | this box has no secrets key | approved |
| MV-N127 | internal/host/movesreport.go | this box's secrets key is not the console's | approved |
| MV-N128 | internal/host/movesreport.go | this box could not compose the launch environment of the held row | approved |
-| MV-N129 | internal/host/movesreport.go | the launch environment differs from the source's: <NAME (kind)>, ... | pending |
+| MV-N129 | internal/host/movesreport.go | the launch environment differs from the source's: <NAME (kind)>, ... | approved |
| MV-N130 | internal/host/movesreport.go (a T3 note, not a failure) | <NAME> in the carried .env overrides the rendered configuration | approved |
| MV-N131 | internal/host/movesreport.go (a T3 note for an advisory variable) | <NAME> (<kind>) | approved |
| MV-N132 | internal/host/movesreport.go | the store bound as <PREFIX> did not answer the probe from this box | approved |
@@ -185,7 +185,7 @@ Columns: id, file, text, status.
| MV-N143 | internal/host/movesreport.go | this box cannot write the return lane in the source's bucket | approved |
| MV-N144 | internal/host/movesreport.go | no replica has been staged yet | approved |
| MV-N145 | internal/host/movesreport.go | the instance left <region> recently and can move back after <RFC 3339 time> | approved |
-| 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-N146 | internal/moves/readiness.go (the <which> of MV-N68) | the disk's per-file sync | approved |
| MV-N147 | internal/console/apiinstancemove.go | invalid region | approved |
| MV-N148 | internal/host/movesphase1.go | the app's readiness path could not be read | approved |
| MV-N149 | internal/moves/readiness.go | the target has not checked a release yet | approved |
@@ -370,10 +370,10 @@ Not built yet (18):
- M-C20 (`internal/host/movespaging.go`): the paging sentence is Plan 5, and no `movespaging.go` exists.
- MV-N11 (`internal/console/apiinstancemove.go`): the declare-lost door is Plan 4 Task 10. `resolve` answers "nothing to resolve yet" to anything but a retry.
-Built, but the gate cannot read the row (3), so the row stays pending until Paul rules on how it is gated:
+Built, but the gate could not read the row (3). Paul adopted three notation rules for them on 2026-10-10 (preflight L14), and Plan 6 Task 7 put the rules in the gate and flipped all three to approved, verbatim:
-- MV-N54: the row ends in a list template, `<sqid> ("<name>"), ...`. The code builds that list at run time (`apicli.go`) and joins it into the sentence, so the sentence is never one literal ending in `, ...`. Making it so would print a literal `...`.
-- MV-N129: the same, `<NAME (kind)>, ...`; `t3Differs` in `internal/host/movesreport.go` joins the names it found.
-- MV-N146: the row's text carries a note, `(MV-N68 with a new <which>)`. The code says `no fresh rate measurement for the disk's per-file sync` through MV-N68's `NoFreshRate`, which is correct; the note is not a part of any sentence the code could say.
+- MV-N54: the row ends in a list template, `<sqid> ("<name>"), ...`. The code builds that list at run time (`apicli.go`) and joins it into the sentence. Rule: a final `<element>, ...` is one runtime list placeholder. The gate checks the head with the list as the code's own fill-in, and checks the element where the code builds it (`movesListElementFiles` names `apicli.go`). Rule: `%q` prints quotes, so the gate reads it as a quoted fill-in and the row's `"<segment>"` matches.
+- MV-N129: the same list rule, `<NAME (kind)>, ...`; `t3Differs` in `internal/host/movesreport.go` joins the names it found. Its element is one placeholder, so only the head and the join are checked.
+- MV-N146: the note moved from the Text cell to the File cell (`internal/moves/readiness.go (the <which> of MV-N68)`), and the text is the fill-in `the disk's per-file sync`, which `readiness.go` says as one literal.
The 16 rows added after that review (MV-N184 to MV-N186, MV-N300 to MV-N312) were approved by Paul on 2026-10-09 with no edits.
diff --git a/testdata/moves-copy/file-note/readiness.go b/testdata/moves-copy/file-note/readiness.go
new file mode 100644
index 000000000..6153b3eea
--- /dev/null
+++ b/testdata/moves-copy/file-note/readiness.go
@@ -0,0 +1,13 @@
+package fixture
+
+// Not compiled: testdata is ignored by the go tool.
+
+import "fmt"
+
+type noFreshRate struct{ which string }
+
+func (e *noFreshRate) Error() string {
+ return fmt.Sprintf("no fresh rate measurement for %s", e.which)
+}
+
+var errFsync = &noFreshRate{which: "the disk's per-file sync"}
diff --git a/testdata/moves-copy/file-note/record.md b/testdata/moves-copy/file-note/record.md
new file mode 100644
index 000000000..51334314b
--- /dev/null
+++ b/testdata/moves-copy/file-note/record.md
@@ -0,0 +1,11 @@
+# Negative fixture: a note belongs in the File cell, not the Text cell (MV-N146, preflight L14)
+
+MV-N1 carries its note in the Text cell, so its text is not a sentence the
+code says (a looser match would let the note hide under the code's
+fill-in). MV-N2 is the same sentence with the note in the File cell, and is
+spoken.
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N1 | readiness.go | no fresh rate measurement for the disk's per-file sync (MV-N9 with a new <which>) | approved |
+| MV-N2 | readiness.go (the <which> of MV-N9) | the disk's per-file sync | approved |
diff --git a/testdata/moves-copy/list-join/head.go b/testdata/moves-copy/list-join/head.go
new file mode 100644
index 000000000..45327b32c
--- /dev/null
+++ b/testdata/moves-copy/list-join/head.go
@@ -0,0 +1,11 @@
+package fixture
+
+// Not compiled: testdata is ignored by the go tool.
+
+import "strings"
+
+// The head is spoken, but the list is joined with " and ", so the row's
+// ", ..." is not what an operator reads.
+func differs(names []string) string {
+ return "the environment differs from the source's: " + strings.Join(names, " and ")
+}
diff --git a/testdata/moves-copy/list-join/record.md b/testdata/moves-copy/list-join/record.md
new file mode 100644
index 000000000..1d9e6955b
--- /dev/null
+++ b/testdata/moves-copy/list-join/record.md
@@ -0,0 +1,5 @@
+# Negative fixture: a list template whose list is joined with something other than ", "
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N1 | head.go | the environment differs from the source's: <NAME (kind)>, ... | approved |
diff --git a/testdata/moves-copy/list-template/elem.go b/testdata/moves-copy/list-template/elem.go
new file mode 100644
index 000000000..876bc3012
--- /dev/null
+++ b/testdata/moves-copy/list-template/elem.go
@@ -0,0 +1,17 @@
+package fixture
+
+// Not compiled: testdata is ignored by the go tool.
+
+import (
+ "fmt"
+ "strings"
+)
+
+// The elements of MV-N2 and MV-N3, and the join both lists use.
+func alts(sqids, names []string) string {
+ out := make([]string, len(sqids))
+ for i := range sqids {
+ out[i] = fmt.Sprintf("%s (%q)", sqids[i], names[i])
+ }
+ return strings.Join(out, ", ")
+}
diff --git a/testdata/moves-copy/list-template/head.go b/testdata/moves-copy/list-template/head.go
new file mode 100644
index 000000000..41add6b8b
--- /dev/null
+++ b/testdata/moves-copy/list-template/head.go
@@ -0,0 +1,42 @@
+package fixture
+
+// Not compiled: testdata is ignored by the go tool.
+
+import (
+ "fmt"
+ "strings"
+)
+
+// MV-N1: the head, with the joined list as its last fill-in. The element is
+// one placeholder, built here.
+func differs(names, kinds []string) string {
+ var out []string
+ for i := range names {
+ out = append(out, names[i]+" ("+kinds[i]+")")
+ }
+ return "the environment differs from the source's: " + strings.Join(out, ", ")
+}
+
+// MV-N2: %q prints the quotes the row shows. The element is built in
+// elem.go, where the record's element file map points.
+func ambiguous(seg, alts string) string {
+ return fmt.Sprintf("account %q is ambiguous. pick one of: %s", seg, alts)
+}
+
+// MV-N3: the head is spoken, but elem.go builds "<id> (<zone>)", not the
+// row's "<id> [<zone>]".
+func several(region, alts string) string {
+ return fmt.Sprintf("the region %q has several boxes: %s", region, alts)
+}
+
+// MV-N4: the list is a fixed word, not the code's fill-in.
+const noBox = "no box answered the probe: none"
+
+// MV-N5: the row has no colon before its list, so the gate cannot tell
+// where the element starts. The code says it anyway.
+func boxes(list string) string { return "the boxes " + list }
+
+// MV-N6's head is not spoken: the code says "configuration", the row "store".
+func storeDiffers(list string) string {
+ return "the configuration differs from the source's: " + list
+}
diff --git a/testdata/moves-copy/list-template/record.md b/testdata/moves-copy/list-template/record.md
new file mode 100644
index 000000000..527c4f1d9
--- /dev/null
+++ b/testdata/moves-copy/list-template/record.md
@@ -0,0 +1,15 @@
+# Negative fixture: list templates (preflight L14, Paul 2026-10-10)
+
+A final `<element>, ...` is one runtime list placeholder: the head is spoken
+with the list as the code's own fill-in, and the element is checked
+separately where the code builds it. MV-N1 and MV-N2 are spoken; MV-N3 to
+MV-N6 are not, each for one reason.
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N1 | head.go | the environment differs from the source's: <NAME (kind)>, ... | approved |
+| MV-N2 | head.go | account "<segment>" is ambiguous. pick one of: <sqid> ("<name>"), ... | approved |
+| MV-N3 | head.go | the region "<region>" has several boxes: <id> [<zone>], ... | approved |
+| MV-N4 | head.go | no box answered the probe: <box>, ... | approved |
+| MV-N5 | head.go | the boxes <box>, ... | approved |
+| MV-N6 | head.go | the store differs from the source's: <NAME (kind)>, ... | approved |
diff --git a/testdata/moves-copy/quoted-verb/quoted.go b/testdata/moves-copy/quoted-verb/quoted.go
new file mode 100644
index 000000000..b2c70244d
--- /dev/null
+++ b/testdata/moves-copy/quoted-verb/quoted.go
@@ -0,0 +1,13 @@
+package fixture
+
+// Not compiled: testdata is ignored by the go tool.
+
+import "fmt"
+
+func region(r string) string {
+ return fmt.Sprintf("region %q is not one of this app's regions", r)
+}
+
+func zone(z string) string {
+ return fmt.Sprintf("zone %s is not one of this region's zones", z)
+}
diff --git a/testdata/moves-copy/quoted-verb/record.md b/testdata/moves-copy/quoted-verb/record.md
new file mode 100644
index 000000000..618311df6
--- /dev/null
+++ b/testdata/moves-copy/quoted-verb/record.md
@@ -0,0 +1,10 @@
+# Negative fixture: %q prints quotes (preflight L14, Paul 2026-10-10)
+
+The gate renders %q as a quoted fill-in. MV-N1's row shows the quotes the
+code prints, so it is spoken. MV-N2's row shows quotes the code's %s does
+not print, so it is not.
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N1 | quoted.go | region "<name>" is not one of this app's regions | approved |
+| MV-N2 | quoted.go | zone "<name>" is not one of this region's zones | approved |