gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

feat(metal): --keep conserva el /home viejo, y los usuarios se crean con SU uid

Decidido hoy: manda el uid de los DATOS. takana crea los usuarios que encuentra en el /home
conservado —todos— con su mismo uid y el nombre del directorio como login, y NO hace chown de nada:
recorrer cientos de miles de ficheros es lento, es destructivo sobre los metadatos y pierde el
reparto si había más de un dueño.

takana metal install --keep PARTUUID=…:/home → takana.keep= en la cmdline → --keep en el script.
En inglés por la regla 4, no --conservar. Sólo /home: conservar /usr o /etc de otro sistema no es
conservar datos, es arrancar un híbrido, y se rechaza nombrando el motivo.

Se comprueba que monta ANTES de tocar la raíz, en sólo lectura; si ya estaba montada se lee de donde
está y NO se desmonta (un umount ajeno se propaga al original, medido en este repo). El plan nombra
a cada usuario con su uid antes de pedir el «si». Y un choque de uid —el 74 ya es de sshd— aborta
antes del mke2fs: la salida es del usuario, no nuestra.

Dos defectos que habrían fallado en SILENCIO, los dos medidos:

· el findfs de busybox NO entiende PARTUUID (1.36.1: «LABEL=label or UUID=uuid»). El initramfs de
  pivote es quien monta, y ahí sólo hay busybox: un findfs PARTUUID= sale VACÍO sin error y la
  máquina arranca con /home vacío diciendo que todo fue bien. Se monta por UUID del filesystem, que
  tampoco cambia porque esa partición no se formatea nunca.

· el árbol del stage son HARDLINKS del rootfs sellado, así que un >> escribe DENTRO DEL STORE.
  Reproducido sobre el artefacto real: echo >> arbol/etc/passwd cambió 662e02d6→e22ddba3 en
  …-product-rootfs/etc/passwd. keep_aplicar copia y hace mv encima, que rompe el enlace y deja el
  inode del store intacto. Y sed -i NO repara ese daño: también rompe el enlace, dejando lo malo en
  el store. Hubo que reescribir el fichero del store (verificado: vuelve a 662e02d6).

Probado con un /home de mentira (dos uid distintos, un dir de root y uno con espacios): cosecha
correcta, rechazos dichos en voz alta, y el store byte a byte igual después.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

