carlos / platform Public

carlos move: server text by type, --no-wait reads nothing after acceptance, no guessed regions (Task 1a fix round 2)

1. Copy-gate provenance by type. Text the console wrote is serverText,
   made only in mover.roundTrip from the body of a response the HTTP client
   returned, and said only through saySpoken: the refusal's sentence and
   the stuck reason. Everything else the CLI says is this file's own copy
   through moveSay; Go's errors and resolveMode's sentences (in no copy
   record) are never shown (MV-N365 to MV-N371 added pending). The gate's
   name table and its external() trust are gone; movesServerText and
   TestMovesServerTextHasOneMaker hold the type to one maker and one sink.
   A sentence field read whole at a sink now emits the sentences written to
   it. Fixture testdata/moves-copy/sinks/provenance: apiErrorFromBody fed
   literal JSON, a forged StuckReason, a reassignment inside sayRecorded,
   a forged serverText and a leaked one each fail the gate.
2. An accepted mutation with --no-wait says the answer's state and exits 0
   with no further read.
3. A region the wait never learnt is not filled in from the record: M-C9
   alone when the destination is unknown, the outcome line when the region
   a sentence names is.

B8 census: moveMain's digest (the session refusal).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 4db9fe5fd220ea428a0bbeb32b1e333292efb9ee parent b3c405a
8 files changed, +524 −349
  • approved_moves_copy_scan_test.go +109 −116
  • approved_moves_copy_test.go +49 −53
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +8 −1
  • internal/provision/plan9_b8_census_test.go +1 −1
  • move.go +202 −117
  • move_test.go +105 −3
  • testdata/moves-copy/sinks/forwarded-local-error/emit.go +0 −58
  • testdata/moves-copy/sinks/provenance/emit.go +50 −0
diff --git a/approved_moves_copy_scan_test.go b/approved_moves_copy_scan_test.go
index 7f0199c7b..b3d8149cf 100644
--- a/approved_moves_copy_scan_test.go
+++ b/approved_moves_copy_scan_test.go
@@ -1134,6 +1134,28 @@ func (c *evalCtx) evalIdent(id *ast.Ident, depth int) ([]string, []problem) {
// any struct of the scanned files (a keyed or positional composite literal, or
// an assignment), is. The text of each write is checked where it is written,
// so the read is forwarded.
+// fieldWriteAlts is the sentences written to the field e reads, in the struct
+// type it reads (every write, when the type is unknown). A write that is
+// itself forwarded keeps its mark: it is checked where it is made.
+func (c *evalCtx) fieldWriteAlts(e *ast.SelectorExpr) []string {
+ f := e.Sel.Name
+ rt := lastName(baseTypeName(c.typeExprOf(e.X, 0)))
+ var alts []string
+ for _, w := range c.u.fieldWrites[f] {
+ wc := c.u.newCtx(w.file, w.fd, c.noCalls)
+ wt := lastName(w.ctype)
+ if w.recv != nil {
+ wt = lastName(baseTypeName(wc.typeExprOf(w.recv, 0)))
+ }
+ if rt != "" && wt != "" && wt != rt {
+ continue
+ }
+ x, _ := wc.eval(w.expr, 0)
+ alts = append(alts, x...)
+ }
+ return dedupe(alts)
+}
+
func (c *evalCtx) fieldRead(e *ast.SelectorExpr) ([]string, []problem) {
f := e.Sel.Name
types := c.u.fieldTypes[f]
@@ -1826,13 +1848,14 @@ func scanMovesFileIn(t *testing.T, u *movesUniverse, root, file string, noCalls
emit := func(pos token.Pos, kind string, e ast.Expr, fragment bool) {
line := fset.Position(pos).Line
end := fset.Position(e.End()).Line
- // Text from outside the file (movesExternalText), or a local sink's
- // own forwarded parameter, is no sentence of this file here: the
- // parameter is checked where the sink is called.
- if ctx.external(e, 0) {
- return
- }
alts, probs := ctx.eval(e, 0)
+ // A sentence field read whole at a sink speaks what was written to it:
+ // each write's sentence needs its row here (fix round 2 of Plan 6
+ // Task 1a: a StuckReason written with a literal was otherwise passed
+ // as checked where it is made, which only checked it was a sentence).
+ if sel, ok := e.(*ast.SelectorExpr); ok && movesSentenceFields[sel.Sel.Name] && len(probs) == 0 {
+ alts = ctx.fieldWriteAlts(sel)
+ }
fromParam := false
if ctx.fd != nil {
params := map[string]bool{}
@@ -2007,126 +2030,96 @@ func scanMovesFileIn(t *testing.T, u *movesUniverse, root, file string, noCalls
return out, vios
}
-// external reports whether e is text from outside the file, by a name in
-// movesExternalText (a call's result, or a field of a decoded answer, read
-// through .Error() and parentheses), or a call to a package helper whose
-// result is external at every return, or a local whose every assignment is
-// (or the zero value, or nil), or the local sink's own forwarded parameter,
-// which is checked where the sink is called. Anything else is read as usual.
-func (c *evalCtx) external(e ast.Expr, depth int) bool {
- if depth > maxEvalDepth {
- return false
+// movesServerTextSpec names a file's server text: the type that carries text
+// the console wrote, its one field, the one function that makes it (Maker,
+// which must read the body of a response the HTTP client returned), and the
+// one sink that says it (Sink, which takes the type). Nothing else in the
+// package may make the type, write its field or read it.
+type movesServerTextSpec struct{ Type, Field, Maker, Sink string }
+
+type serverTextProblem struct{ fn, msg string }
+
+func (p serverTextProblem) String() string { return p.fn + ": " + p.msg }
+
+// checkServerText holds a server text type to its spec, across every non-test
+// Go file of file's package directory.
+func checkServerText(t *testing.T, file string, spec movesServerTextSpec) []serverTextProblem {
+ t.Helper()
+ dir := filepath.Dir(file)
+ names, err := filepath.Glob(filepath.Join(dir, "*.go"))
+ if err != nil {
+ t.Fatal(err)
}
- ext := movesExternalText[c.file]
- named := func(n string) bool { _, ok := ext[n]; return ok }
- switch e := e.(type) {
- case *ast.ParenExpr:
- return c.external(e.X, depth+1)
- case *ast.CallExpr:
- if f, ok := e.Fun.(*ast.SelectorExpr); ok && f.Sel.Name == "Error" && len(e.Args) == 0 {
- return c.external(f.X, depth+1)
- }
- return c.externalCall(e, 0, depth+1)
- case *ast.SelectorExpr:
- return named(e.Sel.Name)
- case *ast.Ident:
- if e.Name == "nil" || c.fwd[e.Name] {
- return true
+ var out []serverTextProblem
+ maker, sink := false, false
+ for _, name := range names {
+ if strings.HasSuffix(name, "_test.go") {
+ continue
}
- as := c.locals[e.Name]
- if len(as) == 0 {
- return false
+ f, err := parser.ParseFile(token.NewFileSet(), name, nil, 0)
+ if err != nil {
+ t.Fatal(err)
}
- for _, a := range as {
- switch {
- case a.zero:
- case a.rhs != nil:
- if !c.external(a.rhs, depth+1) {
- return false
+ for _, d := range f.Decls {
+ fd, ok := d.(*ast.FuncDecl)
+ if !ok || fd.Body == nil {
+ continue
+ }
+ fn := fd.Name.Name
+ if fd.Recv != nil {
+ fn = recvBase(fd) + "." + fn
+ }
+ writes := map[ast.Node]bool{}
+ readsBody, callsDo := false, false
+ ast.Inspect(fd.Body, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.AssignStmt:
+ for _, l := range n.Lhs {
+ if s, ok := l.(*ast.SelectorExpr); ok && s.Sel.Name == spec.Field {
+ writes[s] = true
+ if fn != spec.Maker {
+ out = append(out, serverTextProblem{fd.Name.Name, fmt.Sprintf("writes %s.%s outside %s", spec.Type, spec.Field, spec.Maker)})
+ }
+ }
+ }
+ case *ast.CompositeLit:
+ if id, ok := n.Type.(*ast.Ident); ok && id.Name == spec.Type && fn != spec.Maker {
+ out = append(out, serverTextProblem{fd.Name.Name, fmt.Sprintf("makes a %s outside %s", spec.Type, spec.Maker)})
+ }
+ case *ast.SelectorExpr:
+ switch {
+ case n.Sel.Name == "Body":
+ readsBody = true
+ case n.Sel.Name == "Do":
+ callsDo = true
+ case n.Sel.Name == spec.Field && !writes[n] && fn != spec.Maker && fn != spec.Sink:
+ out = append(out, serverTextProblem{fd.Name.Name, fmt.Sprintf("reads %s.%s outside %s", spec.Type, spec.Field, spec.Sink)})
+ }
}
- case a.call != nil:
- if !c.externalCall(a.call, a.pos, depth+1) {
- return false
+ return true
+ })
+ if fn == spec.Maker {
+ maker = true
+ if !readsBody || !callsDo {
+ out = append(out, serverTextProblem{fn, "makes server text but reads no body of a response the HTTP client returned"})
+ }
+ }
+ if fn == spec.Sink {
+ for _, fld := range fd.Type.Params.List {
+ if id, ok := fld.Type.(*ast.Ident); ok && id.Name == spec.Type {
+ sink = true
+ }
}
- default:
- return false
}
}
- return true
- }
- return false
-}
-
-// externalCall reports whether result pos of call is external: the callee is
-// named in movesExternalText, or it is a package helper each of whose returns
-// gives an external value at pos (read in the helper's own context).
-func (c *evalCtx) externalCall(call *ast.CallExpr, pos, depth int) bool {
- if depth > maxEvalDepth {
- return false
- }
- name := ""
- switch f := call.Fun.(type) {
- case *ast.SelectorExpr:
- name = f.Sel.Name
- case *ast.Ident:
- name = f.Name
- }
- if _, ok := movesExternalText[c.file][name]; ok {
- return true
}
- decls, p := c.resolveCallees(call)
- if p != nil || len(decls) == 0 {
- return false
+ if !maker && !strings.Contains(dir, "testdata") {
+ out = append(out, serverTextProblem{spec.Maker, "the maker is not declared"})
}
- for _, d := range decls {
- if d.Body == nil {
- return false
- }
- key := fmt.Sprintf("external %p %d", d, pos)
- if c.visiting[key] {
- return false
- }
- c.visiting[key] = true
- hc := c.u.newCtx(strings.TrimPrefix(movesFset.Position(d.Pos()).Filename, c.u.root+"/"), d, c.noCalls)
- hc.visiting = c.visiting
- ok := hc.returnsExternal(d, pos, depth+1)
- delete(c.visiting, key)
- if !ok {
- return false
- }
+ if !sink {
+ out = append(out, serverTextProblem{spec.Sink, "the sink does not take " + spec.Type})
}
- return true
-}
-
-// returnsExternal reports whether every return of d gives an external value at
-// pos (c is d's own context).
-func (c *evalCtx) returnsExternal(d *ast.FuncDecl, pos, depth int) bool {
- nres := resultCount(d)
- named := namedResults(d)
- ok := true
- ast.Inspect(d.Body, func(n ast.Node) bool {
- if !ok {
- return false
- }
- switch n := n.(type) {
- case *ast.FuncLit:
- return false
- case *ast.ReturnStmt:
- switch {
- case len(n.Results) == nres && pos < len(n.Results):
- ok = c.external(n.Results[pos], depth)
- case len(n.Results) == 1 && nres > 1:
- cl, isCall := n.Results[0].(*ast.CallExpr)
- ok = isCall && c.externalCall(cl, pos, depth)
- case len(n.Results) == 0 && pos < len(named):
- ok = c.external(&ast.Ident{Name: named[pos], NamePos: n.Pos()}, depth)
- default:
- ok = false
- }
- }
- return true
- })
- return ok
+ return out
}
// checkFuncArg checks the function a call hands to a sentence-function
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 3f7f4a1f7..8cad37a64 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -78,6 +78,7 @@ var movesCopyIDs = []string{
"MV-N319",
"MV-N350", "MV-N351", "MV-N352", "MV-N353", "MV-N354", "MV-N355", "MV-N356",
"MV-N357", "MV-N358", "MV-N359", "MV-N360", "MV-N361", "MV-N362", "MV-N363", "MV-N364",
+ "MV-N365", "MV-N366", "MV-N367", "MV-N368", "MV-N369", "MV-N370", "MV-N371",
}
// copyRow is one row of the record. The File cell is the file the sentence is
@@ -738,9 +739,9 @@ var movesLocalSinks = map[string]map[string]int{
// Every operator sentence of `carlos move` goes through moveSay, L13's
// say (named so because the scan resolves helpers by name, and
// internal/release has a say of its own).
- // sayError and sayRecorded forward text this file does not write; their
- // arguments are traced where they are called (movesExternalText).
- "move.go": {"moveSay": 1, "sayError": 1, "sayRecorded": 1},
+ // Text the console wrote goes through saySpoken instead, which is no
+ // sink here: it takes only serverText (movesServerText).
+ "move.go": {"moveSay": 1},
}
var movesCopyTables = map[string][]string{
@@ -825,24 +826,14 @@ var movesSinkAllow = map[string]map[string]string{
},
}
-// movesExternalText names, per file, what a forwarding sink may pass on
-// without a row: text whose provenance is the console's answer, or an error
-// that crossed the API, never a sentence the file makes itself. A name is a
-// called function or method (its result, error or text) or a field read from
-// a decoded answer. Each entry says why. A local sink's argument is traced to
-// these names where the sink is called; a locally made error
-// (errors.New, fmt.Errorf) is a sentence and needs its row.
-var movesExternalText = map[string]map[string]string{
- "move.go": {
- "apiErrorFromBody": "the console's answer: its own JSON error sentence, recorded with the door that speaks it (M-C13, M-C17, MV-N1 and the rest), or apimode.go's reading of a body with none",
- "getJSON": "an error that crossed the API on a read: net/http's transport error, or apimode.go's console-moved explanation",
- "postJSON": "an error that crossed the API on a mutation, as getJSON's",
- "deleteJSON": "an error that crossed the API on an abort, as getJSON's",
- "Unmarshal": "encoding/json's error decoding the console's answer",
- "sleep": "mover.sleep's only error is ctx.Err(): the context's own cancellation, Go's text",
- "session": "resolveMode's refusal (moveDeps.session): apimode.go's own sentences, which every API verb says",
- "StuckReason": "the stuck reason the console's detail answer carries from the record: a sentence a provision transition recorded (MV-N300 to MV-N312 and the rows before them)",
- },
+// movesServerText names, per file, the type that carries text the console
+// wrote (fix round 2 of Plan 6 Task 1a, ruling 1). It is made in one function
+// that reads an HTTP response body, and said through one sink; the gate
+// exempts that sink by its type (the scan never sees it: it is no local
+// sink), and TestMovesServerTextHasOneMaker holds the rest. Every other value
+// at a sink is traced to a copy row.
+var movesServerText = map[string]movesServerTextSpec{
+ "move.go": {Type: "serverText", Field: "spoken", Maker: "mover.roundTrip", Sink: "saySpoken"},
}
// movesMappingHelpers are functions that map a refusal from another package
@@ -1696,46 +1687,51 @@ func checkFuncValueAllow(allow map[string]map[string]string, refs []funcRef) []s
return out
}
-// Fix round 1 of Plan 6 Task 1a, ruling 2: a forwarding sink's argument is
-// traced where the sink is called. Text whose provenance is the console's
-// answer or an error that crossed the API is exempt by name
-// (movesExternalText); a locally made error is a sentence that needs its row
-// (sayError(out, errors.New("unreviewed copy")) fails the gate); an error the
-// gate cannot trace is a violation.
-func TestMovesForwardedErrorsAreTraced(t *testing.T) {
- file := filepath.Join("testdata", "moves-copy", "sinks", "forwarded-local-error", "emit.go")
- savedSinks, savedExt := movesLocalSinks[file], movesExternalText[file]
- movesLocalSinks[file] = map[string]int{"moveSay": 1, "sayError": 1}
- movesExternalText[file] = map[string]string{"apiErrorFromBody": "the console's answer", "getJSON": "an error that crossed the API"}
+// Fix round 2 of Plan 6 Task 1a, ruling 1: text the console wrote reaches an
+// operator only as serverText, made in one function that reads an HTTP
+// response body and said only through its one sink. Every other value at a
+// sink is traced to a copy row. The re-review's three ways round that, and a
+// forged serverText, each fail the gate.
+func TestMovesProvenanceBypassesFail(t *testing.T) {
+ file := filepath.Join("testdata", "moves-copy", "sinks", "provenance", "emit.go")
+ saved := movesLocalSinks[file]
+ movesLocalSinks[file] = map[string]int{"moveSay": 1, "sayError": 1, "sayRecorded": 1}
defer func() {
- movesLocalSinks[file], movesExternalText[file] = savedSinks, savedExt
- if savedSinks == nil {
+ if saved == nil {
delete(movesLocalSinks, file)
- }
- if savedExt == nil {
- delete(movesExternalText, file)
+ } else {
+ movesLocalSinks[file] = saved
}
}()
u := newUniverse(t, ".", []string{file})
ems, vios := scanMovesFileIn(t, u, ".", file, false)
- var unrecorded []string
+ failed := map[string]bool{}
for _, e := range findUnrecorded(ems, nil) {
- unrecorded = append(unrecorded, e.fn+": "+strings.ReplaceAll(e.pattern, wild, "%"))
- }
- sort.Strings(unrecorded)
- // The two locally made errors are sentences with no row. The untraced
- // error, and a helper's own error, are values the gate cannot derive (and
- // violations, below). A helper that passes on getJSON's error (viaFetch)
- // is external.
- want := []string{"formatted: % boxes are unreviewed", "local: unreviewed copy", "untraced: ", "viaHelper: "}
- if strings.Join(unrecorded, "|") != strings.Join(want, "|") {
- t.Errorf("unrecorded = %q, want %q", unrecorded, want)
- }
- var where []string
+ failed[e.fn] = true
+ }
for _, v := range vios {
- where = append(where, v.fn)
+ failed[v.fn] = true
+ }
+ spec := movesServerTextSpec{Type: "serverText", Field: "spoken", Maker: "roundTrip", Sink: "saySpoken"}
+ for _, p := range checkServerText(t, file, spec) {
+ failed[p.fn] = true
+ }
+ for _, fn := range []string{"literalJSON", "forgedStuck", "sayRecorded", "forgedServer", "leaked"} {
+ if !failed[fn] {
+ t.Errorf("%s gets round the gate", fn)
+ }
+ }
+}
+
+// move.go's serverText has one maker, roundTrip, which reads the body of a
+// response it got from the HTTP client, and one sink, saySpoken.
+func TestMovesServerTextHasOneMaker(t *testing.T) {
+ for file, spec := range movesServerText {
+ for _, p := range checkServerText(t, file, spec) {
+ t.Errorf("%s: %s", file, p)
+ }
}
- if strings.Join(where, ",") != "untraced,viaHelper" {
- t.Errorf("violations in %v, want untraced and viaHelper: %v", where, vios)
+ if _, ok := movesServerText["move.go"]; !ok {
+ t.Fatal("move.go declares no serverText")
}
}
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 6ff1be194..fb8dd90f2 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -284,7 +284,7 @@ Columns: id, file, text, status.
## Plan 6
-(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim. Task 1a takes MV-N350 to MV-N364 (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved)
+(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim. Task 1a takes MV-N350 to MV-N371 (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved)
| id | file | text | status |
|---|---|---|---|
@@ -304,6 +304,13 @@ Columns: id, file, text, status.
| MV-N362 | move.go | <host> is relocating after its box was terminated. This move will not finish. | pending |
| MV-N363 | main.go | move an instance to another region: status | cut | abort | resolve --retry (operators; off unless the console enables it) | pending |
| MV-N364 | move.go (M-C10's <box> when the claim names none) | the target box | pending |
+| MV-N365 | move.go | Could not reach the console. Check the connection and try again. | pending |
+| MV-N366 | move.go | The console's answer could not be read. | pending |
+| MV-N367 | move.go | Not found. Check --account and --app, and that you are a member of the instance's account. | pending |
+| MV-N368 | move.go | The console answered <status> and gave no reason. | pending |
+| MV-N369 | move.go | carlos move could not sign in to a console. Run carlos auth login, or check --console, CARLOS_CONSOLE and CARLOS_SERVICE_TOKEN. | pending |
+| MV-N370 | move.go | This console has moved to <url>. Run carlos auth login --console <url>, then run the command again. | pending |
+| MV-N371 | move.go | Stopped waiting. | pending |
## Review of 2026-10-09
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 28e1059c2..232730442 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -564,7 +564,7 @@ var plan9B8CensusInventory = []censusEntry{
{"main.go", "main", "key-site", "lit:instances,lit:sidecars", "3eb1b1a9b8a7fd9cc2820b5a4348046b4f6bac3496d5ac6c4e2c32722233bd15"},
// Plan 6 Task 1a: `carlos move` builds the console's move door path
// (/api/cli/apps/.../instances/<host>/move); it writes no record.
- {"move.go", "moveMain", "key-site", "lit:instances", "b41a91fb74208d9f9a08ad797bcb483162eb19f79d176bd4acd52de76805dcd8"},
+ {"move.go", "moveMain", "key-site", "lit:instances", "d6c44f466f50f7454703d410bcefa624d932c2f58d430980ef2f08a426e0a029"},
{"schedule.go", "renderScheduleList", "key-site", "lit:instances", "af6d666083fbbc753eecc98809d3d307344cc15110362a7e93afab573749ea6e"},
{"internal/activator/layout.go", "LayoutInputs.sockets", "key-site", "lit:instances", "1d11ac5ac5be6a42e676e064771141fe8f4367c78cbb7e5c5cca072f40c27c42"},
{"internal/activator/paths.go", "ExecBackend.pathsFor", "key-site", "lit:instances", "cbc01865e27934cb38baab9329a0867b899149531b4706a533a6956afc7c71b3"},
diff --git a/move.go b/move.go
index b9898baef..cb3db48ab 100644
--- a/move.go
+++ b/move.go
@@ -1,6 +1,7 @@
package main
import (
+ "bytes"
"context"
"encoding/json"
"errors"
@@ -29,18 +30,19 @@ import (
// ends the command: the doors' rate limit answers before any handler, so the
// same call is sent again. The outcome is read only from `last`, bound to the
// id the console gave the move (L2, Review Focus 1), and its regions are that
-// move's own, bound from its own answers. Exit codes (L24): 0 done, or a
-// request accepted with --no-wait; 3 rolled back or aborted; 4 stuck,
-// rolling back past its release deadline, relocating, ready for a manual
-// cut, or an outcome not recorded; 5 refused (by the console, or before
-// anything was sent); 1 a transport error or a 5xx; 2 a usage error.
+// move's own, bound from its own answers; a region the wait never learnt is
+// never guessed. Exit codes (L24): 0 done, or a command accepted with
+// --no-wait (which reads nothing after the acceptance); 3 rolled back or
+// aborted; 4 stuck, rolling back past its release deadline, relocating,
+// ready for a manual cut, or an outcome not recorded; 5 refused (by the
+// console, or before anything was sent); 1 the console not reached, its
+// answer unreadable, or a 5xx; 2 a usage error.
//
-// Every operator sentence goes through moveSay, the copy gate's sink for this
-// file (movesLocalSinks). Text this file does not write goes through the two
-// forwarding sinks, sayError and sayRecorded, whose arguments the gate traces
-// where they are called, to the console's answer or an error that crossed the
-// API (movesExternalText). The flag help is said by moveFlagHelp
-// (movesSinkAllow).
+// What the operator reads is this file's own copy, through moveSay (the copy
+// gate's sink, movesLocalSinks), or text the console wrote, as serverText
+// through saySpoken. serverText is made only in roundTrip, from the body of a
+// response the HTTP client returned (movesServerText). Go's own errors are
+// never shown. The flag help is said by moveFlagHelp (movesSinkAllow).
const (
moveExitDone = 0
@@ -52,7 +54,7 @@ const (
moveProtocolMark = "phase1"
)
-// The sentences this file adds (copy record MV-N350 to MV-N364, pending).
+// The sentences this file adds (copy record MV-N350 to MV-N371, pending).
const (
moveNotEnabled = "Moves are not enabled on this console."
moveNoApp = "carlos move needs --app: name the app the instance belongs to."
@@ -63,25 +65,27 @@ const (
moveNeedsHost = "carlos move needs --host <host>."
moveNeedsTo = "carlos move needs --to <region>."
moveNeedsRetry = "carlos move resolve needs --retry."
+ moveUnreachable = "Could not reach the console. Check the connection and try again."
+ moveUnreadable = "The console's answer could not be read."
+ moveNotFound = "Not found. Check --account and --app, and that you are a member of the instance's account."
+ moveNoSession = "carlos move could not sign in to a console. Run carlos auth login, or check --console, CARLOS_CONSOLE and CARLOS_SERVICE_TOKEN."
+ moveStopped = "Stopped waiting."
)
func moveSay(out io.Writer, line string) {
fmt.Fprintln(out, line)
}
-// sayError passes on an error this file did not write: the console's answer
-// (apiErrorFromBody) or an error from a call across the API. The copy gate
-// traces each call's argument to one of those (movesExternalText); a locally
-// made error is a sentence and needs its row.
-func sayError(out io.Writer, err error) {
- moveSay(out, err.Error())
-}
+// serverText is text the console wrote: its refusal sentence, or the stuck
+// reason its detail answer carries from the record (a sentence a provision
+// transition recorded). It is made only in roundTrip, from the body of a
+// response the HTTP client returned, and said only through saySpoken. The
+// copy gate holds both (TestMovesServerTextHasOneMaker).
+type serverText struct{ spoken string }
-// sayRecorded passes on a sentence the console's answer carries from the
-// record: the stuck reason a provision transition recorded (after M-C11, on
-// its own line, preflight L28).
-func sayRecorded(out io.Writer, text string) {
- moveSay(out, text)
+// saySpoken says text the console wrote, as it wrote it.
+func saySpoken(out io.Writer, t serverText) {
+ fmt.Fprintln(out, t.spoken)
}
func runMove(args []string) {
@@ -191,9 +195,11 @@ func moveMain(ctx context.Context, args []string, out io.Writer, d moveDeps) int
moveSay(out, moveAsRoot)
return moveExitRefused
}
- sess, sessErr := d.session(a.console)
- if sessErr != nil {
- sayError(out, sessErr)
+ // resolveMode's own sentences are in no copy record (the general record
+ // is the nearest-region plan's), so carlos move says its own.
+ sess, err := d.session(a.console)
+ if err != nil {
+ moveSay(out, moveNoSession)
return moveExitError
}
if sess == nil {
@@ -260,6 +266,10 @@ type moveOutcome struct {
type moveAnswer struct {
moveFlat
Detail *moveDetailView `json:"detail"`
+ // stuck is the journal's stuck reason as server text, set by roundTrip
+ // (hasStuck when there is one).
+ stuck serverText
+ hasStuck bool
}
type moveDetailView struct {
@@ -272,15 +282,15 @@ type moveDetailView struct {
}
type moveJournal struct {
- ID string `json:"id"`
- To string `json:"to"`
- Cut string `json:"cut"`
- State string `json:"state"`
- CutPhase string `json:"cut_phase"`
- CutBudgetS int `json:"cut_budget_s"`
- Reason string `json:"reason"`
- StuckReason string `json:"stuck_reason"`
- Rollback *moveRollback `json:"rollback"`
+ ID string `json:"id"`
+ To string `json:"to"`
+ Cut string `json:"cut"`
+ State string `json:"state"`
+ CutPhase string `json:"cut_phase"`
+ CutBudgetS int `json:"cut_budget_s"`
+ Reason string `json:"reason"`
+ WireStuck string `json:"stuck_reason"` // read only by roundTrip
+ Rollback *moveRollback `json:"rollback"`
}
type moveRollback struct {
@@ -324,6 +334,101 @@ func (m *mover) withDetail() string {
// it gives up with exit 1: a console restart is not a failed move.
const moveTransient = 5
+// moveReply is one response from the move door, as roundTrip read it.
+type moveReply struct {
+ status int
+ answer moveAnswer // decoded, on a 2xx
+ refusal serverText // the console's own sentence, on another status
+ refused bool // refusal holds one
+ moved string // the console's new origin, on a console-shaped redirect
+ failed bool // the console was not reached, or its answer not read
+ unread bool // ... and it was the answer that could not be read
+ stopped bool // the context ended the call
+}
+
+// roundTrip sends one request to the move door and reads its response. It is
+// the one maker of serverText (movesServerText): the refusal's "error"
+// sentence, and the stuck reason of a detail answer, read from the body of
+// the response the HTTP client returned.
+func (m *mover) roundTrip(ctx context.Context, method, path string, in any) moveReply {
+ var body io.Reader
+ if in != nil {
+ raw, err := json.Marshal(in)
+ if err != nil {
+ return moveReply{failed: true, unread: true}
+ }
+ body = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, m.sess.c.base+path, body)
+ if err != nil {
+ return moveReply{failed: true}
+ }
+ if in != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if m.sess.c.token != "" {
+ req.Header.Set("Authorization", "Bearer "+m.sess.c.token)
+ }
+ res, err := m.sess.c.httpc.Do(req)
+ if err != nil {
+ return moveReply{failed: true, stopped: ctx.Err() != nil}
+ }
+ defer res.Body.Close()
+ r := moveReply{status: res.StatusCode}
+ if moved := consoleMoved(m.sess.c.base, res.StatusCode, res.Header.Get("Location")); moved != nil {
+ r.moved = moved.To
+ return r
+ }
+ raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ r.failed = true
+ return r
+ }
+ if res.StatusCode/100 == 2 {
+ if json.Unmarshal(raw, &r.answer) != nil {
+ r.failed, r.unread = true, true
+ return r
+ }
+ if d := r.answer.Detail; d != nil && d.Move != nil && d.Move.WireStuck != "" {
+ r.answer.stuck, r.answer.hasStuck = serverText{spoken: d.Move.WireStuck}, true
+ }
+ return r
+ }
+ var e struct {
+ Error string `json:"error"`
+ }
+ if json.Unmarshal(raw, &e) == nil && e.Error != "" {
+ r.refusal, r.refused = serverText{spoken: e.Error}, true
+ }
+ return r
+}
+
+// flat is the reply's flat view.
+func (r moveReply) flat() moveFlat {
+ return r.answer.moveFlat
+}
+
+// sayReply says why a reply ended the command: the console's own sentence
+// when it gave one, else this file's.
+func (m *mover) sayReply(r moveReply) {
+ switch {
+ case r.moved != "":
+ moveSay(m.out, fmt.Sprintf("This console has moved to %s. Run carlos auth login --console %s, then run the command again.", r.moved, r.moved))
+ case r.stopped:
+ moveSay(m.out, moveStopped)
+ case r.unread:
+ moveSay(m.out, moveUnreadable)
+ case r.failed:
+ moveSay(m.out, moveUnreachable)
+ case r.refused:
+ saySpoken(m.out, r.refusal)
+ case r.status == http.StatusNotFound:
+ moveSay(m.out, moveNotFound)
+ default:
+ moveSay(m.out, fmt.Sprintf("The console answered %d and gave no reason.", r.status))
+ }
+}
+
// limit notes a 429: from now on the command waits 10 seconds between calls,
// and says so the first time.
func (m *mover) limit() {
@@ -335,35 +440,32 @@ func (m *mover) limit() {
// get reads one answer, riding out 429s (backing off, said once) and a few
// transient failures. code is -1 on success, else the exit code to return.
-func (m *mover) get(ctx context.Context, path string, into any) (code int) {
+func (m *mover) get(ctx context.Context, path string) (moveAnswer, int) {
failures := 0
for {
- status, body, err := m.sess.getJSON(ctx, path)
+ r := m.roundTrip(ctx, http.MethodGet, path, nil)
+ ok := !r.failed && r.moved == ""
switch {
- case err == nil && status == http.StatusOK:
- if err := json.Unmarshal(body, into); err != nil {
- sayError(m.out, err)
- return moveExitError
- }
- return -1
- case err == nil && status == http.StatusTooManyRequests:
+ case ok && r.status == http.StatusOK:
+ return r.answer, -1
+ case r.moved != "" || r.stopped:
+ m.sayReply(r)
+ return moveAnswer{}, moveExitError
+ case ok && r.status == http.StatusTooManyRequests:
m.limit()
- case err == nil && status >= 400 && status < 500:
- sayError(m.out, apiErrorFromBody(status, body))
- return moveExitRefused
+ case ok && r.status >= 400 && r.status < 500:
+ m.sayReply(r)
+ return moveAnswer{}, moveExitRefused
default:
failures++
if failures >= moveTransient {
- if err == nil {
- err = apiErrorFromBody(status, body)
- }
- sayError(m.out, err)
- return moveExitError
+ m.sayReply(r)
+ return moveAnswer{}, moveExitError
}
}
- if err := m.sleep(ctx); err != nil {
- sayError(m.out, err)
- return moveExitError
+ if !m.sleep(ctx) {
+ moveSay(m.out, moveStopped)
+ return moveAnswer{}, moveExitError
}
}
}
@@ -375,25 +477,25 @@ func (m *mover) interval() time.Duration {
return m.poll
}
-func (m *mover) sleep(ctx context.Context) error {
+// sleep waits one interval; false when the context ended first.
+func (m *mover) sleep(ctx context.Context) bool {
t := time.NewTimer(m.interval())
defer t.Stop()
select {
case <-ctx.Done():
- return ctx.Err()
+ return false
case <-t.C:
- return nil
+ return true
}
}
func (m *mover) readFlat(ctx context.Context) (moveFlat, int) {
- var v moveFlat
- return v, m.get(ctx, m.path, &v)
+ a, code := m.get(ctx, m.path)
+ return a.moveFlat, code
}
func (m *mover) readDetail(ctx context.Context) (moveAnswer, int) {
- var a moveAnswer
- code := m.get(ctx, m.withDetail(), &a)
+ a, code := m.get(ctx, m.withDetail())
if code < 0 && a.Detail == nil {
a.Detail = &moveDetailView{}
}
@@ -401,53 +503,48 @@ func (m *mover) readDetail(ctx context.Context) (moveAnswer, int) {
}
// send sends one mutation, sending the same call again after the back-off
-// while the doors' rate limit answers 429 (ruling 1 of Task 1a's fix round:
-// the limit answers before any handler, so nothing was done).
-func (m *mover) send(ctx context.Context, method, tail string, body any) (status int, raw []byte, err error) {
+// while the doors' rate limit answers 429 (fix round 1, ruling 1: the limit
+// answers before any handler, so nothing was done).
+func (m *mover) send(ctx context.Context, method, tail string, body any) moveReply {
for {
- if method == http.MethodDelete {
- status, raw, err = m.sess.deleteJSON(ctx, m.path)
- } else {
- status, raw, err = m.sess.postJSON(ctx, m.path+tail, body)
- }
- if err != nil || status != http.StatusTooManyRequests {
- return status, raw, err
+ r := m.roundTrip(ctx, method, m.path+tail, body)
+ if r.failed || r.moved != "" || r.status != http.StatusTooManyRequests {
+ return r
}
m.limit()
- if err = m.sleep(ctx); err != nil {
- return 0, nil, err
+ if !m.sleep(ctx) {
+ return moveReply{failed: true, stopped: true}
}
}
}
// mutate sends one move mutation. A refusal is the console's own sentence
-// and exit 5. On acceptance the answer names the move (Task 2), and the move's
-// detail, read once now, binds its regions: the sentences about this move
-// name them, whatever a later move does to the record. A request says M-C1;
-// then the wait (unless --no-wait) follows the move's id.
+// and exit 5. On acceptance the answer names the move (Task 2). With
+// --no-wait that answer is all: its state is said, and nothing more is read
+// (fix round 2, ruling 2). Else the move's detail, read once now, binds its
+// regions, a request says M-C1, and the wait follows the move's id.
func (m *mover) mutate(ctx context.Context, method, tail string, body any, wait, request bool) int {
- status, raw, err := m.send(ctx, method, tail, body)
- if err != nil {
- sayError(m.out, err)
+ r := m.send(ctx, method, tail, body)
+ switch {
+ case r.failed || r.moved != "":
+ m.sayReply(r)
return moveExitError
- }
- if status >= 400 && status < 500 {
- sayError(m.out, apiErrorFromBody(status, raw))
+ case r.status >= 400 && r.status < 500:
+ m.sayReply(r)
return moveExitRefused
- }
- if status < 200 || status > 299 {
- sayError(m.out, apiErrorFromBody(status, raw))
- return moveExitError
- }
- var view moveFlat
- if err := json.Unmarshal(raw, &view); err != nil {
- sayError(m.out, err)
+ case r.status < 200 || r.status > 299:
+ m.sayReply(r)
return moveExitError
}
+ view := r.flat()
if view.ID == "" || view.Protocol != moveProtocolMark {
moveSay(m.out, moveNotEnabled)
return moveExitRefused
}
+ if !wait {
+ moveSay(m.out, fmt.Sprintf("Move %s of %s is %s.", view.ID, m.host, view.State))
+ return moveExitDone
+ }
w := newMoveWatch(m, view.ID)
w.toRegion = view.Target
a, code := m.readDetail(ctx)
@@ -458,9 +555,6 @@ func (m *mover) mutate(ctx context.Context, method, tail string, body any, wait,
if request && w.first("start") {
moveSay(m.out, fmt.Sprintf("Moving %s from %s to %s.", m.host, w.fromRegion, w.toRegion))
}
- if !wait {
- return moveExitDone
- }
return w.run(ctx)
}
@@ -541,11 +635,9 @@ func (w *moveWatch) learn(a moveAnswer) {
// never inferred from a state seen: it is last, bound to this move's id.
func (w *moveWatch) run(ctx context.Context) int {
for first := true; ; first = false {
- if !first {
- if err := w.m.sleep(ctx); err != nil {
- sayError(w.m.out, err)
- return moveExitError
- }
+ if !first && !w.m.sleep(ctx) {
+ moveSay(w.m.out, moveStopped)
+ return moveExitError
}
view, code := w.m.readFlat(ctx)
if code >= 0 {
@@ -638,8 +730,8 @@ func (w *moveWatch) observe(a moveAnswer) int {
}
case "stuck":
moveSay(w.m.out, fmt.Sprintf("%s could not start in %s or in %s. It is offline. Run carlos move resolve --host %s --retry once the cause is fixed.", host, to, from, host))
- if j.StuckReason != "" {
- sayRecorded(w.m.out, j.StuckReason)
+ if a.hasStuck {
+ saySpoken(w.m.out, a.stuck)
}
return moveExitWaiting
case "relocating":
@@ -654,10 +746,11 @@ func (w *moveWatch) observe(a moveAnswer) int {
}
// outcome is the end of a move that is no longer in flight: last, if it is
-// this move's, else not recorded. Its regions are the move's own. Only with
-// no move in flight at all may the record's region stand in for one the wait
-// never learnt: then no later move can have changed it (a later move that
-// closed would be last, and one in flight would be shown).
+// this move's, else not recorded. Its regions are the move's own, as the
+// wait learnt them; one it never learnt is never filled in from the record
+// (fix round 2, ruling 3). Without its region a sentence that names it gives
+// way: M-C9 alone for M-C8 (whose destination is unknown), and the outcome
+// line for M-C7, M-C9 and the aborted line.
func (w *moveWatch) outcome(a moveAnswer) int {
host := w.m.host
l := a.Last
@@ -666,14 +759,6 @@ func (w *moveWatch) outcome(a moveAnswer) int {
return moveExitWaiting
}
from, to := w.fromRegion, w.toRegion
- if a.State == "none" {
- if from == "" {
- from = a.Detail.Region
- }
- if to == "" {
- to = a.Detail.Region
- }
- }
switch l.Outcome {
case "done":
if to == "" {
@@ -686,7 +771,7 @@ func (w *moveWatch) outcome(a moveAnswer) int {
return w.outcomeLine(l, moveExitRolled)
}
// M-C8 for a rollback the wait missed only when the outcome carries
- // its reason (ruling 4): a reason is never made up.
+ // its reason (ruling 4), and only with its destination known.
if l.Reason != "" && to != "" && w.first("rollback") {
moveSay(w.m.out, fmt.Sprintf("%s could not start %s: %s. Moving it back to %s.", to, host, l.Reason, from))
}
@@ -704,7 +789,7 @@ func (w *moveWatch) outcome(a moveAnswer) int {
}
// outcomeLine says the outcome without a region, when the wait never learnt
-// the move's own and another move holds the record now.
+// the move's own.
func (w *moveWatch) outcomeLine(l *moveOutcome, code int) int {
moveSay(w.m.out, fmt.Sprintf("Move %s of %s is %s.", l.ID, w.m.host, l.Outcome))
return code
diff --git a/move_test.go b/move_test.go
index f6d85006b..269bae9ab 100644
--- a/move_test.go
+++ b/move_test.go
@@ -53,6 +53,8 @@ type fakeMoveConsole struct {
// startDetail, when set, is the detail answer right after a mutation
// (the move's own start), whatever the next scripted frame is.
startDetail frame
+ // detailStatus, when set, answers every detail GET with that status.
+ detailStatus int
}
func (c *fakeMoveConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -71,6 +73,10 @@ func (c *fakeMoveConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && path == tMovePth {
if r.URL.Query().Get("detail") == "1" {
c.details++
+ if c.detailStatus != 0 {
+ write(c.detailStatus, map[string]string{"error": "detail refused by the test"})
+ return
+ }
cur := c.last
if c.mutated && c.startDetail != nil {
cur = c.startDetail
@@ -468,17 +474,37 @@ func TestMoveRefusesRoot(t *testing.T) {
wantLines(t, out.String(), "Run carlos move as yourself, not as root. It uses your own credentials.")
}
-// --no-wait answers after the request: M-C1, exit 0.
+// --no-wait answers from the mutation's own answer, and reads nothing after
+// it (fix round 2, ruling 2): a detail read that fails or is rate limited
+// cannot change the exit of a command the console accepted.
func TestMoveNoWait(t *testing.T) {
c := &fakeMoveConsole{frames: []frame{none(nil), inFlight("preparing", nil, nil)}}
code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk", "--no-wait")
if code != 0 {
t.Fatalf("exit %d:\n%s", code, out)
}
- wantLines(t, out, mc1)
+ wantLines(t, out, "Move "+tMoveID+" of acme.go.tito.io is preparing.")
wantNoLine(t, out, mc2)
}
+func TestMoveNoWaitReadsNothingAfterAcceptance(t *testing.T) {
+ for _, status := range []int{http.StatusInternalServerError, http.StatusTooManyRequests} {
+ for _, args := range [][]string{
+ {"--host", tHost, "--to", "uk", "--no-wait"}, {"cut", "--host", tHost, "--no-wait"},
+ {"abort", "--host", tHost, "--no-wait"}, {"resolve", "--host", tHost, "--retry", "--no-wait"},
+ } {
+ c := &fakeMoveConsole{frames: []frame{inFlight("ready", nil, nil)}, detailStatus: status}
+ if args[0] == "--host" {
+ c.frames = []frame{none(nil)}
+ }
+ code, out := runMoveAgainst(t, c, args...)
+ if code != 0 || c.details != 0 {
+ t.Fatalf("%v with detail answering %d: exit %d after %d detail reads, want 0 and none:\n%s", args, status, code, c.details, out)
+ }
+ }
+ }
+}
+
// status: one read, the state in a line, exit 0; --wait waits for this
// move's outcome; with nothing in flight and nothing closed, it says so.
func TestMoveStatus(t *testing.T) {
@@ -499,7 +525,9 @@ func TestMoveStatus(t *testing.T) {
// move this operator was waiting on.
closed := none(outcome(tMoveID, "rolled_back", 0))
c = &fakeMoveConsole{frames: []frame{closed}}
- if code, out := runMoveAgainst(t, c, "status", "--host", tHost, "--wait"); code != 3 || !strings.Contains(out, mc9+"\n") {
+ // The wait never saw the move, so it never learnt its regions, and the
+ // record's region now is not taken for them (fix round 2, ruling 3).
+ if code, out := runMoveAgainst(t, c, "status", "--host", tHost, "--wait"); code != 3 || !strings.Contains(out, "Move "+tMoveID+" of acme.go.tito.io is rolled_back.\n") || strings.Contains(out, "back in") {
t.Fatalf("status --wait after a rollback = %d:\n%s", code, out)
}
}
@@ -744,3 +772,77 @@ func TestMoveFlagHelp(t *testing.T) {
t.Errorf("a bad flag's help:\n%s", out.String())
}
}
+
+// Fix round 2, ruling 3: a region the wait never learnt is not filled in. A
+// known origin with an unknown destination says M-C9 alone, even when the
+// outcome carries a reason, since M-C8 names the destination.
+func TestMoveOutcomeNeverGuessesARegion(t *testing.T) {
+ var out bytes.Buffer
+ m := &mover{out: &out, host: tHost}
+ w := newMoveWatch(m, tMoveID)
+ w.fromRegion = "ie"
+ last := &moveOutcome{ID: tMoveID, Outcome: "rolled_back", Reason: "the landing ran past its deadline"}
+ a := moveAnswer{moveFlat: moveFlat{State: "none", Last: last}, Detail: &moveDetailView{Region: "fr"}}
+ if code := w.outcome(a); code != 3 {
+ t.Fatalf("exit %d", code)
+ }
+ if got := out.String(); got != mc9+"\n" {
+ t.Fatalf("output %q, want M-C9 alone", got)
+ }
+}
+
+// Fix round 2, ruling 1: what the CLI says when the console is not reached,
+// answers with no reason, or the session cannot be made, is this file's own
+// copy; only the console's own sentence is passed on, as server text.
+func TestMoveFixedSentences(t *testing.T) {
+ // Nothing listens: the transport fails.
+ ts := httptest.NewServer(http.NotFoundHandler())
+ dead := ts.URL
+ ts.Close()
+ deps := moveDeps{
+ session: func(string) (*apiSession, error) {
+ return &apiSession{c: newConsoleClient(dead, "cli-token"), console: dead, address: "op@keymail.dev"}, nil
+ },
+ euid: func() int { return 1000 }, poll: time.Millisecond,
+ }
+ var out bytes.Buffer
+ if code := moveMain(context.Background(), []string{"status", "--host", tHost, "--account", "bab", "--app", "titogo"}, &out, deps); code != 1 {
+ t.Fatalf("unreachable console: exit %d:\n%s", code, out.String())
+ }
+ wantLines(t, out.String(), "Could not reach the console. Check the connection and try again.")
+
+ // The session cannot be made: one sentence of this file.
+ out.Reset()
+ deps.session = func(string) (*apiSession, error) { return nil, errors.New("unreviewed resolveMode text") }
+ if code := moveMain(context.Background(), []string{"status", "--host", tHost, "--account", "bab", "--app", "titogo"}, &out, deps); code != 1 {
+ t.Fatalf("no session: exit %d:\n%s", code, out.String())
+ }
+ wantLines(t, out.String(), "carlos move could not sign in to a console. Run carlos auth login, or check --console, CARLOS_CONSOLE and CARLOS_SERVICE_TOKEN.")
+ wantNoLine(t, out.String(), "unreviewed resolveMode text")
+
+ // A refusal with no JSON reason (the family's 404), and a 5xx with none.
+ for _, c := range []struct {
+ status int
+ code int
+ want string
+ }{
+ {http.StatusNotFound, 5, "Not found. Check --account and --app, and that you are a member of the instance's account."},
+ {http.StatusBadGateway, 1, "The console answered 502 and gave no reason."},
+ } {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(c.status)
+ _, _ = w.Write([]byte("plain text"))
+ }))
+ deps.session = func(string) (*apiSession, error) {
+ return &apiSession{c: newConsoleClient(ts.URL, "cli-token"), console: ts.URL, address: "op@keymail.dev"}, nil
+ }
+ out.Reset()
+ code := moveMain(context.Background(), []string{"status", "--host", tHost, "--account", "bab", "--app", "titogo"}, &out, deps)
+ ts.Close()
+ if code != c.code {
+ t.Fatalf("%d with no reason: exit %d, want %d:\n%s", c.status, code, c.code, out.String())
+ }
+ wantLines(t, out.String(), c.want)
+ wantNoLine(t, out.String(), "plain text")
+ }
+}
diff --git a/testdata/moves-copy/sinks/forwarded-local-error/emit.go b/testdata/moves-copy/sinks/forwarded-local-error/emit.go
deleted file mode 100644
index 2af6950ee..000000000
--- a/testdata/moves-copy/sinks/forwarded-local-error/emit.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package fixture
-
-// Not compiled. sayError is a local sink that forwards an error, as move.go's
-// does. What reaches it is traced at each call: the console's own text
-// (apiErrorFromBody) and an error from a call across the API (getJSON) are
-// external, by name; a locally made error is a sentence and needs its row;
-// an error the gate cannot trace is a violation.
-
-func moveSay(out int, line string) {}
-
-func sayError(out int, err error) {
- moveSay(out, err.Error())
-}
-
-func local(out int) {
- sayError(out, errors.New("unreviewed copy"))
-}
-
-func formatted(out int, n int) {
- sayError(out, fmt.Errorf("%d boxes are unreviewed", n))
-}
-
-func external(out int, status int, body []byte) {
- sayError(out, apiErrorFromBody(status, body))
-}
-
-func crossed(out int) {
- _, _, err := getJSON("/x")
- if err != nil {
- sayError(out, err)
- }
-}
-
-func untraced(out int, err error) {
- sayError(out, err)
-}
-
-// A package helper's error result is traced through its returns: one that
-// makes its own error is not external and fails the gate; one that passes on
-// an external error is external.
-func helper() error {
- return errors.New("helper copy")
-}
-
-func viaHelper(out int) {
- sayError(out, helper())
-}
-
-func fetch() (int, error) {
- _, _, err := getJSON("/y")
- return 0, err
-}
-
-func viaFetch(out int) {
- if _, err := fetch(); err != nil {
- sayError(out, err)
- }
-}
diff --git a/testdata/moves-copy/sinks/provenance/emit.go b/testdata/moves-copy/sinks/provenance/emit.go
new file mode 100644
index 000000000..166f75172
--- /dev/null
+++ b/testdata/moves-copy/sinks/provenance/emit.go
@@ -0,0 +1,50 @@
+package fixture
+
+// Not compiled. The ways round the provenance rule that the 2026-10-11
+// re-review of Plan 6 Task 1a found, plus a forged serverText. Each one
+// must fail the gate (TestMovesProvenanceBypassesFail).
+
+type serverText struct{ spoken string }
+
+type moveJournal struct{ StuckReason string }
+
+func moveSay(out int, line string) {}
+
+func sayError(out int, err error) { moveSay(out, err.Error()) }
+
+// Bypass 3: the forwarding sink reassigns its own parameter.
+func sayRecorded(out int, text string) {
+ text = "unreviewed reassignment"
+ moveSay(out, text)
+}
+
+func saySpoken(out int, t serverText) {}
+
+func apiErrorFromBody(status int, body []byte) error {
+ var e struct{ Error string }
+ if json.Unmarshal(body, &e) == nil && e.Error != "" {
+ return errors.New(e.Error)
+ }
+ return fmt.Errorf("unexpected response (status %d)", status)
+}
+
+// Bypass 1: the console's error decoder, fed literal JSON.
+func literalJSON(out int) {
+ sayError(out, apiErrorFromBody(400, []byte(`{"error":"unreviewed copy"}`)))
+}
+
+// Bypass 2: a stuck reason written here, not read from an answer.
+func forgedStuck(out int) {
+ j := moveJournal{StuckReason: "unreviewed stuck reason"}
+ sayRecorded(out, j.StuckReason)
+}
+
+// Server text made outside its one maker.
+func forgedServer(out int) {
+ saySpoken(out, serverText{spoken: "unreviewed server text"})
+}
+
+// Server text read outside its one sink.
+func leaked(out int, t serverText) {
+ moveSay(out, "the console says "+t.spoken)
+}