tawasuyu

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…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

traducir: familia «servicio» — systemd → tarjeta de arje, y el pivote pasa a ser uno POR FAMILIA

Un unit de systemd no es un sitio web. Meterlo en `Sitio`/`Ruta` sería justo el «parecerse» que el
acta existe para evitar, así que cada familia tiene su modelo y el centro empareja SÓLO dentro de la
familia. Un par cruzado se rechaza con un error, no con un intento: `systemd → caddy` sale por
`sys.exit`, porque traducir entre familias daría un fichero que PARECE correcto.

Dos familias hoy: `web` (nginx, apache → caddy) y `servicio` (systemd → arje). 3 pares con 5 plugins.

**Dónde una elisión silenciosa no pierde una opción sino que CAMBIA el sistema.** El payload `Native`
de arje acepta `exec`, `argv` y `envp` y nada más (comprobado en la semilla real del producto): NO
hay campo de usuario. Un `User=git` traducido en silencio correría el servicio COMO ROOT — escalación
de privilegios en un fichero generado que nadie vuelve a leer. Sale SIN-TRADUCIR con su línea. Igual
`EnvironmentFile=` (el fichero no está en la máquina donde se traduce ⇒ envp incompleto, falla
tarde), `Type=forking` (arje supervisa al proceso que lanza ⇒ BUCLE DE REINICIO) y todo el
endurecimiento, que callado entrega un servicio MENOS confinado que el original.

Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`, que ya existe en la
semilla del producto — buscarlo antes de declararlo intraducible evitó una elisión inventada.

**Lector y escritor no opinan del mismo campo.** `User=` lo CAPTURA el lector y lo JUZGA el escritor;
cuando los dos anotaban, el acta decía dos cosas distintas de la misma línea, y un acta que se
contradice se deja de leer. El lector registra en qué línea vio cada campo (`Servicio.lineas`) para
que el escritor señale la línea real en vez de un 0.

Controles: la tarjeta generada tiene el MISMO juego de claves que la tarjeta real de `sshd` del
producto (+`_mudanza` de procedencia); dos corridas dan el fichero byte a byte idéntico; y la familia
`web` sigue validando con el caddy del corpus (`Valid configuration` en los dos pares).

De paso, `ulid_determinista` sale a `ids.py`: lo necesitaban `declarar.py` y el escritor de arje, y
copiarlo habría dejado dos generadores de id que se pueden separar sin que nada falle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x

fefa92ec5a635ab719659a54f9a99c8546b238c7 · ruwaq Sergio · (1 killa ñawpaq) · tayta 822d0b894 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 19

scripts/mudanza/declarar.py

scripts/mudanza/formatos/__init__.py

scripts/mudanza/formatos/apache.py

  • silq'uyninpi t'ikrakurqan fn leer

scripts/mudanza/formatos/arje.py

scripts/mudanza/formatos/nginx.py

  • silq'uyninpi t'ikrakurqan fn leer

scripts/mudanza/formatos/systemd.py

scripts/mudanza/ids.py

scripts/mudanza/modelo.py

scripts/mudanza/traducir.py

11 qillqakuna · +559 −45

diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md
index 5ff8c8f9..e207b352 100644
--- a/docs/29-mudanza.md
+++ b/docs/29-mudanza.md
@@ -332,6 +332,49 @@ centro los **descubre**: no hay una lista que editar, porque una lista mantenida
 desincroniza y el formato nuevo «no existe» sin que nada falle. `traducir.py --list` dice qué pares
 habilita y cuántos plugins costaron.
 
+#### El pivote es uno POR FAMILIA
+
+Un unit de systemd no es un sitio web. Meterlo a la fuerza en `Sitio`/`Ruta` sería justo el
+«parecerse» que el acta existe para evitar, así que cada familia tiene su modelo y el centro
+**empareja sólo dentro de la familia**:
+
+| familia | modelo | lectores | escritores |
+|---|---|---|---|
+| `web` | `Sitio` / `Ruta` / `Accion` | nginx, apache | caddy |
+| `servicio` | `Servicio` / `Sistema` | systemd | arje |
+
+Un par cruzado se rechaza con un error, no con un intento:
+
+```
+$ traducir.py --from systemd --to caddy …
+✗ `systemd` es de la familia «servicio» y `caddy` de la familia «web».
+  No es un par: traducir entre familias distintas daría un fichero que PARECE correcto.
+```
+
+#### `systemd → arje`: dónde una elisión silenciosa no pierde una opción, sino que CAMBIA el sistema
+
+El payload `Native` de arje acepta `exec`, `argv` y `envp` — **y nada más** (comprobado en la semilla
+real del producto). No hay campo de usuario. Un `User=git` traducido en silencio correría el servicio
+**como root**: no es perder una opción, es **escalar privilegios en un fichero generado que nadie
+vuelve a leer**. Sale como SIN-TRADUCIR, con su línea.
+
+Los otros tres del mismo tipo: `EnvironmentFile=` (el fichero no está en la máquina donde se traduce
+⇒ el `envp` sale incompleto y el servicio falla tarde), `Type=forking` (arje supervisa al proceso que
+lanza ⇒ **bucle de reinicio**; la bandera de primer plano depende del demonio y decirla es del
+humano), y todo el endurecimiento (`ProtectSystem`, `NoNewPrivileges`…), que callado entrega un
+servicio **menos confinado** que el original.
+
+Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`, que ya existe en la
+semilla del producto. Y el control es que la tarjeta generada tiene **el mismo juego de claves que la
+tarjeta real de `sshd`**, más `_mudanza` de procedencia.
+
+#### Lector y escritor no opinan del mismo campo
+
+`User=` lo **captura** el lector y lo **juzga** el escritor. Cuando los dos anotaban, el acta decía
+dos cosas distintas de la misma línea — y un acta que se contradice se deja de leer. El lector
+registra además *en qué línea* vio cada campo, para que el escritor señale la línea real en vez de
+un `0` que obliga a buscar a mano lo que ya se sabía.
+
 #### El acta viaja DENTRO del modelo
 
 Es la decisión que hace que el pivote no mienta. Si el acta fuera un efecto de cada traductor, lo que
