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…

TOML 1182Shell 87Rust 63Markdown 541592 Sutichaykuna

traducir: CENTRO de traducción con modelo pivote y plugins — N+M en vez de N×M

Traducir de a pares no escala: con nginx, apache, caddy, haproxy y lighttpd son 20 traductores, y
cada formato nuevo agrega 2N. Con un modelo intermedio son N lectores + M escritores: un formato
nuevo cuesta uno o dos plugins y estrena todos los pares de golpe.

**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 de ese par. Las dos salidas las valida el
caddy del propio corpus: `Valid configuration` en las dos.

Los plugins se DESCUBREN (`formatos/` + `@lector` / `@escritor`): una lista mantenida a mano se
desincroniza y el formato nuevo «no existe» sin que nada falle. `--list` dice qué pares habilita.

**El acta viaja DENTRO del modelo**, y es la decisión que hace que el pivote no mienta: si fuera un
efecto de cada traductor, lo que el LECTOR no entendió se perdería antes de llegar al escritor. El
escritor además puede agregarle — Caddy no tiene equivalente de `location ~`, así que en vez de
emitir un `handle` de prefijo que SE PARECE, lo manda al acta. Parecerse es peor que faltar.

Y un bug del lector de apache, encontrado probando: `ProxyPass` tiene dos formas y dentro de
`<Location>` lleva sólo la URL. Mirar sólo la de tres tokens dejaba sin traducir la forma más común y
el acta la reportaba como «no reconocida» — un falso negativo que manda a escribir a mano algo que el
lector sí sabe hacer.

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

3137d47f165b900042653af6db5513180fe471c8 · ruwaq Sergio · (1 killa ñawpaq) · tayta 030d728fa · kay commitpi sach'ata qhaway

Semántico sayk'uy · 24

scripts/mudanza/formatos/__init__.py

scripts/mudanza/formatos/apache.py

scripts/mudanza/formatos/caddy.py

scripts/mudanza/formatos/nginx.py

scripts/mudanza/modelo.py

scripts/mudanza/traducir.py

7 qillqakuna · +532 −237

diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md
index 79f7ad17..5ff8c8f9 100644
--- a/docs/29-mudanza.md
+++ b/docs/29-mudanza.md
@@ -301,11 +301,47 @@ reconstruyen el servidor desde cero**:
 Ninguno de los tres es un log de lo que se hizo: los tres son entradas que se vuelven a ejecutar.
 Ésa es la diferencia entre mudar un servidor y poder mudarlo otra vez.
 
-### 4.0 quinquies · Traducir la configuración — `traducir.py`
+### 4.0 quinquies · El CENTRO de traducción — pivote y plugins
 
 Cambiar de nginx a caddy no es cambiar un binario: es **reescribir la configuración**. `traducir.py`
 hace la parte mecánica y —sobre todo— **dice con número de línea lo que NO pudo traducir**.
 
+#### ⚠ De a pares el trabajo crece en N×M — por eso hay un modelo PIVOTE
+
+Un traductor por par no escala: con nginx, apache, caddy, haproxy y lighttpd son **20 traductores**,
+y cada formato nuevo agrega 2N. Con un modelo intermedio son **N lectores + M escritores**: un
+formato nuevo cuesta uno o dos plugins y **estrena todos los pares de golpe**.
+
+```
+  formatos/nginx.py   ─┐                      ┌─►  formatos/caddy.py
+  formatos/apache.py  ─┼─►  modelo pivote  ───┤
+  (el próximo)        ─┘    Config/Sitio/Ruta └─►  (el próximo)
+```
+
+**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. Las dos salidas las
+valida el caddy del propio corpus:
+
+```
+apache → caddy :  Valid configuration
+nginx  → caddy :  Valid configuration
+```
+
+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 mantenida a mano se
+desincroniza y el formato nuevo «no existe» sin que nada falle. `traducir.py --list` dice qué pares
+habilita y cuántos plugins costaron.
+
+#### 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
+un **lector** no entendió se perdería antes de llegar al escritor. Viajando en el modelo, el escritor
+la emite como comentario al final del fichero — y el humano la ve en lo que va a desplegar.
+
+Y el escritor puede **agregarle**: Caddy no tiene equivalente directo de `location ~`, así que en vez
+de emitir un `handle` de prefijo —que *se parece* y no es lo mismo— lo manda al acta. **Parecerse es
+peor que faltar.**
+
 #### Doctrina heredada de `soltar`/`paskaq` (tawasuyu)
 
 `soltar` desencadena datos presos en formatos cautivos (Btrieve, Paradox, `.mdb`). El dominio es otro
