carlos / platform Public

moves copy: bring 24 approved rows to their code, 21 stay pending

Paul approved all 205 rows on 2026-10-09; 45 were still pending because
the copy gate could not find their text in the file they named.

- 18 rows drifted in form: the code said the approved bytes, but not as
  one literal or format the gate reads (a %w: prefix, a noun parameter,
  a sentence assembled in two steps, a sentinel plus a path). The code now
  emits the same bytes as one literal or format: provision refusals
  (MV-N12 to N15, N40 to N45), the instance gate's rate limit (MV-N52),
  T-check "did not pass" (MV-N59), the archive's path errors (MV-N77,
  N80, N86), the floor write, floor lock and restore rearm sentences in
  systemapply (MV-N92, N105, N106). The only byte difference: a move in
  state "<state>" is now written with quotes in the format, not %q.
- 6 rows moved: M-C14, M-C15, M-C16, MV-N72, MV-N79, MV-N81 are said by
  internal/host/movesphase1.go; their File cell is corrected.
- 21 rows stay pending and the review section says why: 18 not built
  (move.go, movespaging.go, M-C13, M-C19, the declare-lost door) and 3
  built but templated in the row (MV-N54, MV-N129, MV-N146).

Approved text is unchanged.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0f71a4ccf8714cbac729d58c2b22efdda1a0c5f3 parent e772960
7 files changed, +118 −59
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +40 −25
  • internal/console/apiinstancemove.go +3 −1
  • internal/host/movesfloor.go +10 −2
  • internal/host/systemapply.go +7 −5
  • internal/moves/archive/archive.go +32 −12
  • internal/moves/readiness.go +3 −4
  • internal/provision/provision.go +23 −10
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 ba8067f97..19458f100 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -26,9 +26,9 @@ Columns: id, file, text, status.
| M-C11 | move.go | <host> could not start in <to> or in <from>. It is offline. Run carlos move resolve --host <host> --retry once the cause is fixed. | pending |
| M-C12 | move.go | Ready to cut. Run carlos move cut --host <host> when you are ready. | pending |
| M-C13 | internal/console/apiinstancemove.go | This move has started its cut. It will finish or move back on its own. | pending |
-| M-C14 | internal/host/movespass.go | <host> keeps a file this move cannot copy: <path>. Only regular files and folders move. | pending |
-| M-C15 | internal/host/movespass.go | <host> keeps more than 256 MiB of files beside its database. Keep large files in object storage. | pending |
-| M-C16 | internal/host/movesreport.go | <host> does not answer its readiness path. Declare moves.ready_path for this app. | pending |
+| M-C14 | internal/host/movesphase1.go | <host> keeps a file this move cannot copy: <path>. Only regular files and folders move. | approved |
+| M-C15 | internal/host/movesphase1.go | <host> keeps more than 256 MiB of files beside its database. Keep large files in object storage. | approved |
+| M-C16 | internal/host/movesphase1.go | <host> does not answer its readiness path. Declare moves.ready_path for this app. | approved |
| M-C17 | internal/console/apiinstancemove.go | <host> uses private storage. Instances with private storage cannot move yet. | approved |
| M-C18 | move.go | This declares <box> lost. Anything <host> wrote on <box> after <time> is lost. Type the host name to confirm. | pending |
| M-C19 | internal/console/apiinstances.go | <host> is being moved. Delete it after the move finishes, or abort the move first. | pending |
@@ -51,10 +51,10 @@ Columns: id, file, text, status.
| MV-N9 | internal/host/movesreport.go | the staging lane holds another move's move.json | approved |
| MV-N10 | internal/host/systemapply.go | moves protocol floor: <version> is below <min_release> | approved |
| MV-N11 | internal/console/apiinstancemove.go | declare-lost is not enabled in this deployment yet | pending |
-| MV-N12 | internal/provision/provision.go | no journaled move in flight | pending |
-| MV-N13 | internal/provision/provision.go | the record's move is <id> for incarnation <incarnation> | pending |
-| MV-N14 | internal/provision/provision.go | malformed move start | pending |
-| MV-N15 | internal/provision/provision.go | past the point of no return. the cut completes forward or moves back on its own | pending |
+| MV-N12 | internal/provision/provision.go | no journaled move in flight | approved |
+| MV-N13 | internal/provision/provision.go | the record's move is <id> for incarnation <incarnation> | approved |
+| MV-N14 | internal/provision/provision.go | malformed move start | approved |
+| MV-N15 | internal/provision/provision.go | past the point of no return. the cut completes forward or moves back on its own | approved |
| MV-N16 | internal/console/apiinstancemove.go | the instance is already in <region> | approved |
| MV-N17 | internal/console/apiinstancemove.go | could not read the app's instances manifest. try again | approved |
| MV-N18 | internal/console/apiinstancemove.go | could not read this instance's storage binding. try again | approved |
@@ -79,26 +79,26 @@ Columns: id, file, text, status.
| MV-N37 | internal/console/apiinstancemove.go | this console is below the moves protocol floor. roll the console first | approved |
| MV-N38 | internal/console/apiinstancemove.go | the move changed since this request read it. read its state and try again | approved |
| MV-N39 | internal/console/apiinstancemove.go | past the point of no return. the cut completes forward | approved |
-| MV-N40 | internal/provision/provision.go | cut is only legal from ready | pending |
-| MV-N41 | internal/provision/provision.go | already aborting | pending |
-| MV-N42 | internal/provision/provision.go | a move in state "<state>" cannot be aborted | pending |
-| MV-N43 | internal/provision/provision.go | a move to <to> is already <state> | pending |
-| MV-N44 | internal/provision/provision.go | a <role> does not move. delete it and create one in the new region | pending |
-| MV-N45 | internal/provision/provision.go | no move in flight | pending |
+| MV-N40 | internal/provision/provision.go | cut is only legal from ready | approved |
+| MV-N41 | internal/provision/provision.go | already aborting | approved |
+| MV-N42 | internal/provision/provision.go | a move in state "<state>" cannot be aborted | approved |
+| MV-N43 | internal/provision/provision.go | a move to <to> is already <state> | approved |
+| MV-N44 | internal/provision/provision.go | a <role> does not move. delete it and create one in the new region | approved |
+| MV-N45 | internal/provision/provision.go | no move in flight | approved |
| MV-N46 | internal/console/apiinstancemove.go | this move's state does not allow that. read its state and try again | approved |
| MV-N47 | internal/console/apiinstancemove.go | only exec-backed instances move. this route is kind <kind> | approved |
| MV-N48 | internal/console/apiinstancemove.go | an addr-backed route's process is not platform-managed. there is nothing to move | approved |
| MV-N49 | internal/console/apiinstancemove.go | fleet-placed instances keep state-does-not-follow semantics. moves are for platform regions | approved |
| MV-N50 | internal/console/apiinstancemove.go | pooled instances do not move yet. their data lives on the pool's own box | approved |
| 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 | pending |
+| 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-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 |
| MV-N58 | internal/moves/readiness.go | <check> was not reported | approved |
-| MV-N59 | internal/moves/readiness.go | <check>: did not pass | pending |
+| MV-N59 | internal/moves/readiness.go | <check>: did not pass | approved |
| MV-N60 | internal/moves/readiness.go | T11: the cut budget admits no probe attempt | approved |
| MV-N61 | internal/moves/readiness.go | T11: the cut budget was priced for a final round of <MiB> MiB and the bar is <MiB> MiB | approved |
| MV-N62 | internal/moves/readiness.go | the source has not published a readiness report yet | approved |
@@ -111,27 +111,27 @@ Columns: id, file, text, status.
| MV-N69 | internal/moves/readiness.go | the database size could not be measured | approved |
| MV-N70 | internal/moves/readiness.go | the cut window is not a usable number of seconds | approved |
| MV-N71 | internal/moves/readiness.go | the cut needs about <seconds> seconds with one probe attempt and the window is <seconds> seconds | approved |
-| MV-N72 | internal/host/movesreport.go | nested instances cannot move yet | pending |
+| MV-N72 | internal/host/movesphase1.go | nested instances cannot move yet | approved |
| MV-N73 | internal/host/movesreport.go | the source has published no archive yet | approved |
| MV-N74 | internal/host/movesreport.go | the host claim has not touched <region> yet | approved |
| MV-N75 | internal/host/movesreport.go | waiting for the root stage pass | approved |
| MV-N76 | internal/host/movesreport.go | the source has not reported its release yet | approved |
-| MV-N77 | internal/moves/archive/archive.go | archive: only regular files and directories move: <path> | pending |
+| MV-N77 | internal/moves/archive/archive.go | archive: only regular files and directories move: <path> | approved |
| MV-N78 | internal/moves/archive/archive.go | archive: the files directory is over 256 MiB | approved |
-| MV-N79 | internal/moves/archive/archive.go | archive: the files directory holds more than 131072 entries | pending |
-| MV-N80 | internal/moves/archive/archive.go | archive: a file changed while it was being archived: <path> | pending |
-| MV-N81 | internal/moves/archive/archive.go | archive: refused path: <path> is longer than 4096 bytes | pending |
+| MV-N79 | internal/host/movesphase1.go | archive: the files directory holds more than 131072 entries | approved |
+| MV-N80 | internal/moves/archive/archive.go | archive: a file changed while it was being archived: <path> | approved |
+| MV-N81 | internal/host/movesphase1.go | archive: refused path: <path> is longer than 4096 bytes | approved |
| MV-N82 | internal/moves/readiness.go | T11: the target's report carries a rate that is not a usable number | approved |
| MV-N83 | internal/moves/readiness.go | T11: the target's report claims <n> probe attempts and its measurements admit <n> | approved |
| MV-N84 | internal/moves/readiness.go | the target's readiness report is dated ahead of this clock | approved |
| MV-N85 | internal/moves/readiness.go | the source's readiness report is dated ahead of this clock | approved |
-| MV-N86 | internal/moves/archive/archive.go | archive: only regular files and directories move: <path> is a hard link | pending |
+| MV-N86 | internal/moves/archive/archive.go | archive: only regular files and directories move: <path> is a hard link | approved |
| MV-N87 | internal/host/systemapply.go | moves protocol floor: carlos.prev records no version, so it is below <min_release> | approved |
| MV-N88 | internal/host/systemapply.go | moves protocol floor: the floor on this box cannot be read: <error> | approved |
| MV-N89 | internal/host/systemapply.go | a participant in a held operation does not downgrade: <version> is below the running <release> | approved |
| MV-N90 | internal/host/systemapply.go | a participant in a held operation does not downgrade, and the registry cannot say whether this box is one: <error> | approved |
| MV-N91 | internal/host/systemapply.go | a participant in a held operation does not downgrade, and the running release is unknown | approved |
-| MV-N92 | internal/host/systemapply.go | moves protocol floor: could not write it on this box: <error> | pending |
+| MV-N92 | internal/host/systemapply.go | moves protocol floor: could not write it on this box: <error> | approved |
| MV-N93 | internal/host/systemapply.go | <refusal>; the root passes paused for the install could not be started again: <error>. the next apply or recovery starts them | approved |
| MV-N94 | internal/host/systemapply.go | <refusal>; the root passes paused for the install could not be started again: <error>, and the retry could not be recorded: <error>. the journal stays for the next recovery | approved |
| MV-N95 | internal/host/systemapply.go | <refusal>; the root passes paused for the restore could not be started again: <error> | approved |
@@ -144,8 +144,8 @@ Columns: id, file, text, status.
| MV-N102 | internal/release/materializeheld.go | release: held rows are not staged while CARLOS_HOST_CLAIM is on | approved |
| MV-N103 | internal/appconfig/renderheld.go | config: not a held row of this move | approved |
| MV-N104 | internal/appconfig/renderheld.go | config: held rows are not rendered while CARLOS_HOST_CLAIM is on | approved |
-| MV-N105 | internal/host/systemapply.go | moves protocol floor: another writer holds the floor lock on this box; this waits for the next run | pending |
-| MV-N106 | internal/host/systemapply.go | <error>; the root passes paused for the restore could not be started again: <error>, and the retry could not be recorded: <error> | pending |
+| MV-N105 | internal/host/systemapply.go | moves protocol floor: another writer holds the floor lock on this box; this waits for the next run | approved |
+| MV-N106 | internal/host/systemapply.go | <error>; the root passes paused for the restore could not be started again: <error>, and the retry could not be recorded: <error> | approved |
| MV-N107 | internal/host/systemapply.go | <error>; the journal stays: <error> | approved |
| MV-N108 | internal/activator/backend_exec.go | S2: the agent is not placed in its delegated cgroup subtree, so it cannot spawn strictly | approved |
| MV-N109 | internal/activator/backend_exec.go | S2: this host's last strict group has not been cleaned up yet | approved |
@@ -273,6 +273,21 @@ Columns: id, file, text, status.
Paul approved the wording of all 205 rows then pending (M-C1 to M-C22 and MV-N1 to MV-N183), with no edits. 160 are marked approved above: their code already speaks the approved text.
-The other 45 stay `pending` here only because the copy gate requires an approved row's text to appear in its file. Their wording is approved and final. Each flips to approved, verbatim, in the commit that makes its file speak it: the CLI and paging strings when Plans 5 and 6 build them, and the drifted ones when their code is brought back to the approved text. The 45 rows are: M-C1, M-C10, M-C11, M-C12, M-C13, M-C14, M-C15, M-C16, M-C18, M-C19, M-C2, M-C20, M-C22, M-C3, M-C4, M-C5, M-C6, M-C7, M-C8, M-C9, MV-N105, MV-N106, MV-N11, MV-N12, MV-N129, MV-N13, MV-N14, MV-N146, MV-N15, MV-N40, MV-N41, MV-N42, MV-N43, MV-N44, MV-N45, MV-N52, MV-N54, MV-N59, MV-N72, MV-N77, MV-N79, MV-N80, MV-N81, MV-N86, MV-N92.
+The other 45 were `pending` only because the copy gate requires an approved row's text to appear in its file. Their wording is approved and final. 24 are now `approved`: their code was brought back to the approved text (18 rows: MV-N12, MV-N13, MV-N14, MV-N15, MV-N40 to MV-N45, MV-N52, MV-N59, MV-N77, MV-N80, MV-N86, MV-N92, MV-N105, MV-N106), or the row's File cell was corrected to the file that says it (6 rows, now `internal/host/movesphase1.go`: M-C14, M-C15, M-C16, MV-N72, MV-N79, MV-N81).
+
+21 stay `pending`. Each flips to approved, verbatim, in the commit that makes its file speak it.
+
+Not built yet (18):
+
+- M-C1 to M-C12, M-C18 and M-C22 (14 rows, `move.go`): the `carlos move` CLI is Plan 6, and no `move.go` exists.
+- M-C13 and M-C19 (`internal/console/apiinstancemove.go`, `internal/console/apiinstances.go`): the abort refusal after the cut started and the delete door's refusal during a move. Plan 5 Task 8 lists both as Plan 6 copy; no code says either.
+- 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:
+
+- 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.
Rows added after this review (MV-N184 onward, MV-N300 onward) still need review.
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index ac38b9e06..6ff8122ee 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -118,7 +118,9 @@ func phase1MoveCopy() moveCopy {
ambiguous: func(seg, alts string) string {
return fmt.Sprintf("account %q is ambiguous. pass --account-id with one of: %s", seg, alts)
},
- tooMany: func(noun string) string { return "too many " + noun + " requests. wait a minute and try again" },
+ // moveGate asks the family's gate for the noun "instance" and no
+ // other, so the recorded sentence (MV-N52) is written whole.
+ tooMany: func(string) string { return "too many instance requests. wait a minute and try again" },
noBucket: "this console has no deployment bucket, so it cannot answer this",
},
kind: func(k string) string { return "only exec-backed instances move. this route is kind " + k },
diff --git a/internal/host/movesfloor.go b/internal/host/movesfloor.go
index 8203cabbe..11b378d61 100644
--- a/internal/host/movesfloor.go
+++ b/internal/host/movesfloor.go
@@ -200,6 +200,14 @@ func floorRegression(local, remote *moves.Floor) error {
// less than what is published.
var errFloorWrite = errors.New("could not write it on this box")
+// floorWriteError is errFloorWrite with what the write said. Its text is
+// what it always was ("could not write it on this box: <cause>"); armFloor
+// reads cause to say the recorded sentence whole (copy record MV-N92).
+type floorWriteError struct{ cause error }
+
+func (e *floorWriteError) Error() string { return fmt.Sprintf("%v: %v", errFloorWrite, e.cause) }
+func (e *floorWriteError) Is(target error) bool { return target == errFloorWrite }
+
// syncLocalFloor is the rule root's apply and the agent's refresh share:
// read the published floor and make the local files say the same. It
// returns the floor on disk afterwards, nil when none is armed.
@@ -250,14 +258,14 @@ func syncLocalFloor(ctx context.Context, s release.Store, dataDir string) (*move
}
if err := writeFloorFiles(d, *remote); err != nil {
out, _ = readLocalFloor(dataDir)
- return fmt.Errorf("%w: %v", errFloorWrite, err)
+ return &floorWriteError{cause: err}
}
out = remote
return nil
})
if !ran {
out, _ = readLocalFloor(dataDir)
- return out, fmt.Errorf("%w: %v", errFloorWrite, err)
+ return out, &floorWriteError{cause: err}
}
return out, err
}
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go
index 09330955d..144c37edd 100644
--- a/internal/host/systemapply.go
+++ b/internal/host/systemapply.go
@@ -835,8 +835,9 @@ func (a *systemApplier) armFloor(ctx context.Context) error {
if err == nil {
return nil
}
- if errors.Is(err, errFloorWrite) {
- return fmt.Errorf("%w: moves protocol floor: %v", errSystemDeferred, err)
+ var fw *floorWriteError
+ if errors.As(err, &fw) {
+ return fmt.Errorf("%w: %s", errSystemDeferred, fmt.Sprintf("moves protocol floor: could not write it on this box: %v", fw.cause))
}
onDisk := "none"
if f != nil {
@@ -967,7 +968,7 @@ func (a *systemApplier) activationGuard(target, running string, fn func() error)
return err
}
if errors.Is(err, errFloorLockBusy) {
- return activationDeferred{fmt.Errorf("moves protocol floor: %v; this waits for the next run", errFloorLockBusy)}
+ return activationDeferred{errors.New("moves protocol floor: another writer holds the floor lock on this box; this waits for the next run")}
}
if floorLockCannotExist(err) && !a.floorArmed() {
a.ops.logf("system apply: moves protocol floor lock: %v (no floor is armed, and none can be on this box; continuing)", err)
@@ -1616,9 +1617,10 @@ func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (boo
// before any early refusal. The caller keeps the journal.
if !renamed {
if rerr := a.rearm(prior); rerr != nil {
- err = fmt.Errorf("%w; the root passes paused for the restore could not be started again: %v", err, rerr)
if nerr := a.notePendingRearm(prior); nerr != nil {
- err = fmt.Errorf("%w, and the retry could not be recorded: %v", err, nerr)
+ err = fmt.Errorf("%w; the root passes paused for the restore could not be started again: %v, and the retry could not be recorded: %v", err, rerr, nerr)
+ } else {
+ err = fmt.Errorf("%w; the root passes paused for the restore could not be started again: %v", err, rerr)
}
}
}
diff --git a/internal/moves/archive/archive.go b/internal/moves/archive/archive.go
index 08fa24ece..cecd1306b 100644
--- a/internal/moves/archive/archive.go
+++ b/internal/moves/archive/archive.go
@@ -48,7 +48,7 @@ var (
// (errors.Is sees it), Path the file as Build names it (relative to the
// files directory, or the env file's base name). It lets a caller say
// which file in its own words without reading the error's text; Error() is
-// the text the sentinel's rows record (MV-N77, MV-N81, MV-N86).
+// the text the sentinel's rows record (MV-N77, MV-N80, MV-N81, MV-N86).
type PathError struct {
Err error
Path string
@@ -63,8 +63,28 @@ func (e *PathError) Error() string {
}
func (e *PathError) Unwrap() error { return e.Err }
-func pathError(sentinel error, path, suffix string) error {
- return &PathError{Err: sentinel, Path: path, msg: sentinel.Error() + ": " + path + suffix}
+// The refusals that name a file, each one whole recorded sentence (copy
+// record MV-N77, MV-N80, MV-N81 and MV-N86) so the copy gate can read it.
+// Err is the sentinel errors.Is sees; the text is the sentinel's, a colon
+// and the path, as the rows say.
+func specialFileError(path string) error {
+ return &PathError{Err: ErrSpecialFile, Path: path, msg: fmt.Sprintf("archive: only regular files and directories move: %s", path)}
+}
+
+func hardLinkError(path string) error {
+ return &PathError{Err: ErrSpecialFile, Path: path, msg: fmt.Sprintf("archive: only regular files and directories move: %s is a hard link", path)}
+}
+
+func changedError(path string) error {
+ return &PathError{Err: ErrChanged, Path: path, msg: fmt.Sprintf("archive: a file changed while it was being archived: %s", path)}
+}
+
+// The row says 4096, so the sentence does; this fails to compile if
+// MaxPathLen is ever changed without the row.
+const _ = uint(MaxPathLen-4096) + uint(4096-MaxPathLen)
+
+func longPathError(path string) error {
+ return &PathError{Err: ErrBadPath, Path: path, msg: fmt.Sprintf("archive: refused path: %s is longer than 4096 bytes", path)}
}
// The limits as enforced. Variables only so tests can lower them; nothing
@@ -142,10 +162,10 @@ func walk(root *os.Root, envPath string) ([]item, error) {
return err
}
if !info.Mode().IsRegular() && !info.IsDir() {
- return pathError(ErrSpecialFile, p, "")
+ return specialFileError(p)
}
if len(p) > MaxPathLen {
- return pathError(ErrBadPath, p, fmt.Sprintf(" is longer than %d bytes", MaxPathLen))
+ return longPathError(p)
}
return add(item{name: FilesPrefix + p, rel: p, root: root, info: info})
})
@@ -160,7 +180,7 @@ func walk(root *os.Root, envPath string) ([]item, error) {
case err != nil:
return nil, err
case !info.Mode().IsRegular():
- return nil, pathError(ErrSpecialFile, filepath.Base(envPath), "")
+ return nil, specialFileError(filepath.Base(envPath))
default:
if err := add(item{name: EnvEntry, path: envPath, info: info}); err != nil {
return nil, err
@@ -199,7 +219,7 @@ func openItemChecked(it item) (io.ReadCloser, error) {
}
if !got.Mode().IsRegular() || !os.SameFile(it.info, got) {
f.Close()
- return nil, fmt.Errorf("%w: %s", ErrChanged, displayName(it))
+ return nil, changedError(displayName(it))
}
// Phase 1 does not carry hard links. os.Root constrains names, not
// inodes: a pre-existing link to a file outside the instance directory
@@ -207,7 +227,7 @@ func openItemChecked(it item) (io.ReadCloser, error) {
// carry the outside inode to the target. Counted on the opened file.
if linkCount(got) > 1 {
f.Close()
- return nil, pathError(ErrSpecialFile, displayName(it), " is a hard link")
+ return nil, hardLinkError(displayName(it))
}
return f, nil
}
@@ -262,7 +282,7 @@ func openFilesRoot(filesDir string) (*os.Root, error) {
return nil, err
}
if !info.IsDir() {
- return nil, fmt.Errorf("%w: %s", ErrSpecialFile, filesDir)
+ return nil, specialFileError(filesDir)
}
root, err := os.OpenRoot(filesDir)
if err != nil {
@@ -275,7 +295,7 @@ func openFilesRoot(filesDir string) (*os.Root, error) {
}
if !os.SameFile(info, got) {
root.Close()
- return nil, fmt.Errorf("%w: %s", ErrChanged, filesDir)
+ return nil, changedError(filesDir)
}
return root, nil
}
@@ -345,7 +365,7 @@ func copyItem(tw io.Writer, it item, size int64) (Entry, error) {
fh := sha256.New()
n, err := io.CopyN(io.MultiWriter(tw, fh), rc, size)
if errors.Is(err, io.EOF) {
- return Entry{}, fmt.Errorf("%w: %s", ErrChanged, displayName(it))
+ return Entry{}, changedError(displayName(it))
}
if err != nil {
return Entry{}, err
@@ -353,7 +373,7 @@ func copyItem(tw io.Writer, it item, size int64) (Entry, error) {
var one [1]byte
m, err := io.ReadFull(rc, one[:])
if m > 0 {
- return Entry{}, fmt.Errorf("%w: %s", ErrChanged, displayName(it))
+ return Entry{}, changedError(displayName(it))
}
if err != io.EOF {
return Entry{}, err
diff --git a/internal/moves/readiness.go b/internal/moves/readiness.go
index 4dfa55cef..937f49c4b 100644
--- a/internal/moves/readiness.go
+++ b/internal/moves/readiness.go
@@ -164,11 +164,10 @@ func (r TargetReport) Passing() (bool, string) {
return false, id + " was not reported"
}
if !c.OK {
- reason := c.Reason
- if reason == "" {
- reason = "did not pass"
+ if c.Reason == "" {
+ return false, id + ": did not pass"
}
- return false, id + ": " + reason
+ return false, id + ": " + c.Reason
}
}
return true, ""
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index cbc74a44f..03e3fcee6 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -1148,6 +1148,19 @@ const (
// re-reads.
var ErrMoveConflict = errors.New("provision: move state conflict")
+// The move refusals that name no value (copy record MV-N12, MV-N14, MV-N15,
+// MV-N40, MV-N41 and, for the legacy abort, MV-N45). Each is a whole recorded
+// sentence, so the copy gate can read it; the ones that name a value (MV-N13,
+// MV-N42, MV-N43, MV-N44) are written in place as one format.
+const (
+ refusedNoMove = "no move in flight"
+ refusedNoJournaledMove = "no journaled move in flight"
+ refusedMalformedStart = "malformed move start"
+ refusedPastNoReturn = "past the point of no return. the cut completes forward or moves back on its own"
+ refusedCutNotReady = "cut is only legal from ready"
+ refusedAlreadyAborting = "already aborting"
+)
+
// SetRecordMove applies one guarded move transition: the same narrow
// read-CAS-write with one retry that SetRecordRegion uses (and for the same
// reason — re-reading and re-applying a transition on a lost race changes
@@ -1206,16 +1219,16 @@ type MoveStart struct {
func MoveStartTransition(p MoveStart) func(*Record) error {
return func(rec *Record) error {
if rec.Move != nil {
- return fmt.Errorf("%w: a move to %s is already %s", ErrMoveConflict, rec.Move.To, rec.Move.State)
+ return fmt.Errorf("%w: %s", ErrMoveConflict, fmt.Sprintf("a move to %s is already %s", rec.Move.To, rec.Move.State))
}
if !moveIDShape.MatchString(p.ID) || len(p.Incarnation) != 64 || (p.Cut != "auto" && p.Cut != "manual") || p.Now.IsZero() {
- return fmt.Errorf("%w: malformed move start", ErrMoveConflict)
+ return fmt.Errorf("%w: %s", ErrMoveConflict, refusedMalformedStart)
}
if rec.Region == p.To || p.From != rec.Region {
return fmt.Errorf("%w: the instance is already in %s", ErrMoveConflict, rec.Region)
}
if rec.Role != "" {
- return fmt.Errorf("%w: a %s does not move. delete it and create one in the new region", ErrMoveConflict, rec.Role)
+ return fmt.Errorf("%w: %s", ErrMoveConflict, fmt.Sprintf("a %s does not move. delete it and create one in the new region", rec.Role))
}
rec.Move = &MoveState{ID: p.ID, Incarnation: p.Incarnation, From: p.From, To: p.To, Cut: p.Cut, State: MovePreparing, RequestedBy: p.By, RequestedAt: p.Now.UTC()}
return nil
@@ -1247,10 +1260,10 @@ type MoveGuard struct{ ID, Incarnation string }
func (g MoveGuard) check(rec *Record) error {
if rec.Move == nil || rec.Move.Legacy() {
- return fmt.Errorf("%w: no journaled move in flight", ErrMoveConflict)
+ return fmt.Errorf("%w: %s", ErrMoveConflict, refusedNoJournaledMove)
}
if rec.Move.ID != g.ID || rec.Move.Incarnation != g.Incarnation {
- return fmt.Errorf("%w: the record's move is %s for incarnation %s", ErrMoveConflict, rec.Move.ID, rec.Move.Incarnation)
+ return fmt.Errorf("%w: %s", ErrMoveConflict, fmt.Sprintf("the record's move is %s for incarnation %s", rec.Move.ID, rec.Move.Incarnation))
}
return nil
}
@@ -1279,7 +1292,7 @@ func MoveGuardedCut(g MoveGuard) func(*Record) error {
return err
}
if rec.Move.State != MoveReady {
- return fmt.Errorf("%w: cut is only legal from ready", ErrMoveConflict)
+ return fmt.Errorf("%w: %s", ErrMoveConflict, refusedCutNotReady)
}
// Spec §7.4 pins at readiness: a ready move with no pinned inputs
// (after a back-to-ready, until the repin) has nothing for the
@@ -1332,12 +1345,12 @@ func MoveGuardedAbort(g MoveGuard) func(*Record) error {
case MovePreparing, MoveReady:
case MoveCutting:
if rec.Move.CutPhase != CutRequested {
- return fmt.Errorf("%w: past the point of no return. the cut completes forward or moves back on its own", ErrMoveConflict)
+ return fmt.Errorf("%w: %s", ErrMoveConflict, refusedPastNoReturn)
}
case MoveAborting:
- return fmt.Errorf("%w: already aborting", ErrMoveConflict)
+ return fmt.Errorf("%w: %s", ErrMoveConflict, refusedAlreadyAborting)
default:
- return fmt.Errorf("%w: a move in state %q cannot be aborted", ErrMoveConflict, rec.Move.State)
+ return fmt.Errorf("%w: %s", ErrMoveConflict, fmt.Sprintf("a move in state \"%s\" cannot be aborted", rec.Move.State))
}
rec.Move.State = MoveAborting
return nil
@@ -2383,7 +2396,7 @@ func MoveMarkCutting() func(*Record) error {
func MoveAbort() func(*Record) error {
return func(rec *Record) error {
if rec.Move == nil {
- return fmt.Errorf("%w: no move in flight", ErrMoveConflict)
+ return fmt.Errorf("%w: %s", ErrMoveConflict, refusedNoMove)
}
switch rec.Move.State {
case MovePreparing, MoveReady: