carlos / platform Public

Merge remote-tracking branch 'origin/main' into feat/hostclaim-activation

Paul Campbell pushed by claude agent, operated by paul@keymail.dev c48c7b9042ca39dbbcb51f765b285e91cf10c0b7 parent ef3c4bfparent 17d5960
8 files changed, +324 −235
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +223 −200
  • internal/console/apiinstancemove.go +3 −1
  • internal/host/movesfloor.go +10 −2
  • internal/host/systemapply.go +7 −5
  • internal/moves/archive/archive.go +33 −10
  • internal/moves/readiness.go +3 −4
  • internal/provision/provision.go +31 −10
  • internal/stopevidence/fdholders_alias_linux_test.go +14 −3

CI passed — run details

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 75f1ada2f..b56d80ec7 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -26,187 +26,187 @@ 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-C17 | internal/console/apiinstancemove.go | <host> uses private storage. Instances with private storage cannot move yet. | 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 |
| M-C20 | internal/host/movespaging.go | <box> is running a process for <host> that this move cannot account for. An operator has been paged. | pending |
-| M-C21 | internal/console/apiinstancemove.go | <region> has more than one box. Moves between regions with several boxes are not supported yet. | pending |
+| M-C21 | internal/console/apiinstancemove.go | <region> has more than one box. Moves between regions with several boxes are not supported yet. | approved |
| M-C22 | move.go | <host> is waiting for a box in <region>. Its last box was terminated before the move finished. Its data up to the last completed upload is kept in <region>. It will start when a box joins the region, and any writes after that upload will be reported then. | pending |
## Plan 1
| id | file | text | status |
|---|---|---|---|
-| MV-N1 | internal/console/apiinstancemove.go | a home with replicas does not move. remove its replicas first | pending |
-| MV-N2 | internal/console/apiinstancemove.go | region <to> is not one of this app's regions: <list> | pending |
-| MV-N3 | internal/console/apiinstancemove.go | the instance has no host claim yet. it is adopted on its next reconcile pass | pending |
-| MV-N4 | internal/console/apiinstancemove.go | the instance is under a <kind> operation. wait for it to finish | pending |
-| MV-N5 | internal/console/apiinstancemove.go | a box in <from> or <to> is below the moves protocol floor. roll the platform first | pending |
-| MV-N6 | internal/console/apiinstancemove.go | cut must be "auto" or "manual" | pending |
-| MV-N7 | internal/console/apiinstancemove.go | this move was started by an older console and has no id. abort it and request it again | pending |
-| MV-N8 | internal/console/apiinstancemove.go | the fleet's edges files could not all be read. try again | pending |
-| MV-N9 | internal/host/movesreport.go | the staging lane holds another move's move.json | pending |
-| MV-N10 | internal/host/systemapply.go | moves protocol floor: <version> is below <min_release> | pending |
+| MV-N1 | internal/console/apiinstancemove.go | a home with replicas does not move. remove its replicas first | approved |
+| MV-N2 | internal/console/apiinstancemove.go | region <to> is not one of this app's regions: <list> | approved |
+| MV-N3 | internal/console/apiinstancemove.go | the instance has no host claim yet. it is adopted on its next reconcile pass | approved |
+| MV-N4 | internal/console/apiinstancemove.go | the instance is under a <kind> operation. wait for it to finish | approved |
+| MV-N5 | internal/console/apiinstancemove.go | a box in <from> or <to> is below the moves protocol floor. roll the platform first | approved |
+| MV-N6 | internal/console/apiinstancemove.go | cut must be "auto" or "manual" | approved |
+| MV-N7 | internal/console/apiinstancemove.go | this move was started by an older console and has no id. abort it and request it again | approved |
+| MV-N8 | internal/console/apiinstancemove.go | the fleet's edges files could not all be read. try again | approved |
+| 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-N16 | internal/console/apiinstancemove.go | the instance is already in <region> | pending |
-| MV-N17 | internal/console/apiinstancemove.go | could not read the app's instances manifest. try again | pending |
-| MV-N18 | internal/console/apiinstancemove.go | could not read this instance's storage binding. try again | pending |
-| MV-N19 | internal/console/apiinstancemove.go | this deployment publishes no armed regions. nothing to move to | pending |
-| MV-N20 | internal/console/apiinstancemove.go | region <to> cannot take a move in this deployment. it publishes no staging bucket. Regions that can: <list> | pending |
-| MV-N21 | internal/console/apiinstancemove.go | moves to the home region are not supported yet. the home publishes no staging lane | pending |
-| MV-N22 | internal/console/apiinstancemove.go | could not read the moves protocol floor. try again | pending |
-| MV-N23 | internal/console/apiinstancemove.go | no box in <to> publishes an edges file yet | pending |
-| MV-N24 | internal/console/apiinstancemove.go | no eligible box in <from> publishes an edges file | pending |
-| MV-N25 | internal/console/apiinstancemove.go | the record's identity is malformed | pending |
-| MV-N26 | internal/console/apiinstancemove.go | could not read the host claim | pending |
-| MV-N27 | internal/console/apiinstancemove.go | the host claim names another incarnation of this instance | pending |
-| MV-N28 | internal/console/apiinstancemove.go | the source box <box> is not the eligible box of <from> | pending |
-| MV-N29 | internal/console/apiinstancemove.go | could not mint a move id | pending |
-| MV-N30 | internal/console/apiinstancemove.go | the instance has unfinished cleanup in <to>. wait for it to finish | pending |
-| MV-N31 | internal/console/apiinstancemove.go | the host claim changed under this request. try again | pending |
-| MV-N32 | internal/console/apiinstancemove.go | could not write the host claim | pending |
-| MV-N33 | internal/console/apiinstancemove.go | could not record the move. try again | pending |
-| MV-N34 | internal/console/apiinstancemove.go | could not write the move. try again | pending |
-| MV-N35 | internal/console/apiinstancemove.go | nothing to resolve yet | pending |
-| MV-N36 | internal/console/apiinstancemove.go | could not confirm whether the move was recorded. read its state before trying again | pending |
-| MV-N37 | internal/console/apiinstancemove.go | this console is below the moves protocol floor. roll the console first | pending |
-| MV-N38 | internal/console/apiinstancemove.go | the move changed since this request read it. read its state and try again | pending |
-| MV-N39 | internal/console/apiinstancemove.go | past the point of no return. the cut completes forward | pending |
-| 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-N46 | internal/console/apiinstancemove.go | this move's state does not allow that. read its state and try again | pending |
-| MV-N47 | internal/console/apiinstancemove.go | only exec-backed instances move. this route is kind <kind> | pending |
-| MV-N48 | internal/console/apiinstancemove.go | an addr-backed route's process is not platform-managed. there is nothing to move | pending |
-| MV-N49 | internal/console/apiinstancemove.go | fleet-placed instances keep state-does-not-follow semantics. moves are for platform regions | pending |
-| MV-N50 | internal/console/apiinstancemove.go | pooled instances do not move yet. their data lives on the pool's own box | pending |
-| MV-N51 | internal/console/apiinstancemove.go | a non-hibernating instance runs outside the activator. nothing manages its data, so nothing can move it | pending |
-| MV-N52 | internal/console/apiinstancemove.go | too many instance requests. wait a minute and try again | pending |
-| MV-N53 | internal/console/apiinstancemove.go | this console has no deployment bucket, so it cannot answer this | 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 |
+| MV-N19 | internal/console/apiinstancemove.go | this deployment publishes no armed regions. nothing to move to | approved |
+| MV-N20 | internal/console/apiinstancemove.go | region <to> cannot take a move in this deployment. it publishes no staging bucket. Regions that can: <list> | approved |
+| MV-N21 | internal/console/apiinstancemove.go | moves to the home region are not supported yet. the home publishes no staging lane | approved |
+| MV-N22 | internal/console/apiinstancemove.go | could not read the moves protocol floor. try again | approved |
+| MV-N23 | internal/console/apiinstancemove.go | no box in <to> publishes an edges file yet | approved |
+| MV-N24 | internal/console/apiinstancemove.go | no eligible box in <from> publishes an edges file | approved |
+| MV-N25 | internal/console/apiinstancemove.go | the record's identity is malformed | approved |
+| MV-N26 | internal/console/apiinstancemove.go | could not read the host claim | approved |
+| MV-N27 | internal/console/apiinstancemove.go | the host claim names another incarnation of this instance | approved |
+| MV-N28 | internal/console/apiinstancemove.go | the source box <box> is not the eligible box of <from> | approved |
+| MV-N29 | internal/console/apiinstancemove.go | could not mint a move id | approved |
+| MV-N30 | internal/console/apiinstancemove.go | the instance has unfinished cleanup in <to>. wait for it to finish | approved |
+| MV-N31 | internal/console/apiinstancemove.go | the host claim changed under this request. try again | approved |
+| MV-N32 | internal/console/apiinstancemove.go | could not write the host claim | approved |
+| MV-N33 | internal/console/apiinstancemove.go | could not record the move. try again | approved |
+| MV-N34 | internal/console/apiinstancemove.go | could not write the move. try again | approved |
+| MV-N35 | internal/console/apiinstancemove.go | nothing to resolve yet | approved |
+| MV-N36 | internal/console/apiinstancemove.go | could not confirm whether the move was recorded. read its state before trying again | approved |
+| 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 | 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 | 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> | pending |
-| MV-N56 | internal/moves/readiness.go | the target has not published a readiness report yet | pending |
-| MV-N57 | internal/moves/readiness.go | the target's readiness report is stale | pending |
-| MV-N58 | internal/moves/readiness.go | <check> was not reported | pending |
-| MV-N59 | internal/moves/readiness.go | <check>: did not pass | pending |
-| MV-N60 | internal/moves/readiness.go | T11: the cut budget admits no probe attempt | pending |
-| 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 | pending |
-| MV-N62 | internal/moves/readiness.go | the source has not published a readiness report yet | pending |
-| MV-N63 | internal/moves/readiness.go | the source's readiness report is stale | pending |
-| MV-N64 | internal/moves/readiness.go | S1: the source did not answer | pending |
-| MV-N65 | internal/moves/readiness.go | S2: the source could not confirm its spawn state | pending |
-| MV-N66 | internal/moves/readiness.go | the target checked files <digest> and the source now publishes <digest> | pending |
-| MV-N67 | internal/moves/readiness.go | the target checked launch environment <digest> and the source now reports <digest> | pending |
-| MV-N68 | internal/moves/readiness.go | no fresh rate measurement for <which> | pending |
-| MV-N69 | internal/moves/readiness.go | the database size could not be measured | pending |
-| MV-N70 | internal/moves/readiness.go | the cut window is not a usable number of seconds | pending |
-| MV-N71 | internal/moves/readiness.go | the cut needs about <seconds> seconds with one probe attempt and the window is <seconds> seconds | pending |
-| MV-N72 | internal/host/movesreport.go | nested instances cannot move yet | pending |
-| MV-N73 | internal/host/movesreport.go | the source has published no archive yet | pending |
-| MV-N74 | internal/host/movesreport.go | the host claim has not touched <region> yet | pending |
-| MV-N75 | internal/host/movesreport.go | waiting for the root stage pass | pending |
-| MV-N76 | internal/host/movesreport.go | the source has not reported its release yet | pending |
-| MV-N77 | internal/moves/archive/archive.go | archive: only regular files and directories move: <path> | pending |
-| MV-N78 | internal/moves/archive/archive.go | archive: the files directory is over 256 MiB | pending |
-| 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-N82 | internal/moves/readiness.go | T11: the target's report carries a rate that is not a usable number | pending |
-| MV-N83 | internal/moves/readiness.go | T11: the target's report claims <n> probe attempts and its measurements admit <n> | pending |
-| MV-N84 | internal/moves/readiness.go | the target's readiness report is dated ahead of this clock | pending |
-| MV-N85 | internal/moves/readiness.go | the source's readiness report is dated ahead of this clock | pending |
-| MV-N86 | internal/moves/archive/archive.go | archive: only regular files and directories move: <path> is a hard link | pending |
-| MV-N87 | internal/host/systemapply.go | moves protocol floor: carlos.prev records no version, so it is below <min_release> | pending |
-| MV-N88 | internal/host/systemapply.go | moves protocol floor: the floor on this box cannot be read: <error> | pending |
-| MV-N89 | internal/host/systemapply.go | a participant in a held operation does not downgrade: <version> is below the running <release> | pending |
-| 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> | pending |
-| MV-N91 | internal/host/systemapply.go | a participant in a held operation does not downgrade, and the running release is unknown | pending |
-| MV-N92 | internal/host/systemapply.go | moves protocol floor: could not write it on this box: <error> | pending |
-| 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 | pending |
-| 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 | pending |
-| MV-N95 | internal/host/systemapply.go | <refusal>; the root passes paused for the restore could not be started again: <error> | pending |
-| MV-N96 | internal/activator/backend_exec.go | activator: a host in a move spawns only under strict containment | pending |
-| MV-N97 | internal/activator/backend_exec.go | S2: the next spawn of this host is not strict | pending |
-| MV-N98 | internal/activator/backend_exec.go | S2: this box has no delegated cgroup subtree, so it cannot spawn strictly | pending |
-| MV-N99 | internal/activator/backend_exec.go | S2: this box cannot spawn strictly | pending |
-| MV-N100 | internal/release/materializeheld.go | release: this process cannot write the version store, so the root stage pass stages this release | pending |
-| MV-N101 | internal/release/materializeheld.go | release: held stage request no longer matches its move | pending |
-| MV-N102 | internal/release/materializeheld.go | release: held rows are not staged while CARLOS_HOST_CLAIM is on | pending |
-| MV-N103 | internal/appconfig/renderheld.go | config: not a held row of this move | pending |
-| MV-N104 | internal/appconfig/renderheld.go | config: held rows are not rendered while CARLOS_HOST_CLAIM is on | 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 | 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-N107 | internal/host/systemapply.go | <error>; the journal stays: <error> | pending |
-| MV-N108 | internal/activator/backend_exec.go | S2: the agent is not placed in its delegated cgroup subtree, so it cannot spawn strictly | pending |
-| MV-N109 | internal/activator/backend_exec.go | S2: this host's last strict group has not been cleaned up yet | pending |
-| MV-N110 | internal/release/materializeheld.go | release: a held publication needs its authority | pending |
-| MV-N111 | internal/activator/backend_exec.go | activator: a strict child of this host is still running in its group | pending |
-| MV-N112 | internal/activator/backend_exec.go | activator: this host's last strict group is still being cleaned up | pending |
-| MV-N113 | internal/moves/readiness.go | the archive's file count could not be measured | pending |
-| MV-N114 | internal/host/movesphase1.go | this deployment has no deployment bucket for the staging lease | pending |
-| MV-N115 | internal/host/movesphase1.go | this box has no secrets key, so it cannot report the launch environment or seal the archive | pending |
-| MV-N116 | internal/host/movesphase1.go | this box cannot probe the instance yet | pending |
-| MV-N117 | internal/host/movesphase1.go | <host> is not an exec-backed instance, so it cannot move | pending |
-| MV-N118 | internal/host/movesphase1.go | <host> does not hibernate, so it cannot move | pending |
-| MV-N119 | internal/host/movesphase1.go | <host> has a sidecar environment file, so it cannot move | pending |
-| MV-N120 | internal/host/movesphase1.go | <host> has <column> set in the registry, so it cannot move | pending |
-| MV-N121 | internal/host/movesreport.go | the moves protocol floor could not be read | pending |
-| MV-N122 | internal/host/movesreport.go | this box cannot read its own copy of the moves protocol floor | pending |
-| MV-N123 | internal/host/movesreport.go | this box has not armed the moves protocol floor yet | pending |
-| MV-N124 | internal/host/movesreport.go | this box speaks moves protocol <n>, below the floor's <n> | pending |
-| MV-N125 | internal/host/movesreport.go | the console has not published its secrets key fingerprint yet | pending |
-| MV-N126 | internal/host/movesreport.go | this box has no secrets key | pending |
-| MV-N127 | internal/host/movesreport.go | this box's secrets key is not the console's | pending |
-| MV-N128 | internal/host/movesreport.go | this box could not compose the launch environment of the held row | 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 | 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 |
+| MV-N63 | internal/moves/readiness.go | the source's readiness report is stale | approved |
+| MV-N64 | internal/moves/readiness.go | S1: the source did not answer | approved |
+| MV-N65 | internal/moves/readiness.go | S2: the source could not confirm its spawn state | approved |
+| MV-N66 | internal/moves/readiness.go | the target checked files <digest> and the source now publishes <digest> | approved |
+| MV-N67 | internal/moves/readiness.go | the target checked launch environment <digest> and the source now reports <digest> | approved |
+| MV-N68 | internal/moves/readiness.go | no fresh rate measurement for <which> | approved |
+| 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/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> | approved |
+| MV-N78 | internal/moves/archive/archive.go | archive: the files directory is over 256 MiB | approved |
+| 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 | 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> | 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 |
+| MV-N96 | internal/activator/backend_exec.go | activator: a host in a move spawns only under strict containment | approved |
+| MV-N97 | internal/activator/backend_exec.go | S2: the next spawn of this host is not strict | approved |
+| MV-N98 | internal/activator/backend_exec.go | S2: this box has no delegated cgroup subtree, so it cannot spawn strictly | approved |
+| MV-N99 | internal/activator/backend_exec.go | S2: this box cannot spawn strictly | approved |
+| MV-N100 | internal/release/materializeheld.go | release: this process cannot write the version store, so the root stage pass stages this release | approved |
+| MV-N101 | internal/release/materializeheld.go | release: held stage request no longer matches its move | approved |
+| 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 | 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 |
+| MV-N110 | internal/release/materializeheld.go | release: a held publication needs its authority | approved |
+| MV-N111 | internal/activator/backend_exec.go | activator: a strict child of this host is still running in its group | approved |
+| MV-N112 | internal/activator/backend_exec.go | activator: this host's last strict group is still being cleaned up | approved |
+| MV-N113 | internal/moves/readiness.go | the archive's file count could not be measured | approved |
+| MV-N114 | internal/host/movesphase1.go | this deployment has no deployment bucket for the staging lease | approved |
+| MV-N115 | internal/host/movesphase1.go | this box has no secrets key, so it cannot report the launch environment or seal the archive | approved |
+| MV-N116 | internal/host/movesphase1.go | this box cannot probe the instance yet | approved |
+| MV-N117 | internal/host/movesphase1.go | <host> is not an exec-backed instance, so it cannot move | approved |
+| MV-N118 | internal/host/movesphase1.go | <host> does not hibernate, so it cannot move | approved |
+| MV-N119 | internal/host/movesphase1.go | <host> has a sidecar environment file, so it cannot move | approved |
+| MV-N120 | internal/host/movesphase1.go | <host> has <column> set in the registry, so it cannot move | approved |
+| MV-N121 | internal/host/movesreport.go | the moves protocol floor could not be read | approved |
+| MV-N122 | internal/host/movesreport.go | this box cannot read its own copy of the moves protocol floor | approved |
+| MV-N123 | internal/host/movesreport.go | this box has not armed the moves protocol floor yet | approved |
+| MV-N124 | internal/host/movesreport.go | this box speaks moves protocol <n>, below the floor's <n> | approved |
+| MV-N125 | internal/host/movesreport.go | the console has not published its secrets key fingerprint yet | approved |
+| 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-N130 | internal/host/movesreport.go (a T3 note, not a failure) | <NAME> in the carried .env overrides the rendered configuration | pending |
-| MV-N131 | internal/host/movesreport.go (a T3 note for an advisory variable) | <NAME> (<kind>) | pending |
-| MV-N132 | internal/host/movesreport.go | the store bound as <PREFIX> did not answer the probe from this box | pending |
-| MV-N133 | internal/host/movesreport.go | this box could not read the archive the source published | pending |
-| MV-N134 | internal/host/movesreport.go | the archive does not open with this box's key or does not match its manifest | pending |
-| MV-N135 | internal/host/movesreport.go | this box could not measure its free disk space | pending |
-| MV-N136 | internal/host/movesreport.go | this box needs <GiB> GiB free for the move without filling its disk past 85% | pending |
-| MV-N137 | internal/host/movesreport.go | this box could not stage the source's release | pending |
-| MV-N138 | internal/host/movesreport.go | no box publishes <region> | pending |
-| MV-N139 | internal/host/movesreport.go | <n> boxes publish <region>, and a move needs exactly one | pending |
-| MV-N140 | internal/host/movesreport.go | the host claim is not held by this move | pending |
-| MV-N141 | internal/host/movesreport.go | the staging lane has no move.json yet | pending |
-| MV-N142 | internal/host/movesreport.go | the source region publishes no data bucket | pending |
-| MV-N143 | internal/host/movesreport.go | this box cannot write the return lane in the source's bucket | pending |
-| MV-N144 | internal/host/movesreport.go | no replica has been staged yet | pending |
-| MV-N145 | internal/host/movesreport.go | the instance left <region> recently and can move back after <RFC 3339 time> | pending |
+| MV-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 |
+| MV-N133 | internal/host/movesreport.go | this box could not read the archive the source published | approved |
+| MV-N134 | internal/host/movesreport.go | the archive does not open with this box's key or does not match its manifest | approved |
+| MV-N135 | internal/host/movesreport.go | this box could not measure its free disk space | approved |
+| MV-N136 | internal/host/movesreport.go | this box needs <GiB> GiB free for the move without filling its disk past 85% | approved |
+| MV-N137 | internal/host/movesreport.go | this box could not stage the source's release | approved |
+| MV-N138 | internal/host/movesreport.go | no box publishes <region> | approved |
+| MV-N139 | internal/host/movesreport.go | <n> boxes publish <region>, and a move needs exactly one | approved |
+| MV-N140 | internal/host/movesreport.go | the host claim is not held by this move | approved |
+| MV-N141 | internal/host/movesreport.go | the staging lane has no move.json yet | approved |
+| MV-N142 | internal/host/movesreport.go | the source region publishes no data bucket | approved |
+| 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-N147 | internal/console/apiinstancemove.go | invalid region | pending |
-| MV-N148 | internal/host/movesphase1.go | the app's readiness path could not be read | pending |
-| MV-N149 | internal/moves/readiness.go | the target has not checked a release yet | pending |
-| MV-N150 | internal/moves/readiness.go | the target checked release <release> and the source now runs <release> | pending |
-| MV-N151 | internal/host/movesphase1.go | the files beside the database could not be archived | pending |
-| MV-N152 | internal/moves/readiness.go | the cut budget could not be priced | pending |
-| MV-N153 | internal/host/movesreport.go | this box requires a proof for the source's release and has not accepted one | pending |
-| MV-N154 | internal/host/movesreport.go | the source staged its archive without a fixed version id | pending |
-| MV-N155 | internal/moves/readiness.go | T11: the database has grown to <MiB> MiB since the target priced <MiB> MiB. waiting for the target's next report | pending |
-| MV-N156 | internal/console/apiinstancemove.go | the source box is not eligible to move from | pending |
-| MV-N157 | internal/console/apiinstancemove.go | could not read the host roster. try again | pending |
-| MV-N158 | internal/provision/provision.go | stop evidence budget | pending |
-| MV-N159 | internal/stopevidence/reconstruct_linux.go | the instance's processes could not be stopped | pending |
-| MV-N160 | internal/stopevidence/reconstruct_linux.go | pid <pid> in the agent's cgroup is not the agent | pending |
-| MV-N161 | internal/stopevidence/reconstruct_linux.go | <count> process(es) hold the database open | pending |
-| MV-N162 | internal/stopevidence/reconstruct_linux.go | lock.json still held | pending |
-| MV-N163 | internal/provision/provision.go | the cut ran past its deadline | pending |
-| MV-N164 | internal/provision/provision.go | the final copy kept finding changes at the source | pending |
-| MV-N165 | internal/provision/provision.go | the stopped files could not be archived | pending |
-| MV-N166 | internal/host/movesphase1.go | <region> has more than one box. Moves between regions with several boxes are not supported yet. | pending |
-| MV-N167 | internal/provision/provision.go | the instance keeps its data under a per-incarnation path, which moves cannot cut yet | pending |
+| 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 |
+| MV-N150 | internal/moves/readiness.go | the target checked release <release> and the source now runs <release> | approved |
+| MV-N151 | internal/host/movesphase1.go | the files beside the database could not be archived | approved |
+| MV-N152 | internal/moves/readiness.go | the cut budget could not be priced | approved |
+| MV-N153 | internal/host/movesreport.go | this box requires a proof for the source's release and has not accepted one | approved |
+| MV-N154 | internal/host/movesreport.go | the source staged its archive without a fixed version id | approved |
+| MV-N155 | internal/moves/readiness.go | T11: the database has grown to <MiB> MiB since the target priced <MiB> MiB. waiting for the target's next report | approved |
+| MV-N156 | internal/console/apiinstancemove.go | the source box is not eligible to move from | approved |
+| MV-N157 | internal/console/apiinstancemove.go | could not read the host roster. try again | approved |
+| MV-N158 | internal/provision/provision.go | stop evidence budget | approved |
+| MV-N159 | internal/stopevidence/reconstruct_linux.go | the instance's processes could not be stopped | approved |
+| MV-N160 | internal/stopevidence/reconstruct_linux.go | pid <pid> in the agent's cgroup is not the agent | approved |
+| MV-N161 | internal/stopevidence/reconstruct_linux.go | <count> process(es) hold the database open | approved |
+| MV-N162 | internal/stopevidence/reconstruct_linux.go | lock.json still held | approved |
+| MV-N163 | internal/provision/provision.go | the cut ran past its deadline | approved |
+| MV-N164 | internal/provision/provision.go | the final copy kept finding changes at the source | approved |
+| MV-N165 | internal/provision/provision.go | the stopped files could not be archived | approved |
+| MV-N166 | internal/host/movesphase1.go | <region> has more than one box. Moves between regions with several boxes are not supported yet. | approved |
+| MV-N167 | internal/provision/provision.go | the instance keeps its data under a per-incarnation path, which moves cannot cut yet | approved |
## Plan 2
@@ -214,20 +214,20 @@ Columns: id, file, text, status.
| id | file | text | status |
|---|---|---|---|
-| MV-N168 | internal/provision/provision.go | configuration changed during the move | pending |
-| MV-N169 | internal/provision/provision.go | the landing ran past its deadline | pending |
-| MV-N170 | internal/provision/provision.go | this box cannot land a move while host claims are on | pending |
-| MV-N171 | internal/provision/provision.go | the landing row is not at the flat path | pending |
-| MV-N172 | internal/provision/provision.go | the installed database does not match the build | pending |
-| MV-N173 | internal/console/apiinstancemove.go | this app has no host-claim admission yet. try again shortly | pending |
-| MV-N174 | internal/console/apiinstancemove.go | could not read this app's admission. try again | pending |
-| MV-N175 | internal/console/apiinstancemove.go | another operation holds this app. try again when it finishes | pending |
-| MV-N176 | internal/console/apiinstancemove.go | could not register the move. try again | pending |
-| MV-N177 | internal/console/apiinstancemove.go | the host roster does not allow this move. the source and target must be active boxes in <from> and <to> | pending |
-| MV-N178 | internal/console/apiinstancemove.go | the instance or its boxes changed under this request. read its state and try again | pending |
-| MV-N179 | internal/console/apiinstancemove.go | the move could not be recorded and is held until an operator checks the record | pending |
-| MV-N180 | internal/provision/provision.go | the files directory already holds files this move did not write | pending |
-| MV-N181 | internal/stopevidence/reconstruct_linux.go | <count> process(es) in the agent's cgroup cannot be identified | pending |
+| MV-N168 | internal/provision/provision.go | configuration changed during the move | approved |
+| MV-N169 | internal/provision/provision.go | the landing ran past its deadline | approved |
+| MV-N170 | internal/provision/provision.go | this box cannot land a move while host claims are on | approved |
+| MV-N171 | internal/provision/provision.go | the landing row is not at the flat path | approved |
+| MV-N172 | internal/provision/provision.go | the installed database does not match the build | approved |
+| MV-N173 | internal/console/apiinstancemove.go | this app has no host-claim admission yet. try again shortly | approved |
+| MV-N174 | internal/console/apiinstancemove.go | could not read this app's admission. try again | approved |
+| MV-N175 | internal/console/apiinstancemove.go | another operation holds this app. try again when it finishes | approved |
+| MV-N176 | internal/console/apiinstancemove.go | could not register the move. try again | approved |
+| MV-N177 | internal/console/apiinstancemove.go | the host roster does not allow this move. the source and target must be active boxes in <from> and <to> | approved |
+| MV-N178 | internal/console/apiinstancemove.go | the instance or its boxes changed under this request. read its state and try again | approved |
+| MV-N179 | internal/console/apiinstancemove.go | the move could not be recorded and is held until an operator checks the record | approved |
+| MV-N180 | internal/provision/provision.go | the files directory already holds files this move did not write | approved |
+| MV-N181 | internal/stopevidence/reconstruct_linux.go | <count> process(es) in the agent's cgroup cannot be identified | approved |
## Plan 3
@@ -235,11 +235,11 @@ Columns: id, file, text, status.
| id | file | text | status |
|---|---|---|---|
-| MV-N182 | internal/provision/provision.go | the probe ran out of attempts | pending |
-| MV-N183 | internal/provision/provision.go | the commit came after the deadline | pending |
-| MV-N184 | internal/moves/startcheck.go | this box holds another generation of the instance | pending |
-| MV-N185 | internal/moves/startcheck.go | the instance's files belong to another generation | pending |
-| MV-N186 | internal/moves/startcheck.go | the instance's database belongs to another generation | pending |
+| MV-N182 | internal/provision/provision.go | the probe ran out of attempts | approved |
+| MV-N183 | internal/provision/provision.go | the commit came after the deadline | approved |
+| MV-N184 | internal/moves/startcheck.go | this box holds another generation of the instance | approved |
+| MV-N185 | internal/moves/startcheck.go | the instance's files belong to another generation | approved |
+| MV-N186 | internal/moves/startcheck.go | the instance's database belongs to another generation | approved |
## Plan 4
@@ -247,19 +247,19 @@ Columns: id, file, text, status.
| id | file | text | status |
|---|---|---|---|
-| MV-N300 | internal/provision/provision.go | the target box was declared lost | pending |
-| MV-N301 | internal/provision/provision.go | the source no longer matches what it recorded when it stopped | pending |
-| MV-N302 | internal/provision/provision.go | the recovery probe ran out of attempts | pending |
-| MV-N303 | internal/provision/provision.go | the source did not answer within 15 minutes of moving back | pending |
-| MV-N304 | internal/provision/provision.go | the move has committed. it cannot roll back | pending |
-| MV-N305 | internal/provision/provision.go | the move is not stuck. there is nothing to retry | pending |
-| MV-N306 | internal/provision/provision.go | the target holds nothing the move still needs. there is nothing to declare lost | pending |
-| MV-N307 | internal/provision/provision.go | the files directory holds files the return did not write | pending |
-| MV-N308 | internal/provision/provision.go | the returned database does not match the build | pending |
-| MV-N309 | internal/provision/provision.go | the source holds files of an installation the return cannot account for | pending |
-| MV-N310 | internal/provision/provision.go | the target's stopped database is missing | pending |
-| MV-N311 | internal/provision/provision.go | the target changed after it stopped | pending |
-| MV-N312 | internal/provision/provision.go | the target's files cannot be archived | pending |
+| MV-N300 | internal/provision/provision.go | the target box was declared lost | approved |
+| MV-N301 | internal/provision/provision.go | the source no longer matches what it recorded when it stopped | approved |
+| MV-N302 | internal/provision/provision.go | the recovery probe ran out of attempts | approved |
+| MV-N303 | internal/provision/provision.go | the source did not answer within 15 minutes of moving back | approved |
+| MV-N304 | internal/provision/provision.go | the move has committed. it cannot roll back | approved |
+| MV-N305 | internal/provision/provision.go | the move is not stuck. there is nothing to retry | approved |
+| MV-N306 | internal/provision/provision.go | the target holds nothing the move still needs. there is nothing to declare lost | approved |
+| MV-N307 | internal/provision/provision.go | the files directory holds files the return did not write | approved |
+| MV-N308 | internal/provision/provision.go | the returned database does not match the build | approved |
+| MV-N309 | internal/provision/provision.go | the source holds files of an installation the return cannot account for | approved |
+| MV-N310 | internal/provision/provision.go | the target's stopped database is missing | approved |
+| MV-N311 | internal/provision/provision.go | the target changed after it stopped | approved |
+| MV-N312 | internal/provision/provision.go | the target's files cannot be archived | approved |
## Plan 5
@@ -268,3 +268,26 @@ Columns: id, file, text, status.
## Plan 6
(completed by Plan 6 Task 7)
+
+## Review of 2026-10-09
+
+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 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.
+
+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/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 1a60fdb7b..0ca5d6bc5 100644
--- a/internal/host/systemapply.go
+++ b/internal/host/systemapply.go
@@ -836,8 +836,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 {
@@ -968,7 +969,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)
@@ -1627,9 +1628,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..648743084 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,6 +282,8 @@ func openFilesRoot(filesDir string) (*os.Root, error) {
return nil, err
}
if !info.IsDir() {
+ // The files root itself is not a directory: untyped, so the caller
+ // reports it as the files failing to archive, never as a <path>.
return nil, fmt.Errorf("%w: %s", ErrSpecialFile, filesDir)
}
root, err := os.OpenRoot(filesDir)
@@ -275,6 +297,7 @@ func openFilesRoot(filesDir string) (*os.Root, error) {
}
if !os.SameFile(info, got) {
root.Close()
+ // The root itself, not a file in it: untyped, as above.
return nil, fmt.Errorf("%w: %s", ErrChanged, filesDir)
}
return root, nil
@@ -345,7 +368,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 +376,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 9a7e89e90..cda7ff114 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -1152,6 +1152,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
@@ -1210,16 +1223,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
@@ -1251,10 +1264,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
}
@@ -1283,7 +1296,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
@@ -1336,12 +1349,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", quotedInner(rec.Move.State)))
}
rec.Move.State = MoveAborting
return nil
@@ -2387,7 +2400,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:
@@ -2741,3 +2754,11 @@ func PutManifestIfMatch(ctx context.Context, s release.Store, acct, app string,
})
return
}
+
+// quotedInner is strconv.Quote without the surrounding quotes: the approved
+// sentence (MV-N42) carries its own quotes, and the state inside them keeps
+// %q's escaping of quotes, backslashes and control characters.
+func quotedInner(s string) string {
+ q := strconv.Quote(s)
+ return q[1 : len(q)-1]
+}
diff --git a/internal/stopevidence/fdholders_alias_linux_test.go b/internal/stopevidence/fdholders_alias_linux_test.go
index 6c9971286..b0ee488ce 100644
--- a/internal/stopevidence/fdholders_alias_linux_test.go
+++ b/internal/stopevidence/fdholders_alias_linux_test.go
@@ -132,14 +132,25 @@ func TestFDIdentityFindsAHolderInAnotherMountNamespace(t *testing.T) {
t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
pid := cmd.Process.Pid
fd3 := filepath.Join("/proc", strconv.Itoa(pid), "fd", "3")
+ // Until sh has run the mount and exec'd sleep, fd 3 can be some other
+ // file (the dynamic loader's libc, while unshare or sh starts), so wait
+ // for fd 3 to be the database itself, by identity: stat through the
+ // magic link reaches the file the descriptor holds in any namespace.
+ want, err := os.Stat(db)
+ if err != nil {
+ t.Fatal(err)
+ }
var target string
+ held := false
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
- if target, _ = os.Readlink(fd3); target != "" {
+ if fi, err := os.Stat(fd3); err == nil && os.SameFile(fi, want) {
+ target, _ = os.Readlink(fd3)
+ held = true
break
}
}
- if target == "" {
- t.Skip("the namespace holder never came up (unprivileged user namespaces refused?)")
+ if !held {
+ t.Skip("the namespace holder never came up holding the database (unprivileged user namespaces refused?)")
}
if target != aliased {
t.Logf("descriptor text %q (expected the bind path %q)", target, aliased)