diff --git a/scripts/mudanza/declarar.py b/scripts/mudanza/declarar.py
index a371385c..bf4ee37a 100755
--- a/scripts/mudanza/declarar.py
+++ b/scripts/mudanza/declarar.py
@@ -35,22 +35,7 @@ import argparse, hashlib, json, os, shlex, sys, tomllib
 
 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
 from planear import origen_binario, alternativas, _REPO   # noqa: E402
-
-CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"   # sin I, L, O, U
-
-
-def ulid_determinista(sem, nombre):
-    """26 caracteres Crockford base32, derivados del nombre. No es un ULID con marca de tiempo real
-    —no hace falta— pero respeta el formato, y ser determinista es lo que permite `diff`."""
-    h = hashlib.blake2b(f"{sem}/{nombre}".encode(), digest_size=16).digest()
-    n = int.from_bytes(h, "big")
-    out = []
-    for _ in range(26):
-        out.append(CROCKFORD[n & 31])
-        n >>= 5
-    s = "".join(reversed(out))
-    return "0" + s[1:]          # el primer carácter de un ULID válido es <= '7'
-
+from ids import ulid_determinista                          # noqa: E402
 
 def tarjeta(nombre, cmdline, cwd, puertos):
     """Una entrada `genesis`. El molde sale de la semilla real del producto."""
diff --git a/scripts/mudanza/formatos/__init__.py b/scripts/mudanza/formatos/__init__.py
index 03fe2fdb..08e9e70c 100644
--- a/scripts/mudanza/formatos/__init__.py
+++ b/scripts/mudanza/formatos/__init__.py
@@ -3,27 +3,33 @@
 Un formato se agrega dejando un fichero en este directorio que decore sus funciones con `@lector` y
 `@escritor`. No hay que tocar el centro ni ningún otro plugin: ése es el punto de tener un pivote.
 
-    @lector("nginx")
+    @lector("nginx", familia="web")
     def leer(texto) -> Config: ...
 
-    @escritor("caddy")
+    @escritor("caddy", familia="web")
     def escribir(cfg) -> str: ...
+
+⚠ **LA FAMILIA NO ES DECORACIÓN.** El pivote es uno por familia: un unit de systemd no es un sitio
+web, y emparejar un lector de servicios con un escritor de web produciría basura *plausible* — el
+modo de fallo caro. El registro empareja SÓLO dentro de la familia y rechaza el cruce con un error,
+no con un intento. Es la misma regla del acta, aplicada un nivel más arriba: parecerse es peor que
+faltar.
 """
 import importlib, pkgutil, pathlib
 
-LECTORES, ESCRITORES = {}, {}
+LECTORES, ESCRITORES = {}, {}          # nombre -> (funcion, familia)
 
 
-def lector(nombre):
+def lector(nombre, familia="web"):
     def deco(f):
-        LECTORES[nombre] = f
+        LECTORES[nombre] = (f, familia)
         return f
     return deco
 
 
-def escritor(nombre):
+def escritor(nombre, familia="web"):
     def deco(f):
-        ESCRITORES[nombre] = f
+        ESCRITORES[nombre] = (f, familia)
         return f
     return deco
 
@@ -35,3 +41,13 @@ def cargar():
         if not m.name.startswith("_"):
             importlib.import_module(f"{__name__}.{m.name}")
     return LECTORES, ESCRITORES
+
+
+def familias():
+    """{familia: (lectores, escritores)} — para listar y para contar pares de verdad."""
+    d = {}
+    for n, (_, fam) in LECTORES.items():
+        d.setdefault(fam, ([], []))[0].append(n)
+    for n, (_, fam) in ESCRITORES.items():
+        d.setdefault(fam, ([], []))[1].append(n)
+    return {k: (sorted(v[0]), sorted(v[1])) for k, v in sorted(d.items())}
diff --git a/scripts/mudanza/formatos/apache.py b/scripts/mudanza/formatos/apache.py
index 8b1601f3..3fab2e4c 100644
--- a/scripts/mudanza/formatos/apache.py
+++ b/scripts/mudanza/formatos/apache.py
@@ -13,8 +13,8 @@ IRRELEVANTES = {"ServerRoot", "Listen", "LoadModule", "TypesConfig", "ErrorLog",
                 "LogLevel", "Timeout", "KeepAlive", "User", "Group", "PidFile", "ServerAdmin"}
 
 
-@lector("apache")
-def leer(texto):
+@lector("apache", familia="web")
+def leer(texto, nombre=""):
     cfg = Config(formato_origen="apache")
     A = cfg.acta
     sitio, ruta = None, None
diff --git a/scripts/mudanza/formatos/arje.py b/scripts/mudanza/formatos/arje.py
new file mode 100644
index 00000000..d1a13bb9
--- /dev/null
+++ b/scripts/mudanza/formatos/arje.py
@@ -0,0 +1,151 @@
+"""Escritor de tarjetas de arje ← modelo pivote «servicio» (SDD 29).
+
+Emite una **semilla** (`seed.card.json`): una card `Virtual` raíz cuyos `genesis` son los servicios.
+Es el mismo molde que `declarar.py` saca del censo — la diferencia es de dónde viene el dato: allá de
+`/proc` (lo que CORRE), acá de los units (lo que está DECLARADO). Se complementan, y ninguno de los
+dos solo alcanza: en gioser el init daba `stopped` para cinco servicios vivos.
+
+⚠ **LO QUE ESTE ESCRITOR NO PUEDE Y NO FINGE**
+
+El payload `Native` de arje acepta `exec`, `argv` y `envp` — **y nada más** (comprobado en la semilla
+real del producto, `crates/takana-bootstrap/src/lib.rs`). No hay campo de usuario. Por lo tanto un
+`User=www-data` no tiene traducción: la tarjeta correría el servicio **como root**. Eso no es perder
+una opción, es **escalar privilegios en un fichero generado que nadie vuelve a leer**, así que sale
+como SIN-TRADUCIR y el traductor termina con código ≠ 0.
+
+`Type=forking` es el otro: arje supervisa al proceso que lanza, y un demonio que se va a segundo
+plano y sale deja a su padre muerto a los milisegundos ⇒ **bucle de reinicio**. Casi siempre se
+arregla con la bandera de primer plano del propio demonio (`-g 'daemon off;'`, `-D`, `--foreground`),
+pero cuál es depende del demonio: decirlo es del humano, adivinarlo no.
+"""
+import json, shlex, sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from ids import ulid_determinista                          # noqa: E402
+from formatos import escritor                              # noqa: E402
+
+SEM = "takana-mudanza"
+
+
+def _tarjeta(srv, acta):
+    argv_all = shlex.split(srv.cmd) if srv.cmd else []
+    if not argv_all:
+        return None
+    if srv.cwd and srv.cwd not in ("/", ""):
+        # `Native` no tiene `cwd`: se envuelve, que es el idioma que la tarjeta de sshd ya usa.
+        exec_, argv = "/bin/sh", ["-c", f"cd {shlex.quote(srv.cwd)} && exec {srv.cmd}"]
+        acta.anota(srv.lineas.get("cwd", srv.origen), "decision", f"WorkingDirectory={srv.cwd}",
+                   "el payload `Native` no tiene `cwd`: se envuelve en `sh -c 'cd … && exec …'`")
+    else:
+        exec_, argv = argv_all[0], argv_all[1:]
+
+    if srv.usuario and srv.usuario != "root":
+        acta.anota(srv.lineas.get("usuario", srv.origen), "SIN-TRADUCIR", f"User={srv.usuario}",
+                   "el payload `Native` de arje NO tiene campo de usuario ⇒ esta tarjeta corre el "
+                   "servicio COMO ROOT. Hay que bajar el privilegio dentro del comando "
+                   "(p. ej. `setpriv --reuid`) antes de desplegarla")
+    if srv.grupo and srv.grupo != "root":
+        acta.anota(srv.lineas.get("grupo", srv.origen), "SIN-TRADUCIR", f"Group={srv.grupo}",
+                   "mismo motivo que `User=`: no hay dónde ponerlo en la tarjeta")
+
+    if srv.tipo == "forking":
+        acta.anota(srv.lineas.get("tipo", srv.origen), "SIN-TRADUCIR", f"Type=forking ({srv.nombre})",
+                   "arje supervisa al proceso que lanza: un demonio que se va a segundo plano sale "
+                   "enseguida y queda en BUCLE DE REINICIO. Hay que agregarle su bandera de primer "
+                   "plano, que depende del demonio")
+    elif srv.tipo in ("notify", "dbus"):
+        acta.anota(srv.lineas.get("tipo", srv.origen), "decision", f"Type={srv.tipo} ({srv.nombre})",
+                   f"arje no implementa el protocolo de `{srv.tipo}`: se trata como `simple`, o sea "
+                   "que se da por arrancado al ejecutarse y no cuando el servicio avisa que está listo")
+
+    # `oneshot` SÍ tiene destino: la semilla real del producto usa `\"supervision\": \"OneShot\"`.
+    if srv.tipo == "oneshot":
+        sup = "OneShot"
+    elif srv.reinicio == "no":
+        sup = "OneShot"
+        acta.anota(srv.lineas.get("reinicio", srv.origen), "decision", f"Restart=no ({srv.nombre})",
+                   "sin reinicio, la tarjeta se emite como `OneShot`: arje no lo revive si muere")
+    else:
+        ini = srv.reinicio_ms or 500
+        sup = {"Restart": {"initial": ini, "max": max(ini * 40, 20000)}}
+        if srv.reinicio == "si-falla":
+            acta.anota(srv.lineas.get("reinicio", srv.origen), "decision", f"Restart=si-falla ({srv.nombre})",
+                       "arje reinicia SIEMPRE, no sólo tras fallo: una salida limpia también lo revive")
+
+    if srv.entorno_ficheros:
+        acta.anota(srv.lineas.get("entorno_ficheros", srv.origen), "SIN-TRADUCIR", f"EnvironmentFile ({srv.nombre})",
+                   "no se copió ninguna variable de esos ficheros: el `envp` de la tarjeta sale "
+                   "INCOMPLETO y el servicio va a fallar tarde")
+
+    return {
+        "flow": {"input": [], "output": []},
+        "genesis": [],
+        "id": ulid_determinista(SEM, srv.nombre),
+        "label": srv.nombre,
+        "lifecycle": "daemon",
+        "lineage": None,
+        "payload": {"Native": {"argv": argv, "envp": [f"{k}={v}" for k, v in sorted(srv.entorno.items())],
+                               "exec": exec_}},
+        "permissions": {"filesystem": "read-write", "ipc": {"allow": []},
+                        "networking": "full", "processes": True},
+        "priority": "normal",
+        "provides": [],
+        # `After=`/`Requires=` se conservan por NOMBRE: arje resuelve el orden por su grafo, y los
+        # objetivos de systemd (`network.target`…) no existen acá — por eso se filtran los `.target`.
+        "requires": sorted({d.replace(".service", "") for d in (srv.tras + srv.necesita)
+                            if not d.endswith(".target")}),
+        "schema_version": 1,
+        "soma": {
+            "cgroup": {"cpu_weight": None, "io_weight": None, "path": f"arje.slice/{srv.nombre}"},
+            "cpu_affinity": None,
+            "namespaces": {k: False for k in
+                           ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")},
+            "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None},
+        },
+        "supervision": sup,
+        "_mudanza": {"desde": "systemd", "descripcion": srv.descripcion,
+                     "tipo_origen": srv.tipo, "cwd_original": srv.cwd,
+                     "usuario_original": srv.usuario, "arranque_auto": srv.arranque_auto},
+    }
+
+
+@escritor("arje", familia="servicio")
+def escribir(sis):
+    acta = sis.acta
+    hijas = []
+    for srv in sis.servicios:
+        if not srv.arranque_auto:
+            # Un unit instalado pero sin `WantedBy=` no lo levanta el arranque. Declararlo igual
+            # cambiaría el sistema: se emite, pero se dice.
+            acta.anota(srv.origen, "decision", f"{srv.nombre} (sin WantedBy=)",
+                       "el unit no se levanta al arrancar en el origen; la semilla SÍ lo va a "
+                       "levantar. Sacalo si no lo querés al boot")
+        t = _tarjeta(srv, acta)
+        if t is None:
+            acta.anota(srv.origen, "SIN-TRADUCIR", srv.nombre,
+                       "sin comando de arranque: NO se emite tarjeta. Una tarjeta que no arranca es "
+                       "peor que ninguna — la ausente falla ruidosamente al primer arranque")
+            continue
+        hijas.append(t)
+
+    raiz = {
+        "flow": {"input": [], "output": []},
+        "genesis": hijas,
+        "id": ulid_determinista(SEM + "-raiz", "mudanza"),
+        "label": "mudanza",
+        "lifecycle": "daemon",
+        "lineage": None,
+        "payload": "Virtual",
+        "permissions": {"filesystem": "read-write", "ipc": {"allow": []},
+                        "networking": "full", "processes": True},
+        "priority": "normal",
+        "provides": [],
+        "requires": [],
+        "schema_version": 1,
+        "soma": {"cgroup": {"cpu_weight": None, "io_weight": None, "path": "arje.slice"},
+                 "cpu_affinity": None,
+                 "namespaces": {k: False for k in
+                                ("cgroup", "ipc", "mount", "net", "pid", "user", "uts")},
+                 "rlimits": {"mem_bytes": None, "nofile": None, "nproc": None}},
+        "supervision": "OneShot",
+    }
+    return json.dumps(raiz, indent=1, sort_keys=True, ensure_ascii=False) + "\n"
diff --git a/scripts/mudanza/formatos/caddy.py b/scripts/mudanza/formatos/caddy.py
index 5e4d41d8..f3e8347b 100644
--- a/scripts/mudanza/formatos/caddy.py
+++ b/scripts/mudanza/formatos/caddy.py
@@ -30,7 +30,7 @@ def _emitir_acciones(acciones, ind):
     return L
 
 
-@escritor("caddy")
+@escritor("caddy", familia="web")
 def escribir(cfg):
     A = cfg.acta
     L = [f"# Generado por scripts/mudanza/traducir.py desde una configuración de {cfg.formato_origen}.",
diff --git a/scripts/mudanza/formatos/nginx.py b/scripts/mudanza/formatos/nginx.py
index 49df4af1..287daa66 100644
--- a/scripts/mudanza/formatos/nginx.py
+++ b/scripts/mudanza/formatos/nginx.py
@@ -41,8 +41,8 @@ def _tokenizar(texto):
     return out
 
 
-@lector("nginx")
-def leer(texto):
+@lector("nginx", familia="web")
+def leer(texto, nombre=""):
     cfg = Config(formato_origen="nginx")
     A = cfg.acta
     pila, sitio, ruta, opaco = [], None, None, 0
diff --git a/scripts/mudanza/formatos/systemd.py b/scripts/mudanza/formatos/systemd.py
new file mode 100644
index 00000000..4bf5d64b
--- /dev/null
+++ b/scripts/mudanza/formatos/systemd.py
@@ -0,0 +1,203 @@
+"""Lector de units de systemd → modelo pivote «servicio» (SDD 29).
+
+systemd es el init de la mayoría de los servidores de los que uno se muda, así que éste es el lector
+que más entradas abre. Pero un unit dice MUCHO más de lo que cualquier otro init sabe expresar, y
+ahí está todo el riesgo: traducir sólo `ExecStart=` y callarse el resto produce un servicio que
+arranca y **hace otra cosa**.
+
+⚠ Los tres campos donde una elisión silenciosa no pierde una opción sino que CAMBIA el sistema:
+
+  · `User=` / `Group=` — sin esto el servicio corre como root. No es «una opción menos»: es
+    escalación de privilegios en un fichero generado, que nadie vuelve a leer.
+  · `EnvironmentFile=` — el fichero no existe en la máquina donde se traduce, así que su contenido
+    no se puede ni mirar. Un servicio al que le falta medio entorno arranca y falla tarde.
+  · Los especificadores (`%i`, `%h`, `%n`…) y las plantillas `foo@.service` — no se resuelven sin
+    saber la instancia. Expandirlos «por lo más probable» es adivinar en silencio.
+
+Los tres van al acta como SIN-TRADUCIR, con su línea.
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from modelo import Servicio, Sistema                       # noqa: E402
+from formatos import lector                                # noqa: E402
+
+# `Restart=` de systemd → el vocabulario del pivote. `on-abnormal`/`on-watchdog`/`on-abort` son
+# matices de «cuándo cuenta como fallo» que ningún otro init distingue: se acercan a `si-falla` y
+# eso se ANOTA, no se hace callado.
+_REINICIO = {"no": "no", "always": "siempre", "on-failure": "si-falla", "on-success": "si-falla",
+             "on-abnormal": "si-falla", "on-watchdog": "si-falla", "on-abort": "si-falla"}
+_MATIZ = {"on-abnormal", "on-watchdog", "on-abort", "on-success"}
+
+# Prefijos de `ExecStart=`. Cada uno cambia la semántica del arranque y ninguno es parte del comando.
+_PREFIJOS = {"-": "ignorar el fallo de arranque", "@": "argv[0] distinto del ejecutable",
+             "+": "correr con privilegios completos", "!": "saltar la restricción de usuario",
+             ":": "no expandir variables de entorno"}
+
+
+def _seg(val):
+    """`RestartSec=` admite `5`, `5s`, `500ms`, `1min`. Devuelve milisegundos."""
+    v = val.strip().lower()
+    for suf, mult in (("ms", 1), ("min", 60000), ("s", 1000), ("m", 60000)):
+        if v.endswith(suf):
+            try:
+                return int(float(v[:-len(suf)]) * mult)
+            except ValueError:
+                return 0
+    try:
+        return int(float(v) * 1000)
+    except ValueError:
+        return 0
+
+
+def _entorno(val):
+    """`Environment="A=1" B=2` — comillas opcionales, varios pares por línea."""
+    import shlex
+    d = {}
+    try:
+        piezas = shlex.split(val)
+    except ValueError:
+        piezas = val.split()
+    for p in piezas:
+        if "=" in p:
+            k, _, v = p.partition("=")
+            d[k] = v
+    return d
+
+
+@lector("systemd", familia="servicio")
+def leer(texto, nombre=""):
+    sis = Sistema(formato_origen="systemd")
+    acta = sis.acta
+    base = os.path.basename(nombre or "unidad.service")
+    srv = Servicio(nombre=base[:-8] if base.endswith(".service") else base)
+    srv.plantilla = "@." in base
+    if srv.plantilla:
+        acta.anota(0, "SIN-TRADUCIR", base,
+                   "es una PLANTILLA (`nombre@.service`): sin saber la instancia, `%i` no se resuelve "
+                   "y el servicio traducido arrancaría con otro argumento")
+
+    seccion, arranques, extras = "", [], []
+    lineas, i = texto.splitlines(), 0
+    while i < len(lineas):
+        n = i + 1
+        cruda = lineas[i]
+        # Continuación de línea: systemd la usa muchísimo en ExecStart largos, y cortar ahí deja un
+        # comando TRUNCADO que además parece válido.
+        while cruda.rstrip().endswith("\\") and i + 1 < len(lineas):
+            i += 1
+            cruda = cruda.rstrip()[:-1] + " " + lineas[i].strip()
+        i += 1
+        ln = cruda.strip()
+        if not ln or ln[0] in "#;":
+            continue
+        if ln.startswith("[") and ln.endswith("]"):
+            seccion = ln[1:-1]
+            continue
+        if "=" not in ln:
+            acta.anota(n, "SIN-TRADUCIR", ln, "línea sin `clave=valor` y fuera de toda sección")
+            continue
+        k, _, v = ln.partition("=")
+        k, v = k.strip(), v.strip()
+
+        if k == "Description":
+            srv.descripcion = v
+            acta.anota(n, "traducido", ln)
+        elif k == "Type":
+            srv.tipo = v
+            srv.lineas["tipo"] = n
+            acta.anota(n, "traducido", ln)
+        elif k == "ExecStart":
+            pref = ""
+            while v and v[0] in _PREFIJOS:
+                pref += v[0]
+                v = v[1:].lstrip()
+            if pref:
+                acta.anota(n, "SIN-TRADUCIR", ln,
+                           "prefijo `%s` en ExecStart (%s): el pivote no lo modela y quitarlo "
+                           "cambia el arranque" % (pref, "; ".join(_PREFIJOS[c] for c in pref)))
+            arranques.append((n, v))
+        elif k in ("ExecStartPre", "ExecStartPost", "ExecStop", "ExecStopPost", "ExecReload",
+                   "ExecCondition"):
+            extras.append((n, k, v))
+        elif k == "WorkingDirectory":
+            srv.cwd = v
+            srv.lineas["cwd"] = n
+            acta.anota(n, "traducido", ln)
+        elif k in ("User", "Group"):
+            campo = "usuario" if k == "User" else "grupo"
+            setattr(srv, campo, v)
+            srv.lineas[campo] = n
+            # Sin anotación: si el destino sabe bajar de privilegio esto es `traducido`, y si no es
+            # SIN-TRADUCIR. Lo sabe el ESCRITOR. Que el lector opine acá duplica la entrada y la
+            # contradice — y un acta que se contradice se deja de leer.
+        elif k == "Environment":
+            srv.entorno.update(_entorno(v))
+            acta.anota(n, "traducido", ln)
+        elif k == "EnvironmentFile":
+            srv.entorno_ficheros.append(v)
+            srv.lineas["entorno_ficheros"] = n
+            acta.anota(n, "SIN-TRADUCIR", ln,
+                       "el fichero de entorno NO está en esta máquina: su contenido no se puede "
+                       "leer ni copiar, y el servicio arrancaría con medio entorno")
+        elif k == "Restart":
+            if v in _MATIZ:
+                acta.anota(n, "decision", ln,
+                           f"`{v}` es un matiz de «cuándo cuenta como fallo» que ningún otro init "
+                           "distingue; se traduce como «si-falla»")
+            elif v not in _REINICIO:
+                acta.anota(n, "SIN-TRADUCIR", ln, f"valor de Restart no reconocido: `{v}`")
+            else:
+                acta.anota(n, "traducido", ln)
+            srv.reinicio = _REINICIO.get(v, "si-falla")
+            srv.lineas["reinicio"] = n
+        elif k == "RestartSec":
+            srv.reinicio_ms = _seg(v)
+            acta.anota(n, "traducido", ln)
+        elif k == "After":
+            srv.tras += v.split()
+            acta.anota(n, "traducido", ln)
+        elif k in ("Requires", "BindsTo"):
+            srv.necesita += v.split()
+            acta.anota(n, "traducido", ln)
+        elif k == "WantedBy":
+            srv.arranque_auto = True
+            acta.anota(n, "traducido", ln)
+        elif k in ("Wants", "Before", "Conflicts", "PartOf", "RequiredBy", "Also",
+                   "DefaultDependencies", "StartLimitIntervalSec", "StartLimitBurst"):
+            acta.anota(n, "no-aplica", ln, "orden/relación que el destino resuelve por el grafo")
+        elif k in ("StandardOutput", "StandardError", "SyslogIdentifier"):
+            acta.anota(n, "no-aplica", ln, "el destino captura la salida por su propio journal")
+        elif k in ("Documentation", "RemainAfterExit", "KillMode", "KillSignal", "TimeoutStopSec",
+                   "TimeoutStartSec", "PIDFile", "Alias"):
+            acta.anota(n, "no-aplica", ln, f"`{k}` es específico de systemd")
+        elif seccion == "Service":
+            # Todo el endurecimiento (`ProtectSystem`, `NoNewPrivileges`, `CapabilityBoundingSet`…)
+            # cae acá. Callarlo sería entregar un servicio MENOS confinado que el original.
+            acta.anota(n, "SIN-TRADUCIR", ln,
+                       f"`{k}` no se modela; si es confinamiento, el servicio traducido corre con "
+                       "MÁS permisos que el original")
+        else:
+            acta.anota(n, "SIN-TRADUCIR", ln, f"`{k}` en [{seccion or 'sin sección'}] no reconocida")
+
+    if not arranques:
+        acta.anota(0, "SIN-TRADUCIR", base, "el unit no tiene `ExecStart=`: no hay qué arrancar")
+    else:
+        srv.cmd = arranques[0][1]
+        srv.origen = arranques[0][0]
+        acta.anota(srv.origen, "traducido", f"ExecStart={srv.cmd}")
+        for n, v in arranques[1:]:
+            acta.anota(n, "SIN-TRADUCIR", f"ExecStart={v}",
+                       "systemd admite varios ExecStart en `Type=oneshot`; el pivote modela UNO")
+    for n, k, v in extras:
+        acta.anota(n, "SIN-TRADUCIR", f"{k}={v}",
+                   f"`{k}` es una fase del ciclo de vida aparte del arranque; hay que portarla a mano")
+
+    for especificador in ("%i", "%I", "%n", "%N", "%h", "%H", "%f", "%p", "%t"):
+        if especificador in srv.cmd:
+            acta.anota(0, "SIN-TRADUCIR", srv.cmd,
+                       f"el comando usa el especificador `{especificador}`, que systemd expande en "
+                       "tiempo de arranque; resolverlo acá sería adivinar")
+            break
+
+    sis.servicios.append(srv)
+    return sis
diff --git a/scripts/mudanza/ids.py b/scripts/mudanza/ids.py
new file mode 100644
index 00000000..2767cefe
--- /dev/null
+++ b/scripts/mudanza/ids.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+"""ids.py — identificadores DETERMINISTAS para lo que se genera (SDD 29).
+
+Derivar el id del nombre en vez de sortearlo es lo que vuelve **revisable** un fichero generado: dos
+corridas del mismo censo producen la misma semilla byte a byte y se comparan con `diff`. Un id
+aleatorio obliga a leer el fichero entero para saber si algo cambió.
+"""
+import hashlib
+
+CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"   # sin I, L, O, U
+
+
+def ulid_determinista(sem, nombre):
+    """26 caracteres Crockford base32, derivados del nombre. No es un ULID con marca de tiempo real
+    —no hace falta— pero respeta el formato."""
+    h = hashlib.blake2b(f"{sem}/{nombre}".encode(), digest_size=16).digest()
+    n = int.from_bytes(h, "big")
+    out = []
+    for _ in range(26):
+        out.append(CROCKFORD[n & 31])
+        n >>= 5
+    s = "".join(reversed(out))
+    return "0" + s[1:]          # el primer carácter de un ULID válido es <= '7'
diff --git a/scripts/mudanza/modelo.py b/scripts/mudanza/modelo.py
index 3efde8fb..a72c1933 100644
--- a/scripts/mudanza/modelo.py
+++ b/scripts/mudanza/modelo.py
@@ -77,3 +77,45 @@ class Config:
     formato_origen: str = ""
     sitios: list = field(default_factory=list)
     acta: Acta = field(default_factory=Acta)
+
+
+# ── FAMILIA «servicio» ──────────────────────────────────────────────────────────────────────────
+# ⚠ El pivote NO es uno solo: es uno POR FAMILIA. Un unit de systemd no es un sitio web, y meterlo a
+# la fuerza en `Sitio`/`Ruta` sería justo el «parecerse» que el acta existe para evitar. Cada familia
+# tiene su modelo, y el registro sólo empareja lectores con escritores de la MISMA familia — un par
+# cruzado se rechaza ruidosamente en vez de producir basura plausible.
+
+
+@dataclass
+class Servicio:
+    """Un proceso supervisado. El denominador común de systemd, OpenRC, runit, s6 y arje.
+
+    `usuario` se conserva aunque el destino no siempre sepa emitirlo: es el campo donde una elisión
+    silenciosa **escala privilegios** en vez de sólo perder una opción."""
+    nombre: str = ""
+    descripcion: str = ""
+    tipo: str = "simple"                             # simple | forking | oneshot | notify | dbus
+    cmd: str = ""                                    # la línea de arranque, tal cual
+    cwd: str = ""
+    usuario: str = ""
+    grupo: str = ""
+    entorno: dict = field(default_factory=dict)
+    entorno_ficheros: list = field(default_factory=list)   # no se pueden leer al traducir
+    tras: list = field(default_factory=list)         # orden (After=)
+    necesita: list = field(default_factory=list)     # dependencia dura (Requires=)
+    reinicio: str = "no"                             # no | siempre | si-falla
+    reinicio_ms: int = 0
+    arranque_auto: bool = False                      # ¿lo levanta el arranque? (WantedBy=)
+    plantilla: bool = False                          # `foo@.service`: no instanciable a ciegas
+    origen: int = 0
+    # {campo: línea} — el lector anota DÓNDE vio cada cosa para que el escritor pueda señalar la
+    # línea real en vez de un 0. Un acta que dice «línea 0» obliga a buscar a mano lo que ya se sabía.
+    lineas: dict = field(default_factory=dict)
+
+
+@dataclass
+class Sistema:
+    """Lo que produce un lector de la familia «servicio» y consume su escritor."""
+    formato_origen: str = ""
+    servicios: list = field(default_factory=list)
+    acta: Acta = field(default_factory=Acta)
diff --git a/scripts/mudanza/traducir.py b/scripts/mudanza/traducir.py
index 53d2ee90..1b4397e8 100755
--- a/scripts/mudanza/traducir.py
+++ b/scripts/mudanza/traducir.py
@@ -12,11 +12,16 @@ escritores**: un formato nuevo cuesta uno o dos plugins y estrena todos los pare
 Probado, no argumentado: el lector de apache se agregó **sin tocar el escritor de Caddy**, y con eso
 `apache → caddy` funciona sin que exista ningún traductor apache→caddy.
 
-── LOS PLUGINS ────────────────────────────────────────────────────────────────────────────────
+── LOS PLUGINS, Y LA FAMILIA ───────────────────────────────────────────────────────────────────
 Un formato se agrega dejando un fichero en `formatos/` con `@lector("x")` y/o `@escritor("y")`. El
 centro los DESCUBRE: no hay una lista que editar, porque una lista que hay que mantener a mano se
 desincroniza y el formato nuevo «no existe» sin que nada falle.
 
+⚠ El pivote es **uno por FAMILIA**, y eso no es taxonomía: un unit de systemd no es un sitio web, y
+un par cruzado produciría basura *plausible*. El centro lo rechaza con un error, no con un intento.
+Hoy hay dos familias — `web` (nginx, apache → caddy) y `servicio` (systemd → arje) — y agregar una
+tercera no toca nada de lo escrito.
+
 ── DOCTRINA HEREDADA DE `soltar`/`paskaq` (tawasuyu) ───────────────────────────────────────────
 `soltar` desencadena datos presos en formatos cautivos. Otro dominio, mismos principios:
 **elisión honesta** (lo no traducido se reporta con línea y motivo), **no adivinar en silencio**,
@@ -26,8 +31,9 @@ antes de llegar al escritor, y el fichero que uno despliega no lo diría.
 
 Uso:
     scripts/mudanza/traducir.py --list