diff --git a/scripts/mudanza/formatos/__init__.py b/scripts/mudanza/formatos/__init__.py
new file mode 100644
index 00000000..03fe2fdb
--- /dev/null
+++ b/scripts/mudanza/formatos/__init__.py
@@ -0,0 +1,37 @@
+"""Registro de PLUGINS de formato (SDD 29).
+
+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")
+    def leer(texto) -> Config: ...
+
+    @escritor("caddy")
+    def escribir(cfg) -> str: ...
+"""
+import importlib, pkgutil, pathlib
+
+LECTORES, ESCRITORES = {}, {}
+
+
+def lector(nombre):
+    def deco(f):
+        LECTORES[nombre] = f
+        return f
+    return deco
+
+
+def escritor(nombre):
+    def deco(f):
+        ESCRITORES[nombre] = f
+        return f
+    return deco
+
+
+def cargar():
+    """Importa todos los plugins del directorio. Descubrimiento, no lista cableada: una lista que hay
+    que editar a mano se desincroniza y el formato nuevo 'no existe' sin que nada falle."""
+    for m in pkgutil.iter_modules([str(pathlib.Path(__file__).parent)]):
+        if not m.name.startswith("_"):
+            importlib.import_module(f"{__name__}.{m.name}")
+    return LECTORES, ESCRITORES
diff --git a/scripts/mudanza/formatos/apache.py b/scripts/mudanza/formatos/apache.py
new file mode 100644
index 00000000..8b1601f3
--- /dev/null
+++ b/scripts/mudanza/formatos/apache.py
@@ -0,0 +1,106 @@
+"""Plugin de formato: LECTOR de Apache httpd (SDD 29).
+
+Existe para PROBAR que el pivote paga: con este fichero, `apache → caddy` funciona **sin haber
+escrito un traductor apache→caddy**. El escritor de Caddy no se tocó. Ése es el argumento entero
+contra traducir de a pares — N×M contra N+M.
+"""
+import os, re, sys
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from modelo import Config, Sitio, Ruta, Accion          # noqa: E402
+from formatos import lector                             # noqa: E402
+
+IRRELEVANTES = {"ServerRoot", "Listen", "LoadModule", "TypesConfig", "ErrorLog", "CustomLog",
+                "LogLevel", "Timeout", "KeepAlive", "User", "Group", "PidFile", "ServerAdmin"}
+
+
+@lector("apache")
+def leer(texto):
+    cfg = Config(formato_origen="apache")
+    A = cfg.acta
+    sitio, ruta = None, None
+
+    for ln, linea in enumerate(texto.split("\n"), 1):
+        s = linea.split("#")[0].strip()
+        if not s:
+            continue
+        m = re.match(r'<(/?)(\w+)([^>]*)>', s)
+        if m:
+            cierra, tag, arg = m.group(1), m.group(2), m.group(3).strip()
+            if tag == "VirtualHost":
+                if cierra:
+                    if sitio:
+                        cfg.sitios.append(sitio); sitio = None
+                else:
+                    sitio = Sitio(origen=ln)
+                    puerto = arg.split(":")[-1].strip('"> ')
+                    if puerto.isdigit():
+                        sitio.puertos.append(puerto)
+                    A.anota(ln, "traducido", s)
+            elif tag in ("Directory", "Location", "LocationMatch", "DirectoryMatch"):
+                if cierra:
+                    if sitio and ruta:
+                        sitio.rutas.append(ruta); ruta = None
+                else:
+                    clase = "regex" if tag.endswith("Match") else "prefijo"
+                    ruta = Ruta(patron=arg.strip('"'), clase=clase, origen=ln)
+            elif tag in ("IfModule", "Files", "FilesMatch", "Limit"):
+                A.anota(ln, "SIN-TRADUCIR", s,
+                        f"`<{tag}>` no tiene traducción mecánica: su efecto depende de qué módulos "
+                        f"están cargados y del orden")
+            continue
+
+        p = s.split()
+        cab = p[0] if p else ""
+        destino = ruta if ruta is not None else sitio
+        if sitio is None:
+            if cab in IRRELEVANTES:
+                A.anota(ln, "no-aplica", s, "ajuste global del proceso httpd")
+            else:
+                A.anota(ln, "SIN-TRADUCIR", s, "directiva global no reconocida")
+            continue
+
+        if cab in ("ServerName", "ServerAlias"):
+            sitio.nombres += [x.strip('"') for x in p[1:]]
+            A.anota(ln, "traducido", s)
+        elif cab == "DocumentRoot":
+            sitio.raiz = p[1].strip('"') if len(p) > 1 else ""
+            A.anota(ln, "traducido", s)
+        elif cab == "DirectoryIndex":
+            sitio.indices = p[1:]
+            A.anota(ln, "traducido", s)
+        elif cab == "SSLCertificateFile":
+            sitio.tls["cert"] = p[1].strip('"') if len(p) > 1 else ""
+            A.anota(ln, "traducido", s)
+        elif cab == "SSLCertificateKeyFile":
+            sitio.tls["key"] = p[1].strip('"') if len(p) > 1 else ""
+            A.anota(ln, "traducido", s)
+        elif cab in ("ProxyPass", "ProxyPassReverse") and len(p) > 1:
+            # ⚠ `ProxyPass` tiene DOS formas y la diferencia importa: dentro de un `<Location>` lleva
+            # sólo la URL (`ProxyPass http://…`), fuera lleva ruta y URL (`ProxyPass /api http://…`).
+            # Mirar sólo la de tres tokens dejaba sin traducir justo la forma que más se usa, y el
+            # acta lo reportaba como «directiva no reconocida» — un falso negativo que manda a
+            # escribir a mano algo que el lector sí sabe hacer.
+            url = p[2].strip('"') if len(p) > 2 else p[1].strip('"')
+            if cab == "ProxyPassReverse":
+                A.anota(ln, "no-aplica", s, "el destino reescribe las cabeceras del proxy por su cuenta")
+            else:
+                if len(p) > 2 and ruta is None:
+                    destino.rutas.append(Ruta(patron=p[1].strip('"'), clase="prefijo", origen=ln))
+                    destino.rutas[-1].acciones.append(Accion("proxy", url))
+                else:
+                    destino.acciones.append(Accion("proxy", url))
+                A.anota(ln, "traducido", s)
+        elif cab == "Redirect" and len(p) > 1:
+            code = p[1] if p[1].isdigit() else "302"
+            dest = p[-1]
+            destino.acciones.append(Accion("redir", f"{code} {dest}"))
+            A.anota(ln, "traducido", s)
+        elif cab in ("RewriteEngine", "RewriteCond", "RewriteRule"):
+            A.anota(ln, "SIN-TRADUCIR", s,
+                    "mod_rewrite no tiene traducción mecánica: es un lenguaje propio con estado "
+                    "entre reglas")
+        elif cab in ("Options", "AllowOverride", "Require", "Order", "Allow", "Deny"):
+            A.anota(ln, "decision", s, "control de acceso o de listado: el destino lo expresa distinto")
+        else:
+            A.anota(ln, "SIN-TRADUCIR", s, "directiva no reconocida por este lector")
+    return cfg
diff --git a/scripts/mudanza/formatos/caddy.py b/scripts/mudanza/formatos/caddy.py
new file mode 100644
index 00000000..5e4d41d8
--- /dev/null
+++ b/scripts/mudanza/formatos/caddy.py
@@ -0,0 +1,74 @@
+"""Plugin de formato: ESCRITOR de Caddy (SDD 29)."""
+import os, sys
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from formatos import escritor                           # noqa: E402
+
+
+def _direcciones(s):
+    """En nginx dos `server` con el MISMO nombre se distinguen por su `listen`; en Caddy, por el
+    ESQUEMA de la dirección. Sin traducir eso, los bloques colisionan y caddy rechaza el fichero
+    entero con «ambiguous site definition» — encontrado validando con el binario real, no leyendo."""
+    solo_80 = bool(s.puertos) and all(p == "80" for p in s.puertos)
+    pref = "http://" if solo_80 else ""
+    return ", ".join(pref + n for n in s.nombres) or (":80" if solo_80 else ":443")
+
+
+def _emitir_acciones(acciones, ind):
+    L = []
+    for a in acciones:
+        if a.tipo == "proxy":
+            L.append(f"{ind}reverse_proxy {a.valor.rstrip('/').replace('http://','').replace('https://','')}")
+        elif a.tipo == "archivos":
+            L.append(f"{ind}root * {a.valor}")
+        elif a.tipo == "intentar":
+            L.append(f"{ind}try_files {a.valor}")
+        elif a.tipo == "redir":
+            p = a.valor.split()
+            code = p[0] if p and p[0].isdigit() else ""
+            dest = " ".join(p[1:]) if code else a.valor
+            L.append(f"{ind}redir {dest} {code}".rstrip() if dest else f"{ind}respond {code}")
+    return L
+
+
+@escritor("caddy")
+def escribir(cfg):
+    A = cfg.acta
+    L = [f"# Generado por scripts/mudanza/traducir.py desde una configuración de {cfg.formato_origen}.",
+         "# ⚠ NO es una traducción completa. Abajo va el ACTA de lo que NO se pudo traducir, con su",
+         "#   línea de origen. Desplegar esto sin resolverlo es desplegar un servidor incompleto.",
+         ""]
+    for s in cfg.sitios:
+        L.append(f"{_direcciones(s)} {{")
+        L.append(f"\t# desde la línea {s.origen} del {cfg.formato_origen} original")
+        if s.tls.get("cert"):
+            L.append(f"\ttls {s.tls.get('cert','')} {s.tls.get('key','')}")
+        if s.raiz:
+            L.append(f"\troot * {s.raiz}")
+        L += _emitir_acciones(s.acciones, "\t")
+        for r in s.rutas:
+            if r.clase == "regex":
+                # Caddy no tiene un equivalente directo de `location ~`. Se DECLARA en el acta en vez
+                # de emitir algo parecido: un `handle` de prefijo no es lo mismo que una regex, y
+                # parecerse es peor que faltar.
+                A.anota(r.origen, "SIN-TRADUCIR", f"ruta por regex: {r.patron}",
+                        "Caddy no tiene equivalente directo de `location ~`; se escribe con "
+                        "`@matcher path_regexp` y hay que revisar el orden de evaluación")
+                continue
+            cuerpo = _emitir_acciones(r.acciones, "\t\t")
+            if cuerpo:
+                L.append(f"\thandle {r.patron} {{    # línea {r.origen}")
+                L += cuerpo
+                L.append("\t}")
+        if s.raiz:
+            L.append("\tfile_server")
+        L.append("}")
+        L.append("")
+
+    pend = A.sin_traducir()
+    if pend:
+        L.append("# ══ ACTA: lo que NO se tradujo — hay que resolverlo a mano ══")
+        for e in pend:
+            L.append(f"#   línea {e['linea']:>4}  {e['directiva'][:70]}")
+            if e["motivo"]:
+                L.append(f"#             └─ {e['motivo'][:96]}")
+    return "\n".join(L) + "\n"
diff --git a/scripts/mudanza/formatos/nginx.py b/scripts/mudanza/formatos/nginx.py
new file mode 100644
index 00000000..49df4af1
--- /dev/null
+++ b/scripts/mudanza/formatos/nginx.py
@@ -0,0 +1,131 @@
+"""Plugin de formato: LECTOR de nginx (SDD 29)."""
+import os, sys
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from modelo import Config, Sitio, Ruta, Accion          # noqa: E402
+from formatos import lector                             # noqa: E402
+
+# Bloques cuya semántica depende del orden de evaluación o del contexto: no se traducen, y —esto es
+# lo importante— TODO lo de adentro tampoco. Un `return 403` sacado de su `if` deja de ser
+# condicional y pasa a aplicar siempre: no se pierde información, se CAMBIA el sentido.
+OPACOS = {"map", "if", "stream", "upstream", "geo", "split_clients"}
+
+# Ajustes del proceso nginx que no tienen ni necesitan equivalente.
+GLOBALES_IRRELEVANTES = {
+    "worker_processes", "worker_connections", "user", "pid", "include", "sendfile",
+    "keepalive_timeout", "gzip", "default_type", "log_format", "types_hash_max_size",
+    "tcp_nopush", "multi_accept", "server_tokens", "charset",
+}
+
+
+def _tokenizar(texto):
+    out, nivel, buf, ln_ini = [], 0, "", 0
+    for ln, linea in enumerate(texto.split("\n"), 1):
+        s = linea.split("#")[0].strip()
+        if not s:
+            continue
+        if not buf:
+            ln_ini = ln
+        for ch in s:
+            if ch == "{":
+                out.append((ln_ini, nivel, buf.strip(), "abre")); nivel += 1; buf = ""
+            elif ch == "}":
+                if buf.strip():
+                    out.append((ln_ini, nivel, buf.strip(), "dir"))
+                nivel -= 1; out.append((ln_ini, nivel, "", "cierra")); buf = ""
+            elif ch == ";":
+                out.append((ln_ini, nivel, buf.strip(), "dir")); buf = ""
+            else:
+                buf += ch
+        if buf and not buf.endswith(" "):
+            buf += " "
+    return out
+
+
+@lector("nginx")
+def leer(texto):
+    cfg = Config(formato_origen="nginx")
+    A = cfg.acta
+    pila, sitio, ruta, opaco = [], None, None, 0
+
+    for ln, _niv, txt, tipo in _tokenizar(texto):
+        cab = txt.split()[0] if txt else ""
+
+        if tipo == "abre":
+            pila.append(cab)
+            if cab in OPACOS:
+                opaco += 1
+                A.anota(ln, "SIN-TRADUCIR", txt + " {",
+                        f"`{cab}` depende del orden de evaluación y del contexto; TODO lo de adentro "
+                        f"queda sin traducir — sacarlo del bloque le cambiaría el sentido")
+            elif cab == "server" and not opaco:
+                sitio = Sitio(origen=ln)
+            elif cab == "location" and not opaco:
+                p = txt.split()
+                if len(p) > 2:   # modificador: ~ ~* ^~ =
+                    clase = {"~": "regex", "~*": "regex", "=": "exacto", "^~": "prefijo"}.get(p[1], "regex")
+                    ruta = Ruta(patron=p[2], clase=clase, origen=ln)
+                else:
+                    ruta = Ruta(patron=p[1] if len(p) > 1 else "/", clase="prefijo", origen=ln)
+            continue
+
+        if tipo == "cierra":
+            c = pila.pop() if pila else ""
+            if c in OPACOS and opaco:
+                opaco -= 1
+            elif c == "location" and sitio is not None and ruta is not None:
+                sitio.rutas.append(ruta); ruta = None
+            elif c == "server" and sitio is not None:
+                cfg.sitios.append(sitio); sitio = None
+            continue
+
+        if opaco:
+            A.anota(ln, "SIN-TRADUCIR", txt,
+                    "está dentro de un bloque que no se pudo traducir: sacarla de ahí dejaría de ser "
+                    "condicional")
+            continue
+
+        p = txt.split()
+        destino = ruta if ruta is not None else sitio
+        if sitio is None:
+            if cab in GLOBALES_IRRELEVANTES or cab.endswith("_log"):
+                A.anota(ln, "no-aplica", txt, "ajuste global del proceso nginx, sin equivalente necesario")
+            elif txt:
+                A.anota(ln, "SIN-TRADUCIR", txt, "directiva global no reconocida")
+            continue
+
+        if cab == "server_name":
+            sitio.nombres += [x for x in p[1:] if x != "_"]
+            A.anota(ln, "traducido", txt)
+        elif cab == "listen":
+            sitio.puertos.append(p[1].split(":")[-1] if len(p) > 1 else "")
+            A.anota(ln, "traducido", txt)
+        elif cab == "root":
+            if ruta is not None:
+                ruta.acciones.append(Accion("archivos", p[1] if len(p) > 1 else ""))
+            else:
+                sitio.raiz = p[1] if len(p) > 1 else ""
+            A.anota(ln, "traducido", txt)
+        elif cab == "index":
+            sitio.indices = p[1:]
+            A.anota(ln, "traducido", txt)
+        elif cab in ("ssl_certificate", "ssl_certificate_key"):
+            sitio.tls["cert" if cab == "ssl_certificate" else "key"] = p[1] if len(p) > 1 else ""
+            A.anota(ln, "traducido", txt)
+        elif cab == "proxy_pass":
+            destino.acciones.append(Accion("proxy", p[1] if len(p) > 1 else ""))
+            A.anota(ln, "traducido", txt)
+        elif cab == "try_files":
+            destino.acciones.append(Accion("intentar", " ".join(p[1:])))
+            A.anota(ln, "traducido", txt)
+        elif cab == "return":
+            destino.acciones.append(Accion("redir", " ".join(p[1:])))
+            A.anota(ln, "traducido", txt)
+        elif cab in ("proxy_set_header", "add_header"):
+            A.anota(ln, "decision", txt,
+                    "las cabeceras de proxy habituales (X-Forwarded-*) las pone el destino por su "
+                    "cuenta; si ésta no es de ésas, hay que escribirla")
+        elif cab in ("client_max_body_size", "expires", "autoindex", "access_log", "error_log"):
+            A.anota(ln, "decision", txt, "tiene equivalente pero con otra forma: revisalo")
+        else:
+            A.anota(ln, "SIN-TRADUCIR", txt, "directiva no reconocida por este lector")
+    return cfg
diff --git a/scripts/mudanza/modelo.py b/scripts/mudanza/modelo.py
new file mode 100644
index 00000000..3efde8fb
--- /dev/null
+++ b/scripts/mudanza/modelo.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+"""modelo.py — el MODELO PIVOTE de configuración de servicio, y el acta que lo acompaña (SDD 29).
+
+── POR QUÉ UN PIVOTE Y NO PARES ────────────────────────────────────────────────────────────────
+Traducir de a pares crece en N×M: con nginx, apache, caddy, haproxy y lighttpd son 20 traductores, y
+cada formato nuevo agrega 2N. Con un modelo intermedio son **N lectores + M escritores**: un formato
+nuevo cuesta UNO o DOS plugins, y estrena todos los pares de golpe. Es la diferencia entre un centro
+de traducción y una pila de conversores.
+
+── EL ACTA VIAJA DENTRO DEL MODELO, NO AL COSTADO ──────────────────────────────────────────────
+Ésta es la decisión que hace que el pivote no mienta. Si el acta fuera un efecto de cada traductor,
+lo que un LECTOR no entendió se perdería antes de llegar al escritor. Viajando en el modelo, el
+escritor puede emitirlo como comentario y el humano lo ve en el fichero que va a desplegar.
+
+Doctrina heredada de `soltar`/`paskaq` (tawasuyu): **elisión honesta** — lo que no se pudo traducir
+se reporta con su línea y su motivo, nunca se descarta en silencio.
+"""
+from dataclasses import dataclass, field
+
+
+@dataclass
+class Accion:
+    """Qué hace una ruta. `tipo` ∈ {proxy, archivos, redir, responder, intentar}."""
+    tipo: str
+    valor: str = ""
+    extra: dict = field(default_factory=dict)
+
+
+@dataclass
+class Ruta:
+    """Un camino dentro de un sitio. `clase` ∈ {prefijo, exacto, regex}.
+
+    `regex` se conserva a propósito aunque casi ningún destino lo soporte igual: **perder la
+    distinción sería traducir mal en silencio**. El escritor decide si sabe emitirla o la manda al
+    acta."""
+    patron: str
+    clase: str = "prefijo"
+    acciones: list = field(default_factory=list)
+    origen: int = 0
+
+
+@dataclass
+class Sitio:
+    nombres: list = field(default_factory=list)
+    puertos: list = field(default_factory=list)      # "80", "443"
+    tls: dict = field(default_factory=dict)          # {cert, key}
+    raiz: str = ""
+    indices: list = field(default_factory=list)
+    rutas: list = field(default_factory=list)
+    acciones: list = field(default_factory=list)     # a nivel de sitio
+    origen: int = 0
+
+
+@dataclass
+class Acta:
+    """Registro de procedencia. Cuatro estados, y el que importa es el primero."""
+    entradas: list = field(default_factory=list)
+
+    def anota(self, linea, estado, texto, motivo=""):
+        # estado ∈ {SIN-TRADUCIR, decision, traducido, no-aplica}
+        self.entradas.append({"linea": linea, "estado": estado,
+                              "directiva": texto, "motivo": motivo})
+
+    def sin_traducir(self):
+        return [e for e in self.entradas if e["estado"] == "SIN-TRADUCIR"]
+
+    def por_estado(self):
+        d = {}
+        for e in self.entradas:
+            d.setdefault(e["estado"], []).append(e)
+        return d
+
+
+@dataclass
+class Config:
+    """Lo que un lector produce y un escritor consume."""
+    formato_origen: str = ""
+    sitios: list = field(default_factory=list)
+    acta: Acta = field(default_factory=Acta)
diff --git a/scripts/mudanza/traducir.py b/scripts/mudanza/traducir.py
index 74523cb4..53d2ee90 100755
--- a/scripts/mudanza/traducir.py
+++ b/scripts/mudanza/traducir.py
@@ -1,269 +1,101 @@
 #!/usr/bin/env python3