dcb83e1fd380c1ad5b1e49f266c1bd163d771d5f · ruwaq Sergio · (26 p'unchaw ñawpaq) · tayta f06d943f9 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 6

crates/takana-cli/src/metal.rs

5 qillqakuna · +362 −4

diff --git a/crates/takana-cli/src/metal.rs b/crates/takana-cli/src/metal.rs
index 72568754..8bb67914 100644
--- a/crates/takana-cli/src/metal.rs
+++ b/crates/takana-cli/src/metal.rs
@@ -103,6 +103,14 @@ pub enum MetalCmd {
         /// No formatear nada: instalar en el espacio SIN ASIGNAR más grande.
         #[arg(long)]
         free: bool,
+        /// CONSERVAR una partición y montarla en el sistema nuevo sin formatearla:
+        /// `--keep PARTUUID=…:/home`. Hoy el único punto admitido es `/home`. Los usuarios que
+        /// haya ahí se crean con **su mismo uid**, para que sus archivos sigan siendo suyos.
+        ///
+        /// ⚠ Viaja por la cmdline del kernel, que separa por ESPACIOS: un `LABEL=` con espacios no
+        /// se puede pasar por acá. Usá `PARTUUID=`, que además es la identidad estable.
+        #[arg(long)]
+        keep: Option<String>,
         /// Disco sobre el que buscar el hueco de `--free`.
         #[arg(long)]
         disk: Option<String>,
@@ -130,9 +138,9 @@ pub fn run(cmd: MetalCmd) -> Result<()> {
         MetalCmd::Check => check(),
         MetalCmd::Survey { json, payload } => survey(json, payload.as_deref()),
         MetalCmd::Install {
-            payload, root, esp, free, disk, yes, unattended, load_only, jump, console,
+            payload, root, esp, free, keep, disk, yes, unattended, load_only, jump, console,
         } => install(Instalacion {
-            payload, root, esp, free, disk, yes, unattended, load_only, jump, console,
+            payload, root, esp, free, keep, disk, yes, unattended, load_only, jump, console,
         }),
     }
 }
@@ -142,6 +150,7 @@ struct Instalacion {
     root: Option<String>,
     esp: Option<String>,
     free: bool,
+    keep: Option<String>,
     disk: Option<String>,
     yes: bool,
     unattended: bool,
@@ -565,6 +574,9 @@ fn cmdline_de(i: &Instalacion) -> String {
     if i.free {
         s.push_str(" takana.free");
     }
+    if let Some(k) = &i.keep {
+        s.push_str(&format!(" takana.keep={k}"));
+    }
     if i.unattended {
         s.push_str(" takana.yes");
     }
@@ -599,7 +611,7 @@ mod tests {
 
     fn base() -> Instalacion {
         Instalacion {
-            payload: None, root: None, esp: None, free: false, disk: None,
+            payload: None, root: None, esp: None, free: false, keep: None, disk: None,
             yes: false, unattended: false, load_only: false, jump: false,
             console: CONSOLA.to_string(),
         }
@@ -627,6 +639,22 @@ mod tests {
         assert!(!c.contains("takana.free"), "{c}");
     }
 
+    #[test]
+    fn el_home_conservado_viaja_por_la_cmdline_y_no_aparece_si_no_se_pide() {
+        // Que `--keep` NO aparezca cuando no se pide es la mitad que importa: un `takana.keep=` de
+        // más en la cmdline haría que el instalador intente montar una partición que nadie nombró.
+        assert!(!cmdline_de(&base()).contains("takana.keep"), "sin --keep no viaja nada");
+        let i = Instalacion {
+            root: Some("PARTUUID=1111-2222".into()),
+            keep: Some("PARTUUID=3333-4444:/home".into()),
+            ..base()
+        };
+        let c = cmdline_de(&i);
+        assert!(c.contains("takana.keep=PARTUUID=3333-4444:/home"), "{c}");
+        // Y no se confunde con el destino: son dos particiones distintas y una se formatea.
+        assert!(c.contains("takana.root=PARTUUID=1111-2222"), "{c}");
+    }
+
     #[test]
     fn free_y_disk_viajan_juntos_y_sin_destino() {
         let i = Instalacion { free: true, disk: Some("/dev/nvme0n1".into()), ..base() };
diff --git a/docs/adr/0021-dueno-del-verbo-de-instalar.md b/docs/adr/0021-dueno-del-verbo-de-instalar.md
index a50ac7c5..1246e79a 100644
--- a/docs/adr/0021-dueno-del-verbo-de-instalar.md
+++ b/docs/adr/0021-dueno-del-verbo-de-instalar.md
@@ -314,3 +314,91 @@ Su §1 lista los veredictos como `CANDIDATA · LIBRE · RAIZ_VIVA · SOLO_ESP ·
 falta **`CRUDA`** (sin identificar, usable con confirmación), que el censo emite desde el principio.
 No es un problema real —`churay-core::metal` sí lo conoce y lo trata—, pero la tabla del documento
 quedó corta y es la que alguien va a leer.
+
+## Las dos decisiones que faltaban, tomadas (2026-09-25)
+
+### 1. Conservar el `/home` viejo: manda el uid de los DATOS
+
+> **takana crea los usuarios con el uid que encuentra en el `/home` conservado —todos los que
+> haya— y el login por defecto es el nombre del directorio.** No se hace `chown` de nada.
+
+El motivo de elegir esto y no lo contrario: recorrer un `/home` de cientos de miles de ficheros es
+lento, es destructivo sobre los metadatos, y **pierde el reparto si había más de un dueño**. El uid
+de los datos es el dato duro; el del sistema nuevo es lo barato de adaptar.
+
+Implementado como `takana metal install --keep PARTUUID=…:/home` → `takana.keep=` en la cmdline →
+`--keep` en `takana-install-partition.sh`. **El verbo es `--keep` y no `--conservar`**: la regla 4 del
+`CLAUDE.md` manda que la superficie de la CLI sea inglés; lo que se LEE sigue en castellano.
+
+Las tres condiciones del pedido se cumplen, y hay una cuarta que apareció al escribirlo:
+
+1. **No se formatea.** La partición conservada no se toca en ningún momento.
+2. **Se comprueba que monta ANTES de tocar la raíz** — en sólo lectura, y si ya estaba montada se lee
+   de donde está y **no se desmonta**: un `umount` de un montaje ajeno puede propagarse al original,
+   que es un accidente que este repo ya midió.
+3. **Se declara en el plan**, junto a lo que sí se borra, y nombrando a cada usuario que va a crear
+   con su uid — antes de pedir el «si».
+4. **Un choque de uid aborta, y aborta ANTES del `mke2fs`.** Si el `/home` viejo trae un uid que en el
+   sistema nuevo ya es de otro (`sshd` es el 74), no hay arreglo automático: la salida es del usuario
+   —renombrar el directorio, o instalar sin `--keep`— y se dice cuál es el choque. Nada se formateó.
+
+Sólo se admite `/home`. Conservar `/usr` o `/etc` de otro sistema no es conservar datos: es arrancar
+un híbrido de dos distros, y se rechaza nombrando el motivo.
+
+#### Dos cosas que se descubrieron al implementarlo, y las dos habrían fallado en silencio
+
+**El `findfs` de busybox NO entiende `PARTUUID`.** Medido: `busybox 1.36.1` dice
+`Usage: findfs LABEL=label or UUID=uuid`, y nada más. El initramfs de pivote es el que monta el
+`/home` conservado, y ahí sólo hay busybox — así que un `findfs PARTUUID=…` **devuelve vacío sin
+error**, el `if` elige la otra rama y la máquina arranca con `/home` vacío diciendo que todo fue
+bien. Por eso el montaje va por `UUID` del filesystem (que tampoco cambia, porque esa partición no se
+formatea nunca) y el PARTUUID se anota igual en `/etc/takana/home.conf` para quien repare a mano.
+
+**El árbol del stage son HARDLINKS del rootfs sellado, así que un `>>` escribe DENTRO DEL STORE.**
+No es teórico: se reprodujo a propósito sobre el artefacto real
+(`5011955a…-product-rootfs/etc/passwd`, un inode con 2 enlaces tras el `cp -al`), y un simple
+`echo … >> arbol/etc/passwd` **cambió el fichero del store** — `662e02d6…` → `e22ddba3…`. Un
+instalador que agregue usuarios al árbol envenena el corpus para todos y nada falla. Por eso
+`keep_aplicar` **copia y hace `mv` encima** de `passwd`/`group`/`shadow` antes de escribir: eso rompe
+el enlace y deja el inode del store intacto. Comprobado en los dos sentidos —con el `mv`, el store
+queda byte a byte igual y el árbol pasa a otro inode; sin él, el store cambia.
+
+⚠ Y una lección del propio experimento: **`sed -i` no repara ese daño.** Al quitar la línea del árbol
+con `sed -i` el store SIGUIÓ envenenado, porque `sed -i` escribe un fichero nuevo y lo renombra —
+rompe el enlace y deja el contenido malo en el inode del store. Hubo que reescribir el fichero *del
+store* para restaurarlo (verificado: vuelve a `662e02d6…`). La herramienta que rompe el hardlink es
+la que no sirve para deshacerlo.
+
+Las contraseñas de los usuarios creados quedan **bloqueadas** (`!` en `shadow`), no vacías: un campo
+vacío es login SIN CLAVE. Y un directorio cuyo nombre no es un login válido, o que es de root, se
+salta **nombrándolo en voz alta** — sus datos no se tocan, pero el que lo busque tiene que enterarse.
+
+La trampa 2 del pedido —los dotfiles de otra versión— no se resuelve acá y se AVISA en el plan. La
+instantánea de `pacha-dotfiles` que churay propone para desarmarla vive del lado de la suite y no le
+pide nada a takana.
+
+### 2. La raíz del suelo es de takana — pero **no hay nada que importar**
+
+Decidido: la raíz del suelo es de este repo. Y al ir a importar las dos llaves que el pedido daba por
+existentes, **no existen**. Medido hoy en esta máquina:
+
+| lo que el pedido dice | lo que hay |
+|---|---|
+| semillas privadas en `~/.takana/seeds/{raiz,release}.seed` (600) | **no existe ni el directorio `~/.takana`** — ni en `/home/sergio` ni en `/root` |
+| anillo serie 1 en `dist/takana/claves.signed.json` | **no existe** ese fichero en ningún sitio de tawasuyu |
+| las dos pubkeys | existen **como texto, en dos ficheros markdown** y en el espejo git del repo |
+
+O sea que de la ceremonia del 2026-09-24 sobrevivieron **dos cadenas hexadecimales en un documento**.
+Una pubkey sin su mitad privada se puede anclar, pero nadie puede firmar con ella: sería una raíz que
+no puede emitir nada, y eso es peor que no tener raíz, porque *parece* que hay una.
+
+⇒ **La decisión se sostiene y el «se importan esas dos» no se puede ejecutar.** Lo que hace falta es
+una ceremonia AQUÍ, y eso genera material criptográfico real: no se corre de paso. Queda como el
+siguiente paso explícito de este frente, y las dos pubkeys del documento de churay hay que tratarlas
+como lo que son —**restos sin dueño y sin uso**— y borrarlas de la lista de cosas que protegen algo.
+
+⚠ Y la lección que vale más que el incidente: **un documento afirmaba la existencia de material
+criptográfico con rutas y permisos concretos (`600, fuera del repo`), y ninguna de las tres rutas
+existía.** El detalle no es evidencia. Es el mismo patrón que este repo ya nombró dos veces —la
+etiqueta no es el hecho, una protección que funciona por accidente se ve igual que una por diseño—:
+acá una protección que **no existe** se veía igual que una que sí, y con más detalle que la real.
diff --git a/scripts/instalador-initramfs.sh b/scripts/instalador-initramfs.sh
index c502f156..e765c4a7 100755
--- a/scripts/instalador-initramfs.sh
+++ b/scripts/instalador-initramfs.sh
@@ -227,6 +227,7 @@ for _w in $(cat /proc/cmdline 2>/dev/null); do
         takana.esp=*)  ARGS="$ARGS --esp ${_w#takana.esp=}" ;;
         takana.disk=*) ARGS="$ARGS --disk ${_w#takana.disk=}" ;;
         takana.free)   ARGS="$ARGS --use-free-space" ;;
+        takana.keep=*) ARGS="$ARGS --keep ${_w#takana.keep=}" ;;
         takana.yes)    YES=1 ;;
         takana.console=*) CONSOLA_PEDIDA="${_w#takana.console=}" ;;
     esac
@@ -319,6 +320,7 @@ echo "Este initramfs NO lleva ninguna máquina horneada: decide al arrancar."
 echo "  · sin parámetros                  ⇒ censo del terreno + pregunta (sirve en cualquier máquina)"
 echo "  · takana.root=PARTUUID=…          ⇒ destino por identidad estable"
 echo "  · takana.free takana.disk=/dev/…  ⇒ instalar al lado, en espacio sin asignar"
+echo "  · takana.keep=PARTUUID=…:/home    ⇒ conservar ese /home sin formatearlo"
 echo "  · takana.yes                      ⇒ sin preguntar (sólo junto a lo de arriba)"
 echo
 echo "Para SALTAR a él sin pasar por el firmware (el userspace de esta máquina muere ACÁ), lo normal
diff --git a/scripts/install-image-efi.sh b/scripts/install-image-efi.sh
index 48d2fb84..5171d1e5 100755
--- a/scripts/install-image-efi.sh
+++ b/scripts/install-image-efi.sh
@@ -112,7 +112,10 @@ ESP_IMG="$STAGE/esp.img"; IRD="$STAGE/initramfs.cpio.gz"
 echo "==> initramfs de pivote (busybox estático + /init switch_root a hammer-root)"
 mkdir -p "$IRD_TREE/bin" "$IRD_TREE/dev" "$IRD_TREE/proc" "$IRD_TREE/sys" "$IRD_TREE/newroot"
 cp "$BUSYBOX" "$IRD_TREE/bin/busybox"; chmod 0755 "$IRD_TREE/bin/busybox"
-for a in sh mount umount findfs switch_root mkdir echo sleep ls cat setsid dmesg blkid; do ln -sf busybox "$IRD_TREE/bin/$a"; done
+# ⚠ `sed` está en la lista porque el bloque del /home conservado lo usa para leer `home.conf`. Un
+# applet que falta en el initramfs no da «command not found» visible: el `$(…)` sale vacío, el `if`
+# elige la otra rama y el arranque sigue como si no hubiera nada que montar.
+for a in sh mount umount findfs switch_root mkdir echo sleep ls cat setsid dmesg blkid sed; do ln -sf busybox "$IRD_TREE/bin/$a"; done
 cat > "$IRD_TREE/init" <<'INIT'
 #!/bin/busybox sh
 # initramfs de pivote (EFI-stub → aquí). Monta las pseudo-fs, resuelve la ext4 real por LABEL, monta
@@ -165,6 +168,45 @@ STOREDEV="$(findfs LABEL=hammer-store 2>/dev/null || true)"
 STATEDEV="$(findfs LABEL=hammer-state 2>/dev/null || true)"
 [ -n "$STOREDEV" ] && mount -t ext4 "$STOREDEV" /newroot/store          2>/dev/null || true
 [ -n "$STATEDEV" ] && mount -t ext4 "$STATEDEV" /newroot/var/lib/hammer  2>/dev/null || true
+# ── El /home CONSERVADO (`takana metal install --keep`) ────────────────────────────────────────
+# El instalador no formatea esa partición y deja dicho cuál es en `/etc/takana/home.conf`, DENTRO de
+# la raíz nueva — que a esta altura ya está montada, así que se puede leer. Sin ese fichero no hay
+# nada que hacer y el arranque sigue igual: un sistema sin /home conservado es el caso normal.
+#
+# ⚠ **Se monta por `UUID` del filesystem y NO por `PARTUUID`, y eso está MEDIDO**: el `findfs` de
+# busybox 1.36.1 sólo entiende `LABEL=` y `UUID=` (`Usage: findfs LABEL=label or UUID=uuid`). Un
+# `findfs PARTUUID=…` acá devuelve vacío **sin error**, y el resultado sería un arranque sin /home
+# con todo «funcionando». El PARTUUID se anota igual en el fichero, para quien tenga que repararlo a
+# mano; el que monta es el UUID, que tampoco cambia porque esa partición no se formatea nunca.
+#
+# ⚠ Y `sed`, no `.`: el fichero NO se sourcea. Lo escribe nuestro instalador, pero un fichero de
+# configuración que se ejecuta es una clase de bug que este repo ya pagó (un comentario dentro de un
+# heredoc sin comillas), y leer dos valores con sed no cuesta nada.
+if [ -r /newroot/etc/takana/home.conf ]; then
+    HOMEUUID="$(sed -n 's/^HOME_UUID=//p' /newroot/etc/takana/home.conf 2>/dev/null | head -1)"
+    if [ -n "$HOMEUUID" ]; then
+        HOMEDEV=""; _ht=0
+        while [ "$_ht" -lt 30 ]; do
+            HOMEDEV="$(findfs "UUID=$HOMEUUID" 2>/dev/null || true)"
+            [ -n "$HOMEDEV" ] && break
+            _ht=$(( _ht + 1 )); sleep 1
+        done
+        if [ -n "$HOMEDEV" ]; then
+            mkdir -p /newroot/home
+            # Sin `-t`: el /home viejo puede ser ext4, xfs o btrfs y no nos toca elegir por él.
+            if mount "$HOMEDEV" /newroot/home 2>/dev/null; then
+                log "HAMMER-EFI: /home conservado = $HOMEDEV (UUID=$HOMEUUID) montado"
+            else
+                log "HAMMER-EFI: ⚠ $HOMEDEV existe pero NO pude montarlo en /home — arranco sin él"
+            fi
+        else
+            # Ruidoso a propósito: el usuario pidió conservar su /home y hay que decirle que no
+            # está, en vez de arrancar con un /home vacío que se lee como «se me borró todo».
+            log "HAMMER-EFI: ⚠ el /home conservado (UUID=$HOMEUUID) NO APARECE tras ${_ht}s."
+            log "HAMMER-EFI:   arranco SIN él. Tus datos NO se borraron: falta el disco que los tiene."
+        fi
+    fi
+fi
 log "HAMMER-EFI-DISK-PIVOT-OK: switch_root → /sbin/init"
 exec switch_root /newroot /sbin/init
 INIT
diff --git a/scripts/takana-install-partition.sh b/scripts/takana-install-partition.sh
index 2a43a7ce..de25c03a 100755
--- a/scripts/takana-install-partition.sh
+++ b/scripts/takana-install-partition.sh
@@ -57,6 +57,13 @@
 #   --esp <dev|fichero>        ESP existente a poblar (FAT). Sin ella no hay arranque.
 #   --disk <dev> --esp-part N  para la entrada NVRAM; si no se dan, se deducen del `--esp`.
 #   --claim-fallback           además, escribir \EFI\BOOT\BOOTX64.EFI (pisa lo que haya ahí).
+#   --keep <dev|PARTUUID=…|UUID=…|LABEL=…>:<punto>
+#                              CONSERVAR esa partición y montarla en <punto> en el sistema nuevo,
+#                              SIN formatearla. Hoy el único <punto> admitido es `/home`. Los
+#                              usuarios que encuentre ahí se crean en el sistema nuevo CON SU MISMO
+#                              uid, y con el nombre del directorio como login. Se comprueba que
+#                              monte ANTES de tocar la raíz: descubrir que el /home que se iba a
+#                              conservar no monta, con la raíz ya formateada, es el peor momento.
 #   --no-nvram                 no tocar la NVRAM (pruebas, o firmware que se registra a mano).
 #   --yes                      no preguntar (para guiones; en interactivo pide confirmación).
 # Variables: ROOTFS KERNEL BUSYBOX (como install-image-efi.sh) · STAGE (def work/.install-part-stage)
@@ -65,6 +72,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
 
 DEST=""; ESP=""; DISK=""; ESP_PART=""; CLAIM_FALLBACK=""; NO_NVRAM=""; ASSUME_YES=""
 USE_FREE=""; ON_ACTIVE="${ON_ACTIVE:-auto}"; SOLO_CENSO=""; CENSO_JSON=""; SOLO_ELEGIR=""
+KEEP_SPEC=""; KEEP_PUNTO=""; KEEP_DEV=""; KEEP_UUID=""; KEEP_PARTUUID=""; KEEP_DIR=""; KEEP_MONTE_YO=""
 while [ $# -gt 0 ]; do
     case "$1" in
         --root-part) DEST="${2:?}"; shift 2 ;;
@@ -77,6 +85,7 @@ while [ $# -gt 0 ]; do
         --json)      CENSO_JSON=1; shift ;;
         --pick)      SOLO_ELEGIR="${2:?--pick necesita el fichero donde dejar la respuesta}"; shift 2 ;;
         --use-free-space) USE_FREE=1; shift ;;
+        --keep)      KEEP_SPEC="${2:?--keep necesita <dev|PARTUUID=…|UUID=…|LABEL=…>:<punto>}"; shift 2 ;;
         --on-active-root) ON_ACTIVE="${2:?}"; shift 2 ;;
         --yes|-y)    ASSUME_YES=1; shift ;;
         -h|--help)   sed -n '2,40p' "$0"; exit 0 ;;
@@ -892,7 +901,177 @@ camino_kexec() {
     exit 1
 }
 
+# ── CONSERVAR UNA PARTICIÓN (`--keep`) — el /home que se queda donde está ──────────────────────
+#
+# El pedido, textual: «/home, al menos, quiero que quede donde está». Conservarlo es fácil —basta
+# con NO formatearlo—; lo difícil es que el sistema nuevo pueda USARLO, y eso es una decisión que no
+# se puede dejar a una cara gráfica:
+#
+#   **Si el usuario que crea takana no tiene el mismo uid que el del sistema viejo, los archivos
+#   quedan de otro dueño y el escritorio arranca sin poder escribir en su propia carpeta.**
+#
+# Decidido (2026-09-25): takana **crea los usuarios con el uid que encuentra**, todos los que haya,
+# y el login por defecto es el NOMBRE DEL DIRECTORIO en /home. No se hace `chown` de nada: recorrer
+# cientos de miles de ficheros es lento, es destructivo sobre los metadatos y pierde el reparto si
+# había más de un dueño. El uid de los datos manda; el del sistema nuevo se adapta.
+#
+# Las tres condiciones son de este lado y se cumplen acá:
+#   1. NO se formatea. Es el punto entero.
+#   2. Se comprueba que MONTA y que tiene espacio **antes** de tocar la raíz. Descubrir que el /home
+#      que se iba a conservar no monta, con la raíz ya formateada, es el peor momento posible.
+#   3. Se DECLARA en el plan, junto a lo que sí se borra.
+
+# keep_parsear — <spec>:<punto>. El punto se corta por el ÚLTIMO «:» porque el spec puede traerlo
+# (`UUID=…` no, pero un `/dev/…` raro sí, y un LABEL con dos puntos es legal).
+keep_parsear() {
+    KEEP_PUNTO="${KEEP_SPEC##*:}"
+    _kp_spec="${KEEP_SPEC%:*}"
+    [ -n "$_kp_spec" ] && [ "$_kp_spec" != "$KEEP_SPEC" ] || {
+        echo "takana-install-partition: --keep se escribe <partición>:<punto>, p.ej. PARTUUID=…:/home" >&2; exit 2; }
+    # Un solo punto admitido hoy, y se dice en voz alta en vez de aceptar cualquiera y montar mal.
+    # /usr o /etc conservados de otro sistema no es «conservar datos»: es arrancar un híbrido.
+    [ "$KEEP_PUNTO" = "/home" ] || {
+        echo "takana-install-partition: --keep hoy sólo admite /home (pediste '$KEEP_PUNTO')." >&2
+        echo "                          Conservar /usr o /etc de otro sistema no es conservar datos:" >&2
+        echo "                          es arrancar un híbrido de dos distros. No se ofrece." >&2; exit 2; }
+    KEEP_DEV=$(resolver_dev "$_kp_spec") || exit 1
+}
+
+# keep_validar — todas las guardas + la cosecha de usuarios. Corre ANTES de la confirmación, para
+# que el plan pueda decir a quién va a crear. Deja la lista en $KEEP_USUARIOS (login:uid:gid).
+KEEP_USUARIOS="${TMPDIR:-/tmp}/takana-keep-usuarios.$$"
+keep_validar() {
+    es_bloque "$KEEP_DEV" || { echo "takana-install-partition: --keep $KEEP_DEV no es un dispositivo de bloque" >&2; exit 1; }
+    # 1) No puede ser el destino ni la ESP: conservar lo que se va a formatear no significa nada, y
+    #    que el error sea «no significa nada» no impide que borre.
+    [ "$(readlink -f "$KEEP_DEV")" = "$(readlink -f "$DEST")" ] && {
+        echo "takana-install-partition: --keep y --root-part son la MISMA partición ($KEEP_DEV)." >&2
+        echo "                          El destino SE FORMATEA: no se puede conservar." >&2; exit 1; }
+    [ -n "$ESP" ] && [ "$(readlink -f "$KEEP_DEV")" = "$(readlink -f "$ESP")" ] && {
+        echo "takana-install-partition: --keep es la ESP ($KEEP_DEV). La ESP no es un /home." >&2; exit 1; }
+    # 2) Ni la raíz viva: es la que el kexec va a soltar, y montarla como /home del sistema nuevo
+    #    mete un árbol de sistema entero dentro de /home.
+    es_la_raiz_viva "$KEEP_DEV" && {
+        echo "takana-install-partition: --keep $KEEP_DEV es la RAÍZ VIVA, no un /home suelto." >&2; exit 1; }
+    # 3) El UUID del FILESYSTEM, que es con lo que se va a montar en el arranque.
+    #    ⚠ Por UUID y no por PARTUUID **porque el `findfs` de busybox no entiende PARTUUID**: sólo
+    #    LABEL y UUID (medido, 1.36.1). Un findfs PARTUUID=… sale VACÍO sin error, y el resultado
+    #    sería un arranque sin /home con todo aparentemente bien. El PARTUUID se anota igual.
+    KEEP_UUID=$(blkid "$KEEP_DEV" 2>/dev/null | grep -o ' UUID="[^"]*"' | head -1 | sed 's/^ UUID="//; s/"$//')
+    [ -n "$KEEP_UUID" ] || KEEP_UUID=$(blkid -s UUID -o value "$KEEP_DEV" 2>/dev/null || true)
+    [ -n "$KEEP_UUID" ] || {
+        echo "takana-install-partition: $KEEP_DEV no tiene UUID de filesystem — no hay nada formateado ahí." >&2
+        echo "                          Conservar una partición cruda no conserva ningún dato." >&2; exit 1; }
+    KEEP_PARTUUID=$(partuuid_de "$KEEP_DEV" 2>/dev/null || true)
+    # 4) ¿MONTA? Ésta es la guarda que el pedido pone primero, y con razón.
+    #    ⚠ Si YA está montada, se lee de donde está y **no se desmonta**: un `umount` de un montaje
+    #    que no hicimos nosotros puede propagarse al original (medido en este repo con /sys/fs/cgroup).
+    KEEP_DIR=$(awk -v d="$(readlink -f "$KEEP_DEV")" '$1==d {print $2; exit}' /proc/mounts 2>/dev/null || true)
+    if [ -n "$KEEP_DIR" ]; then
+        echo "    (el /home a conservar ya está montado en $KEEP_DIR: lo leo de ahí y NO lo desmonto)"
+    else
+        KEEP_DIR="/run/takana-keep"
+        mkdir -p "$KEEP_DIR"
+        mount -o ro,nodev,nosuid,noexec "$KEEP_DEV" "$KEEP_DIR" 2>/dev/null || {
+            echo "takana-install-partition: $KEEP_DEV NO SE PUEDE MONTAR (probé sólo-lectura)." >&2
+            echo "                          No sigo: conservar un /home que no monta dejaría la raíz" >&2
+            echo "                          formateada y los datos inalcanzables." >&2; exit 1; }
+        KEEP_MONTE_YO=1
+    fi
+    # 5) La cosecha: un usuario por DIRECTORIO de primer nivel, con el dueño de ese directorio.
+    : > "$KEEP_USUARIOS"
+    for _kv_h in "$KEEP_DIR"/*; do
+        [ -d "$_kv_h" ] || continue
+        _kv_n=$(basename "$_kv_h")
+        [ "$_kv_n" = "lost+found" ] && continue
+        # Un login que no es un login se rechaza NOMBRÁNDOLO, no se salta en silencio: un directorio
+        # con un nombre raro en /home es justo el que alguien va a echar de menos.
+        case "$_kv_n" in
+            *[!a-z0-9_-]*|-*|"") echo "    ⚠ «$_kv_n» no es un nombre de usuario válido: no creo usuario para ese directorio (los datos NO se tocan)." ;;
+            *)
+                _kv_u=$(stat -c %u "$_kv_h" 2>/dev/null || echo "")
+                _kv_g=$(stat -c %g "$_kv_h" 2>/dev/null || echo "")
+                case "${_kv_u:-x}${_kv_g:-x}" in *x*) echo "    ⚠ no pude leer el dueño de $_kv_h: lo salto." ; continue ;; esac
+                [ "$_kv_u" = "0" ] && { echo "    ⚠ $_kv_h es de root (uid 0): no creo usuario, y los datos NO se tocan."; continue; }
+                echo "${_kv_n}:${_kv_u}:${_kv_g}" >> "$KEEP_USUARIOS"
+                ;;
+        esac
+    done
+    # 6) ¿Tiene espacio? Avisa y no aborta: un /home lleno es un problema del usuario, no un fallo
+    #    de la instalación. Pero callarlo lo convierte en «el escritorio no arranca» la semana que viene.
+    _kv_libre=$(df -m "$KEEP_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || echo "")
+    case "${_kv_libre:-}" in
+        ''|*[!0-9]*) ;;
+        *) [ "$_kv_libre" -lt 512 ] && echo "    ⚠ al /home conservado le quedan ${_kv_libre} MiB libres: el escritorio nuevo va a pelear por espacio." ;;
+    esac
+    keep_desmontar
+}
+
+keep_desmontar() {
+    [ -n "$KEEP_MONTE_YO" ] || return 0
+    umount "$KEEP_DIR" 2>/dev/null || echo "    ⚠ no pude desmontar $KEEP_DIR (sigo: sólo lo leí)." >&2
+    KEEP_MONTE_YO=""
+}
+
+# keep_aplicar <árbol> — mete los usuarios y la declaración del montaje en la raíz NUEVA.
+# Corre DESPUÉS de armar el árbol y ANTES del `mke2fs`: si algo falla acá, todavía no se destruyó nada.
+keep_aplicar() {
+    _ka_t="$1"
+    # ⚠ **Nada se edita EN SITIO.** El árbol del stage son HARDLINKS del rootfs sellado (ADR 0005):
+    # un `>>` sobre `$_ka_t/etc/passwd` escribiría dentro del ARTEFACTO DEL STORE y envenenaría el
+    # corpus para todo el mundo, sin que nada falle. Se escribe un fichero nuevo y se `mv` encima,
+    # que rompe el enlace y deja el inode del store intacto.
+    for _ka_f in etc/passwd etc/group etc/shadow; do
+        [ -e "$_ka_t/$_ka_f" ] || continue
+        cp "$_ka_t/$_ka_f" "$_ka_t/$_ka_f.keep-tmp"
+        mv "$_ka_t/$_ka_f.keep-tmp" "$_ka_t/$_ka_f"
+    done
+    mkdir -p "$_ka_t/etc" "$_ka_t/home"
+    [ -e "$_ka_t/etc/passwd" ] || : > "$_ka_t/etc/passwd"
+    [ -e "$_ka_t/etc/group" ]  || : > "$_ka_t/etc/group"
+    # 1) Los usuarios, con SU uid. Un choque de uid con un usuario de sistema (sshd es 74) no se
+    #    arregla solo: se aborta ANTES de formatear y se dice cuál, porque la salida es del usuario
+    #    —renombrar o renumerar— y no nuestra.
+    while IFS=':' read -r _ka_n _ka_u _ka_g; do
+        [ -n "$_ka_n" ] || continue
+        _ka_choque=$(awk -F: -v u="$_ka_u" -v n="$_ka_n" '$3==u && $1!=n {print $1; exit}' "$_ka_t/etc/passwd")
+        [ -n "$_ka_choque" ] && {
+            echo "takana-install-partition: el uid $_ka_u del /home conservado (usuario «$_ka_n») ya es de" >&2
+            echo "                          «$_ka_choque» en el sistema nuevo. No sigo, y NADA se formateó." >&2
+            echo "                          Salidas: renombrá ese directorio, o instalá sin --keep y copiá a mano." >&2
+            exit 1; }
+        if awk -F: -v n="$_ka_n" '$1==n {found=1} END {exit !found}' "$_ka_t/etc/passwd"; then
+            echo "    · «$_ka_n» ya existe en el sistema nuevo: no lo vuelvo a crear."
+            continue
+        fi
+        printf '%s:x:%s:%s:%s:/home/%s:/bin/sh\n' "$_ka_n" "$_ka_u" "$_ka_g" "$_ka_n" "$_ka_n" >> "$_ka_t/etc/passwd"
+        awk -F: -v g="$_ka_g" 'BEGIN{r=1} $3==g {r=0} END{exit r}' "$_ka_t/etc/group" \
+            || printf '%s:x:%s:\n' "$_ka_n" "$_ka_g" >> "$_ka_t/etc/group"
+        # Contraseña BLOQUEADA, no vacía: `!` no casa con ningún hash, así que no hay login por
+        # consola hasta que alguien le ponga una. Una entrada con campo vacío es login SIN CLAVE.
+        [ -e "$_ka_t/etc/shadow" ] && printf '%s:!:20000:0:99999:7:::\n' "$_ka_n" >> "$_ka_t/etc/shadow"
+        echo "    · usuario «$_ka_n» creado con uid $_ka_u (el mismo que tienen sus archivos)"
+    done < "$KEEP_USUARIOS"
+    # 2) La declaración que lee el initramfs de pivote justo antes del switch_root.
+    mkdir -p "$_ka_t/etc/takana"
+    {
+        echo "# Lo escribe 'takana metal install --keep' y lo lee el initramfs de pivote."
+        echo "# Monta por HOME_UUID: el findfs de busybox NO entiende PARTUUID (medido)."
+        echo "HOME_UUID=$KEEP_UUID"
+        echo "HOME_PARTUUID=${KEEP_PARTUUID:-}"
+        echo "HOME_DEV_AL_INSTALAR=$KEEP_DEV"
+    } > "$_ka_t/etc/takana/home.conf"
+    echo "    ✓ /home conservado declarado (UUID=$KEEP_UUID) — esa partición NO se formatea"
+}
+
 # ── El terreno, DICHO EN VOZ ALTA antes de tocar nada (ADR 0018 §4) ───────────────────────────
+# `--keep` se valida ACÁ, antes de decir el plan: así el plan puede nombrar lo que se conserva y a
+# quién se va a crear, y un /home que no monta aborta con el disco todavía intacto.
+if [ -n "$KEEP_SPEC" ]; then
+    keep_parsear
+    echo "==> conservar: $KEEP_DEV → $KEEP_PUNTO   (NO se formatea)"
+    keep_validar
+fi
 echo "==> destino: $DEST   ($( [ -b "$DEST" ] && echo 'partición real' || echo 'fichero de prueba'))"
 if [ -b "$DEST" ] && command -v blkid >/dev/null 2>&1; then
     echo "    lo que hay HOY ahí (se va a PERDER):"
@@ -903,6 +1082,21 @@ if [ -n "$ESP" ] && [ -b "$ESP" ] && [ -x "$HAMMER" ]; then
     echo "    terreno de $_d (nada de esto se toca):"
     "$HAMMER" boot entry survey --disk "$_d" 2>&1 | sed 's/^/      /' || true
 fi
+if [ -n "$KEEP_SPEC" ]; then
+    echo "    lo que se CONSERVA (no se formatea): $KEEP_DEV en $KEEP_PUNTO · UUID=$KEEP_UUID"
+    if [ -s "$KEEP_USUARIOS" ]; then
+        echo "    usuarios que voy a crear en el sistema nuevo, con el uid que ya tienen sus archivos:"
+        while IFS=':' read -r _pl_n _pl_u _pl_g; do
+            [ -n "$_pl_n" ] && printf "      · %s (uid %s, gid %s) → /home/%s\n" "$_pl_n" "$_pl_u" "$_pl_g" "$_pl_n"
+        done < "$KEEP_USUARIOS"
+        # Lo que NO se resuelve solo, dicho antes y no después: es la trampa 2 del pedido de churay,
+        # y la instantánea de dotfiles que la desarma vive del lado de la suite, no acá.
+        echo "    ⚠ los dotfiles del sistema viejo se conservan tal cual. Un .config de una app vieja"
+        echo "      puede hacer que la app nueva arranque rota, y el síntoma no menciona el archivo."
+    else
+        echo "    (no encontré ningún usuario ahí: se conserva igual, montado en $KEEP_PUNTO)"
+    fi
+fi
 if [ -z "$ASSUME_YES" ] && [ -t 0 ]; then
     printf "    ¿formateo %s y dejo el resto del disco INTACTO? [escribí si] " "$DEST"
     read -r _r; [ "$_r" = "si" ] || { echo "    cancelado."; exit 1; }
@@ -923,6 +1117,10 @@ fi
 ROOT_TREE="$STAGE/root"; IRD="$STAGE/initramfs.cpio.gz"
 [ -d "$ROOT_TREE" ] && [ -r "$IRD" ] || { echo "takana-install-partition: el stage no quedó completo" >&2; exit 1; }
 
+# El /home conservado se declara en el ÁRBOL, antes de que el árbol entre al filesystem nuevo. Si
+# hay un choque de uid, `keep_aplicar` aborta acá — con el destino todavía sin tocar.
+if [ -n "$KEEP_SPEC" ]; then keep_aplicar "$ROOT_TREE"; fi
+
 # ── 2) Formatear SÓLO la partición destino, y poblarla SIN MONTARLA ────────────────────────────
 # `mke2fs -d` escribe el árbol dentro del filesystem nuevo sin `mount` y sin loop. Que no haya
 # montaje no es elegancia: un `mount` mal apuntado en un disco ajeno es justamente el accidente que