-    scripts/mudanza/traducir.py --from nginx  --to caddy --in nginx.conf --out Caddyfile
-    scripts/mudanza/traducir.py --from apache --to caddy --in httpd.conf --out Caddyfile
+    scripts/mudanza/traducir.py --from nginx   --to caddy --in nginx.conf --out Caddyfile
+    scripts/mudanza/traducir.py --from apache  --to caddy --in httpd.conf --out Caddyfile
+    scripts/mudanza/traducir.py --from systemd --to arje  --in /etc/systemd/system --out seed.card.json
 """
 import argparse, os, sys
 
@@ -62,35 +68,80 @@ def imprimir_acta(acta):
     return n
 
 
+def _fusionar(a, b):
+    """Junta dos modelos de la MISMA familia: extiende cada campo lista y concatena el acta.
+
+    Genérico a propósito — vale para `Config` y para `Sistema` sin que el centro sepa de ninguno de
+    los dos. Un directorio de units es la entrada NORMAL de systemd, no un caso raro."""
+    import dataclasses
+    if a is None:
+        return b
+    for f in dataclasses.fields(a):
+        va, vb = getattr(a, f.name), getattr(b, f.name)
+        if isinstance(va, list):
+            va.extend(vb)
+    a.acta.entradas.extend(b.acta.entradas)
+    return a
+
+
+def _entradas(ruta):
+    """Un fichero, o todos los `*.service`/`*.conf` de un directorio, en orden determinista."""
+    if os.path.isdir(ruta):
+        hs = sorted(f for f in os.listdir(ruta)
+                    if f.endswith((".service", ".conf")) and
+                    os.path.isfile(os.path.join(ruta, f)))
+        if not hs:
+            sys.exit(f"✗ {ruta} no tiene ningún `*.service` ni `*.conf`")
+        return [os.path.join(ruta, h) for h in hs]
+    return [ruta]
+
+
 def main():
     lectores, escritores = formatos.cargar()
+    fams = formatos.familias()
     ap = argparse.ArgumentParser(description="Centro de traducción de configuraciones (pivote + plugins).")
     ap.add_argument("--list", action="store_true", help="listar formatos disponibles y los pares que habilitan")
     ap.add_argument("--from", dest="desde", choices=sorted(lectores))
     ap.add_argument("--to", dest="hacia", choices=sorted(escritores))
-    ap.add_argument("--in", dest="entrada")
+    ap.add_argument("--in", dest="entrada", help="fichero, o directorio (se leen sus *.service/*.conf)")
     ap.add_argument("--out", dest="salida")
     a = ap.parse_args()
 
     if a.list:
-        print(f"\n══ formatos registrados ══")
-        print(f"   lectores   ({len(lectores)}): {', '.join(sorted(lectores))}")
-        print(f"   escritores ({len(escritores)}): {', '.join(sorted(escritores))}")
-        n, m = len(lectores), len(escritores)
-        print(f"\n   pares habilitados: {n}×{m} = {n*m}, con {n+m} plugin(s).")
-        print(f"   De a pares harían falta {n*m} traductores, y cada formato nuevo agregaría {n+m}.")
-        for le in sorted(lectores):
-            for es in sorted(escritores):
-                if le != es:
-                    print(f"     {le} → {es}")
+        print("\n══ formatos registrados ══")
+        tot_l, tot_e, pares = 0, 0, 0
+        for fam, (le, es) in fams.items():
+            n, m = len(le), len(es)
+            tot_l, tot_e, pares = tot_l + n, tot_e + m, pares + n * m
+            print(f"\n── familia «{fam}» " + "─" * 40)
+            print(f"   lectores   ({n}): {', '.join(le) or '—'}")
+            print(f"   escritores ({m}): {', '.join(es) or '—'}")
+            for x in le:
+                for y in es:
+                    if x != y:
+                        print(f"     {x} → {y}")
+        print(f"\n   {pares} par(es) habilitados con {tot_l + tot_e} plugin(s).")
+        print(f"   De a pares harían falta {pares} traductores, y cada formato nuevo agregaría "
+              f"tantos como formatos tenga ya su familia.")
+        print("   ⚠ Los pares se cuentan DENTRO de cada familia: un lector de servicios contra un")
+        print("     escritor de web no es un par, es basura plausible. El centro lo rechaza.")
         return
 
     if not (a.desde and a.hacia and a.entrada):
         ap.error("hacen falta --from, --to y --in (o --list)")
 
-    cfg = lectores[a.desde](open(a.entrada).read())
-    texto = escritores[a.hacia](cfg)          # el escritor puede AÑADIR al acta: se imprime después
-    n = imprimir_acta(cfg.acta)
+    fl, fe = lectores[a.desde][1], escritores[a.hacia][1]
+    if fl != fe:
+        sys.exit(f"✗ `{a.desde}` es de la familia «{fl}» y `{a.hacia}` de la familia «{fe}».\n"
+                 f"  No es un par: traducir entre familias distintas daría un fichero que PARECE\n"
+                 f"  correcto. Mirá `--list` para los pares que sí existen.")
+
+    modelo = None
+    for ruta in _entradas(a.entrada):
+        with open(ruta) as fh:
+            modelo = _fusionar(modelo, lectores[a.desde][0](fh.read(), ruta))
+    texto = escritores[a.hacia][0](modelo)    # el escritor puede AÑADIR al acta: se imprime después
+    n = imprimir_acta(modelo.acta)
     if a.salida:
         open(a.salida, "w").write(texto)
         print(f"\n   {a.hacia} → {a.salida}")