-"""traducir.py — traduce la CONFIGURACIÓN de un servicio a la de su equivalente (SDD 29).
+"""traducir.py — CENTRO de traducción de configuraciones, por modelo pivote y plugins (SDD 29).
 
-Mudar de nginx a caddy no es cambiar un binario: es reescribir la configuración. Esto hace la parte
-mecánica y —sobre todo— **dice con nombre y número de línea lo que NO pudo traducir**.
+Mudar de un servicio a su equivalente no es cambiar un binario: es **reescribir la configuración**.
+Esto hace la parte mecánica y —sobre todo— **dice con número de línea lo que NO pudo traducir**.
 
-── DOCTRINA HEREDADA DE `soltar`/`paskaq` (tawasuyu) ────────────────────────────────────────────
-`soltar` desencadena datos presos en formatos cautivos (Btrieve, Paradox, .mdb). El dominio es otro,
-pero sus principios son exactamente los que un traductor de configuración necesita, y se adoptan:
+── POR QUÉ UN CENTRO Y NO TRADUCTORES DE A PARES ───────────────────────────────────────────────
+De a pares el trabajo crece en **N×M**: con nginx, apache, caddy, haproxy y lighttpd son 20
+traductores, y cada formato nuevo agrega 2N. Con un **modelo pivote** son **N lectores + M
+escritores**: un formato nuevo cuesta uno o dos plugins y estrena todos los pares de golpe.
 
-  · **Elisión honesta.** «Lo que no se pudo leer se reporta con offset y motivo.» Acá: cada
-    directiva que no se entendió sale con su NÚMERO DE LÍNEA y su texto. Un traductor que descarta
-    en silencio una directiva es catastrófico — te deja un servidor sin una redirección, sin una
-    regla de auth o sin una cabecera, y el sitio parece funcionar.
-  · **No adivinar en silencio.** Toda heurística queda registrada como decisión, no aplicada de
-    tapadillo.
-  · **Procedencia total.** Cada línea del resultado sabe de qué línea del original salió.
-  · **Determinista**: mismo archivo, misma salida, byte a byte.
+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.
 
-── LO QUE ESTO NO ES ───────────────────────────────────────────────────────────────────────────
-No es un traductor completo de nginx: nginx tiene cientos de directivas, `if`, `rewrite` con regex,
-`map`, módulos. **Traduce el núcleo común y declara todo lo demás.** Un traductor honesto que cubre
-el 70 % y dice cuál es el 30 % restante es útil; uno que aparenta cubrir el 100 % es una trampa.
+── LOS PLUGINS ────────────────────────────────────────────────────────────────────────────────
+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.
+
+── 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**,
+**procedencia total** y **determinismo**. Y una consecuencia de diseño propia: **el acta viaja DENTRO
+del modelo**, no al costado — si fuera de cada traductor, lo que el LECTOR no entendió se perdería
+antes de llegar al escritor, y el fichero que uno despliega no lo diría.
 
 Uso:
-    scripts/mudanza/traducir.py --from nginx --to caddy --in nginx.conf --out Caddyfile
-    scripts/mudanza/traducir.py --from nginx --to caddy --in nginx.conf   # sólo el acta
+    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
 """
-import argparse, re, sys
-
-# ── lectura de nginx ────────────────────────────────────────────────────────────────────────────
-
-def tokenizar_nginx(texto):
-    """Directivas con su número de línea y su anidamiento. Suficiente para el núcleo; no pretende
-    ser el parser de nginx."""
-    out, nivel, buf, ln_ini = [], 0, "", 0
-    for ln, linea in enumerate(texto.split("\n"), 1):
-        s = linea.split("#")[0].strip()
-        if not s:
-            continue
-        if not buf:
-            ln_ini = ln
-        for ch in s:
-            if ch == "{":
-                out.append((ln_ini, nivel, buf.strip(), "abre"))
-                nivel += 1
-                buf = ""
-            elif ch == "}":
-                if buf.strip():
-                    out.append((ln_ini, nivel, buf.strip(), "dir"))
-                nivel -= 1
-                out.append((ln_ini, nivel, "", "cierra"))
-                buf = ""
-            elif ch == ";":
-                out.append((ln_ini, nivel, buf.strip(), "dir"))
-                buf = ""
-            else:
-                buf += ch
-        if buf and not buf.endswith(" "):
-            buf += " "
-    return out
-
-
-# ── traducción nginx → caddy ────────────────────────────────────────────────────────────────────
-
-def nginx_a_caddy(texto):
-    """Devuelve `(caddyfile, acta)`. El acta es la parte que no se puede omitir."""
-    toks = tokenizar_nginx(texto)
-    acta, sitios = [], []
-    actual, pila, loc = None, [], None
-
-    def anota(ln, estado, directiva, motivo=""):
-        acta.append({"linea": ln, "estado": estado, "directiva": directiva, "motivo": motivo})
-
-    # ⚠ Profundidad de bloques que NO se pudieron traducir (`if`, `map`, `stream`, `upstream`).
-    # Sin esto, las directivas de adentro se absorbían como si fueran del servidor: un
-    # `if (...) { return 403; }` salía como un `respond 403` INCONDICIONAL, o sea el sitio entero
-    # devolviendo 403. Encontrado validando la salida con el caddy real. Es el modo de fallo que la
-    # doctrina de `soltar` llama pérdida silenciosa — sólo que peor: no pierde, CAMBIA el sentido.
-    opaco = 0
-
-    i = 0
-    while i < len(toks):
-        ln, niv, txt, tipo = toks[i]
-        i += 1
-        cab = txt.split()[0] if txt else ""
-
-        if tipo == "abre":
-            pila.append(cab)
-            if cab == "server":
-                actual = {"nombres": [], "puertos": [], "root": None, "index": None,
-                          "locations": [], "tls": {}, "linea": ln}
-                anota(ln, "traducido", "server {", "→ un bloque de sitio de Caddy")
-            elif cab == "location":
-                partes = txt.split()
-                loc = {"ruta": partes[1] if len(partes) > 1 else "/", "dirs": [], "linea": ln}
-                if len(partes) > 2:
-                    anota(ln, "SIN-TRADUCIR", txt,
-                          "location con modificador (~, ~*, ^~, =): las reglas por regex de nginx no "
-                          "tienen equivalente directo en Caddy — hay que reescribirla a mano")
-            elif cab in ("http", "events", "upstream", "map", "if", "stream"):
-                if cab in ("map", "if", "stream", "upstream"):
-                    opaco += 1
-                    anota(ln, "SIN-TRADUCIR", txt + " {",
-                          f"`{cab}` no tiene traducción mecánica: su semántica en nginx depende del "
-                          f"orden de evaluación y del contexto — y TODO lo de adentro queda sin "
-                          f"traducir, no se absorbe al bloque de afuera")
-            continue
-
-        if tipo == "cierra":
-            c = pila.pop() if pila else ""
-            if c in ("map", "if", "stream", "upstream") and opaco:
-                opaco -= 1
-            if c == "location" and actual is not None and loc:
-                actual["locations"].append(loc); loc = None
-            elif c == "server" and actual is not None:
-                sitios.append(actual); actual = None
-            continue
-
-        # ── directivas ──────────────────────────────────────────────────────────────────────────
-        if opaco:
-            anota(ln, "SIN-TRADUCIR", txt,
-                  "está dentro de un bloque que no se pudo traducir: sacarla de ahí le cambiaría el "
-                  "sentido (dejaría de ser condicional)")
-            continue
-        if actual is None:
-            if cab in ("worker_processes", "user", "pid", "events", "include", "worker_connections",
-                       "sendfile", "keepalive_timeout", "gzip", "default_type", "log_format",
-                       "access_log", "error_log", "types_hash_max_size", "tcp_nopush"):
-                anota(ln, "no-aplica", txt,
-                      "ajuste global de nginx sin equivalente necesario: Caddy lo maneja solo")
-            elif txt:
-                anota(ln, "SIN-TRADUCIR", txt, "directiva global no reconocida")
-            continue
-
-        p = txt.split()
-        if cab == "server_name":
-            actual["nombres"] += [x for x in p[1:] if x != "_"]
-            anota(ln, "traducido", txt, "→ nombre del sitio")
-        elif cab == "listen":
-            actual["puertos"].append(p[1] if len(p) > 1 else "")
-            if "ssl" in p:
-                anota(ln, "traducido", txt, "→ Caddy hace TLS automático; `ssl` no se escribe")
-            else:
-                anota(ln, "traducido", txt, "→ puerto del sitio")
-        elif cab == "root":
-            (loc or actual)["root" if loc is None else "root"] = p[1] if len(p) > 1 else ""
-            if loc is not None:
-                loc["dirs"].append(("root", p[1] if len(p) > 1 else ""))
-            anota(ln, "traducido", txt, "→ `root *`")
-        elif cab == "index":
-            actual["index"] = " ".join(p[1:])
-            anota(ln, "traducido", txt, "→ `file_server` usa index por defecto")
-        elif cab in ("ssl_certificate", "ssl_certificate_key"):
-            actual["tls"][cab] = p[1] if len(p) > 1 else ""
-            anota(ln, "traducido", txt, "→ `tls <cert> <key>`")
-        elif cab == "proxy_pass" and loc is not None:
-            loc["dirs"].append(("proxy", p[1] if len(p) > 1 else ""))
-            anota(ln, "traducido", txt, "→ `reverse_proxy`")
-        elif cab == "try_files" and loc is not None:
-            loc["dirs"].append(("try_files", " ".join(p[1:])))
-            anota(ln, "traducido", txt, "→ `try_files`")
-        elif cab == "return":
-            destino = " ".join(p[1:])
-            (loc["dirs"] if loc is not None else actual.setdefault("dirs", [])).append(("return", destino))
-            anota(ln, "traducido", txt, "→ `redir`")
-        elif cab in ("proxy_set_header", "add_header"):
-            anota(ln, "decision", txt,
-                  "Caddy pone por su cuenta las cabeceras de proxy habituales (X-Forwarded-*). Si "
-                  "ésta es de las habituales, sobra; si no, hay que escribirla con `header_up`")
-        elif cab in ("access_log", "error_log", "client_max_body_size", "expires", "autoindex"):
-            anota(ln, "decision", txt, "tiene equivalente en Caddy pero con otra forma: revisalo")
-        else:
-            anota(ln, "SIN-TRADUCIR", txt, "directiva no reconocida por este traductor")
+import argparse, os, sys
 
-    # ── emitir Caddyfile ────────────────────────────────────────────────────────────────────────
-    L = ["# Generado por scripts/mudanza/traducir.py desde una configuración de nginx.",
-         "# ⚠ NO es una traducción completa. Leé el ACTA: lo que no se pudo traducir está ahí con su",
-         "#   número de línea. Desplegar esto sin mirar el acta es desplegar un servidor incompleto.",
-         ""]
-    for s in sitios:
-        # En nginx dos `server` con el MISMO server_name se distinguen por su `listen`; en Caddy se
-        # distinguen por el ESQUEMA de la dirección. Sin traducir eso, los dos bloques colisionan y
-        # caddy rechaza el fichero con «ambiguous site definition» — encontrado al validar con el
-        # binario real, no al leer la salida.
-        solo_80 = bool(s["puertos"]) and all(p.split(":")[-1] == "80" for p in s["puertos"])
-        pref = "http://" if solo_80 else ""
-        nombres = ", ".join(pref + n for n in s["nombres"]) or (":80" if solo_80 else ":443")
-        L.append(f"{nombres} {{")
-        L.append(f"\t# desde el `server {{` de la línea {s['linea']} del nginx.conf")
-        if s["tls"].get("ssl_certificate"):
-            L.append(f"\ttls {s['tls'].get('ssl_certificate','')} {s['tls'].get('ssl_certificate_key','')}")
-        if s.get("root"):
-            L.append(f"\troot * {s['root']}")
-        for k, v in s.get("dirs", []):
-            if k == "return":
-                code, *dest = v.split()
-                L.append(f"\tredir {' '.join(dest)} {code}" if dest else f"\trespond {code}")
-        for lo in s["locations"]:
-            cuerpo = []
-            for k, v in lo["dirs"]:
-                if k == "proxy":
-                    cuerpo.append(f"\t\treverse_proxy {v.rstrip('/').replace('http://','').replace('https://','')}")
-                elif k == "root":
-                    cuerpo.append(f"\t\troot * {v}")
-                elif k == "try_files":
-                    cuerpo.append(f"\t\ttry_files {v}")
-                elif k == "return":
-                    code, *dest = v.split()
-                    cuerpo.append(f"\t\tredir {' '.join(dest)} {code}" if dest else f"\t\trespond {code}")
-            if cuerpo:
-                L.append(f"\thandle {lo['ruta']} {{    # location de la línea {lo['linea']}")
-                L += cuerpo
-                L.append("\t}")
-        if s.get("root"):
-            L.append("\tfile_server")
-        L.append("}")
-        L.append("")
-    return "\n".join(L), acta
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import formatos                                          # noqa: E402
 
 
 def imprimir_acta(acta):
-    por = {}
-    for a in acta:
-        por.setdefault(a["estado"], []).append(a)
+    por = acta.por_estado()
     print("\n══ ACTA DE TRADUCCIÓN ══")
-    print(f"   {len(acta)} directiva(s) leídas\n")
-    orden = ["SIN-TRADUCIR", "decision", "traducido", "no-aplica"]
+    print(f"   {len(acta.entradas)} directiva(s) leídas\n")
     titulo = {"SIN-TRADUCIR": "⚠ NO SE PUDO TRADUCIR — hay que escribirlas a mano",
               "decision": "◆ traducidas con una DECISIÓN — revisalas",
               "traducido": "✓ traducidas",
-              "no-aplica": "· no aplican en Caddy"}
-    for e in orden:
+              "no-aplica": "· no aplican en el destino"}
+    for e in ("SIN-TRADUCIR", "decision", "traducido", "no-aplica"):
         if e not in por:
             continue
-        print(f"── {titulo[e]} ({len(por[e])}) " + "─" * 24)
-        for a in por[e] if e in ("SIN-TRADUCIR", "decision") else por[e][:6]:
+        print(f"── {titulo[e]} ({len(por[e])}) " + "─" * 22)
+        detalle = e in ("SIN-TRADUCIR", "decision")
+        for a in (por[e] if detalle else por[e][:5]):
             print(f"   línea {a['linea']:>4}  {a['directiva'][:64]}")
-            if a["motivo"] and e in ("SIN-TRADUCIR", "decision"):
-                print(f"              └─ {a['motivo'][:100]}")
-        if e not in ("SIN-TRADUCIR", "decision") and len(por[e]) > 6:
-            print(f"   … y {len(por[e])-6} más")
+            if detalle and a["motivo"]:
+                print(f"              └─ {a['motivo'][:98]}")
+        if not detalle and len(por[e]) > 5:
+            print(f"   … y {len(por[e])-5} más")
         print()
-    n = len(por.get("SIN-TRADUCIR", []))
+    n = len(acta.sin_traducir())
     if n:
-        print(f"   ⚠ {n} directiva(s) sin traducir. Desplegar sin revisarlas es desplegar un servidor")
-        print("     INCOMPLETO que además parece funcionar — el modo de fallo más caro.")
+        print(f"   ⚠ {n} sin traducir. Desplegar sin resolverlas es desplegar un servidor INCOMPLETO")
+        print("     que además parece funcionar — el modo de fallo más caro.")
     return n
 
 
 def main():
-    ap = argparse.ArgumentParser(description="Traduce la configuración de un servicio a su equivalente.")
-    ap.add_argument("--from", dest="desde", required=True, choices=["nginx"])
-    ap.add_argument("--to", dest="hacia", required=True, choices=["caddy"])
-    ap.add_argument("--in", dest="entrada", required=True)
-    ap.add_argument("--out", dest="salida", help="fichero de salida (default: sólo el acta)")
+    lectores, escritores = formatos.cargar()
+    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("--out", dest="salida")
     a = ap.parse_args()
 
-    texto = open(a.entrada).read()
-    salida, acta = nginx_a_caddy(texto)
-    n = imprimir_acta(acta)
+    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}")
+        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)
     if a.salida:
-        open(a.salida, "w").write(salida + "\n")
+        open(a.salida, "w").write(texto)
         print(f"\n   {a.hacia} → {a.salida}")
     else:
-        print(salida)
+        print(texto)
     sys.exit(1 if n else 0)