diff --git a/workloads/docs-portal/docs/aprendizajes/notas-sueltas.md b/workloads/docs-portal/docs/aprendizajes/notas-sueltas.md
index 18c40c1..0e00191 100644
--- a/workloads/docs-portal/docs/aprendizajes/notas-sueltas.md
+++ b/workloads/docs-portal/docs/aprendizajes/notas-sueltas.md
@@ -1,20 +1,9 @@
# Notas sueltas / aprendizajes
-> **TODO (fase 2+):** esto es un scratchpad estructurado, no un
-> playbook — va creciendo con notas cortas a medida que aparecen. Las
-> entradas de abajo son semillas reales extraídas de incidentes ya
-> documentados; expandir cada una con más contexto en fases siguientes.
-
-## Qué haría distinto
-
-- **Fijar `TUNNEL_TRANSPORT_PROTOCOL=http2` desde el día uno**, no
- después de un incidente — QUIC/UDP sobre NAT doméstico fue un problema
- predecible en retrospectiva. Ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
-- **Separar siempre "chequeo de disponibilidad" de "migraciones"** en
- init containers distintos, y que el chequeo use el mínimo de
- parámetros posible (host/puerto/usuario/db) en vez de reusar una URI
- de conexión pensada para la app. Confirmado como decisión correcta en
- el [incidente de crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
+> Esto es un scratchpad estructurado, no un playbook — sigue creciendo
+> con notas cortas a medida que aparecen. Cada entrada está extraída de
+> un incidente o hallazgo real ya documentado en otra parte del sitio,
+> no es especulación.
## Gotchas de Medusa v2
@@ -26,8 +15,70 @@
del array `images` — hay que setearlo explícito desde el Admin si el
frontend lo usa como imagen hero en listados.
-## TODO
+## MinIO / S3 provider
-- Notas sobre MinIO / S3 provider.
-- Notas sobre el subscriber `revalidate-storefront.ts` y sus límites.
-- Qué automatizaría si volviera a montar este lab desde cero.
+- Medusa sube imágenes de producto a MinIO (`minio-svc:9000`, bucket
+ `ari-products`) usando el provider S3-compatible estándar — MinIO no
+ sabe que no es AWS real, y Medusa tampoco necesita saberlo.
+- La URL pública que termina viendo el navegador **no** es la interna
+ (`minio-svc:9000`): es `S3_FILE_URL=https://media.cruzcloud.net/ari-products`,
+ un dominio propio proxied por NPM hacia el mismo MinIO. Esto tiene
+ una consecuencia directa que ya rompió producción una vez: si
+ `media.cruzcloud.net` deja de rutear bien (mismo patrón de
+ `forward_host` roto que el resto del sitio), **todas** las imágenes
+ de producto del storefront se caen aunque MinIO y Medusa estén
+ perfectamente sanos — porque el navegador nunca habla con MinIO
+ directo, solo con esa URL pública. Ver [Red y exposición](../arquitectura/red-y-exposicion.md).
+
+## Subscriber `revalidate-storefront.ts` y sus límites
+
+- Escucha 6 eventos de Medusa (`product.created/updated/deleted` +
+ los 3 equivalentes de `product-variant`) y por cada uno hace un
+ único `POST` a `${STOREFRONT_URL}/api/revalidate` con un header
+ secreto (`x-revalidate-secret`) — esa ruta del frontend llama
+ `revalidateTag("medusa-products")` de Next.js.
+- **Se degrada solo, no tira el evento entero**: si
+ `STOREFRONT_URL`/`REVALIDATE_SECRET` no están configurados, o el
+ `fetch` falla, solo loguea un `warn` y sigue — un fallo de red hacia
+ el frontend nunca revierte ni bloquea la operación real en Medusa
+ (crear/editar un producto). Mismo criterio de "degradar, no romper"
+ que se repite en el resto del storefront (`searchWithFacets`
+ opcional, `/api/search` cayendo a `{}` si Meilisearch no responde).
+- **Límite real, todavía no un problema**: no hay debounce ni
+ deduplicación — si una operación dispara varios eventos juntos (ej.
+ editar un producto con 3 variantes a la vez), son varios `POST`
+ independientes al mismo endpoint. `revalidateTag` de Next.js es
+ barato e idempotente, así que hoy no se nota, pero si el catálogo
+ creciera mucho y las ediciones masivas se volvieran frecuentes, sería
+ el primer lugar donde agregar algún tipo de agrupación.
+
+## Qué haría distinto si volviera a montar este lab desde cero
+
+- **Fijar `TUNNEL_TRANSPORT_PROTOCOL=http2` desde el día uno** (ver
+ [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)) y
+ **ajustar el MTU de `flannel.1` desde el primer `deploy-lab.sh`** —
+ ambos terminaron siendo incidentes reales
+ (504 intermitente, blackhole cross-node) que en retrospectiva eran
+ predecibles: red doméstica con NAT agresivo + Docker-in-Docker
+ multi-nodo son una combinación conocida para tener problemas de MTU
+ y de transporte UDP. Hoy los dos ya están automatizados
+ (`fix_flannel_mtu` en `deploy-lab.sh`, la variable de entorno del
+ túnel), pero se aprendieron reactivamente, no se anticiparon.
+- **Usar `git merge-base --is-ancestor` para la promoción segura del
+ CI desde el primer pipeline**, no después de ver la condición de
+ carrera en vivo (dos pipelines corriendo en serie sobre el mismo
+ commit, el segundo pisando o saltándose la promoción del primero).
+ El patrón correcto no es complicado una vez que se conoce — el costo
+ fue no anticiparlo.
+- **Separar siempre "chequeo de disponibilidad" de "migraciones"** en
+ init containers distintos, y que el chequeo use el mínimo de
+ parámetros posible (host/puerto/usuario/db) en vez de reusar una URI
+ de conexión pensada para la app. Confirmado como decisión correcta en
+ el [incidente de crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
+- **No escribir un script que regenere credenciales completas en cada
+ corrida** (`create-commerce-secrets.sh` rota JWT/COOKIE/passwords de
+ Postgres/Redis/MinIO enteros cada vez que se ejecuta, incluso si solo
+ hacía falta agregar una clave nueva) — un script de setup inicial y
+ uno de "agregar una clave a un secret ya vivo" deberían haber sido
+ dos herramientas distintas desde el principio, no una sola que hay
+ que saber cuándo NO correr completa.
\ No newline at end of file
diff --git a/workloads/docs-portal/docs/arquitectura/gitops-flujo.md b/workloads/docs-portal/docs/arquitectura/gitops-flujo.md
index db24916..65491fc 100644
--- a/workloads/docs-portal/docs/arquitectura/gitops-flujo.md
+++ b/workloads/docs-portal/docs/arquitectura/gitops-flujo.md
@@ -1,9 +1,9 @@
# Del commit al despliegue: flujo GitOps
-> **TODO (fase 2+):** narrar cada paso con ejemplos reales tomados de
-> `.gitea/workflows/build-medusa.yaml` y `build.yaml`. El diagrama de
-> secuencia de abajo refleja el pipeline real (build → push imagen →
-> promoción segura → commit del tag → sync de Argo CD).
+El diagrama de abajo refleja el pipeline real (build → push imagen →
+promoción segura → commit del tag → sync de Argo CD). Después del
+diagrama hay un ejemplo con hashes de commit reales, tomado de un
+cambio real hecho sobre este mismo repo.
## Secuencia: un commit hasta quedar corriendo en el cluster
@@ -42,9 +42,53 @@ sequenceDiagram
evita que un pipeline viejo sobreescriba el tag con una versión
anterior si hubo un push más reciente mientras corría.
-## TODO
+## Ejemplo real: un commit de principio a fin
-- Ejemplo real con hashes de commit (tomar del historial de
- `apps-registry`).
-- Explicar `syncPolicy.automated.selfHeal` con un caso concreto (qué
- pasa si alguien hace `kubectl edit` a mano).
+Fase 3b del ADR 0006 (facetas del sidebar del catálogo, ver
+`decisiones/0006-motor-busqueda-facetas.md`) recorrió el flujo
+completo dos veces seguidas el mismo día:
+
+| Paso | Commit / evento real |
+|---|---|
+| Feature | `83d448b` — `feat(ecommerce): facetas reales de Meilisearch en el sidebar del catalogo`, en `fix/adr0006-etapa2-fase3b-facetas-sidebar` |
+| Merge a `main` | `6b5450f` |
+| CI build+push | Gitea Actions task 258, imagen `gitea.cruzcloud.net/devops/ecommerce-frontend` |
+| Release commit (CD) | `951682a` — `chore: release v1.0.171 [skip ci]`, bump automático del tag en `frontend/patches/deployment.yaml` |
+| Sync de Argo CD | dos ciclos distintos: primero sincronizó contra `6b5450f` (el merge, todavía con el tag viejo), y en el ciclo siguiente detectó `951682a` y aplicó el tag nuevo |
+| Rollout | `frontend-deploy` con 2/2 réplicas en `v1.0.171` |
+
+El gate en vivo contra ese mismo cambio encontró un bug real
+(`estimatedTotalHits` en 0 con `limit=0` en Meilisearch) — el fix
+repitió el mismo ciclo completo: `884dbd1` → merge `ba3e6c7` → release
+`v1.0.172` → sync → rollout. Dos vueltas completas del diagrama de
+arriba en la misma sesión, cada una verificada contra el endpoint real
+antes de darla por cerrada.
+
+**Detalle no obvio:** Argo CD sincroniza el *merge commit* primero
+(todavía con la imagen vieja, porque el bump de tag es un commit
+*posterior* que hace el propio pipeline) y recién en un segundo ciclo
+de poll detecta el commit de release. Son dos sincronizaciones
+distintas, no una sola — hay que esperar ambas antes de asumir que el
+pod nuevo está corriendo.
+
+## `selfHeal`, con la configuración real
+
+Todas las Applications de este repo comparten el mismo `syncPolicy`
+(`apps/ecommerce-app.yaml`):
+
+```yaml
+syncPolicy:
+ automated:
+ prune: true
+ selfHeal: true
+```
+
+Con `selfHeal: true`, si alguien corre `kubectl scale deployment
+frontend-deploy --replicas=1` directo contra el cluster (saltándose
+Git por completo), Argo CD no se queda en mostrar `OutOfSync` — en el
+siguiente ciclo de reconciliación vuelve a aplicar el manifiesto tal
+como está en `main` y el Deployment queda otra vez en las réplicas
+declaradas ahí. El cambio manual no se pierde silenciosamente: queda
+un instante como drift visible en la UI de Argo CD, pero nunca se
+vuelve el estado final. La única forma de que ese cambio persista es
+que exista en Git — lo mismo que describe [ADR 0002](../decisiones/0002-por-que-argocd-vs-manual.md).
diff --git a/workloads/docs-portal/docs/arquitectura/glosario.md b/workloads/docs-portal/docs/arquitectura/glosario.md
index e24f51a..9ee0353 100644
--- a/workloads/docs-portal/docs/arquitectura/glosario.md
+++ b/workloads/docs-portal/docs/arquitectura/glosario.md
@@ -65,9 +65,142 @@ plantillas (a diferencia de Helm).
incluir y qué parches aplicarles, sin necesidad de un lenguaje de
templating separado.
-## TODO
+## Namespace
-- Namespace, ResourceQuota, LimitRange, NetworkPolicy
-- Ingress vs. Service vs. Deployment
-- StatefulSet (por qué Postgres lo usa y el frontend no)
-- Init container
+**En una frase:** una partición lógica dentro del mismo cluster físico
+— agrupa recursos relacionados y les pone un límite alrededor.
+
+**Detalle:** este lab tiene un namespace por dominio de negocio
+(`ecommerce`, `docs-portal`) más `argocd` para el propio Argo CD. Todo
+lo que gobierna a un namespace (cuotas, políticas de red) vive en
+`platform-infra`, separado de los manifiestos de la app — así una app
+nueva no puede "olvidarse" de traer su propia gobernanza.
+
+## ResourceQuota
+
+**En una frase:** el techo total de recursos que un namespace entero
+puede consumir, sumando todos sus pods.
+
+**Detalle:** la cuota real de `ecommerce`
+(`platform-infra/ecommerce-governance/resourcequota.yaml`):
+
+```yaml
+hard:
+ requests.cpu: "3"
+ requests.memory: 4Gi
+ limits.cpu: "8"
+ limits.memory: 8Gi
+ pods: "12"
+ persistentvolumeclaims: "6"
+ requests.storage: 80Gi
+```
+
+Si un Deployment nuevo pidiera más `requests.memory` del que queda
+disponible bajo ese techo, Kubernetes rechaza el pod directamente — no
+lo deja arrancar y luego fallar.
+
+## LimitRange
+
+**En una frase:** los valores por defecto (y los mínimos/máximos) que
+aplican a un contenedor *individual* dentro de un namespace, para
+cuando el manifiesto no los declara explícitos.
+
+**Detalle:** complementa a `ResourceQuota` (que mira el namespace
+entero) a nivel de un solo contenedor. La de `ecommerce`
+(`limitrange.yaml`) pone `defaultRequest: 100m/128Mi`,
+`default: 500m/512Mi` (límites si el contenedor no los declara), y un
+rango permitido de `10m/16Mi` a `2 CPU/1Gi` por contenedor — evita
+tanto el contenedor que no pide nada y termina compitiendo mal por
+recursos, como el que pide de más y se come la cuota del namespace
+entero él solo.
+
+## NetworkPolicy
+
+**En una frase:** un firewall a nivel de Kubernetes — qué tráfico de
+red se permite entrar (o salir) de un pod.
+
+**Detalle:** sin ninguna `NetworkPolicy`, todos los pods de un cluster
+pueden hablarse entre sí libremente. La de `ecommerce`
+(`networkpolicy.yaml`) usa `podSelector: {}` (aplica a todos los pods
+del namespace) y permite ingress solo desde `namespaceSelector: {}`
+(cualquier namespace del cluster, típicamente Traefik llegando desde
+`kube-system` o el namespace de ingress) — bloquea tráfico externo al
+cluster que intentara saltarse Traefik y pegarle directo a un Service
+interno.
+
+## Deployment
+
+**En una frase:** el controlador para cargas de trabajo **sin
+estado** — réplicas intercambiables entre sí, ninguna con identidad
+propia.
+
+**Detalle:** `frontend-deploy` es un Deployment de 2 réplicas: no
+importa cuál de las dos atienda una petición, ambas sirven exactamente
+lo mismo y ninguna guarda datos propios en disco. Si un pod muere,
+Kubernetes crea otro cualquiera para reemplazarlo — no hay "el pod
+original" que recuperar. Contrastar con **StatefulSet** abajo.
+
+## Service
+
+**En una frase:** un nombre de red estable que apunta a un grupo de
+pods, aunque esos pods cambien.
+
+**Detalle:** los pods son efímeros (mueren y se recrean con IP nueva
+cada vez); un Service (ej. `frontend-svc`, `postgres-svc`) les da un
+nombre DNS interno fijo (`frontend-svc.ecommerce.svc.cluster.local`,
+o simplemente `frontend-svc` desde dentro del mismo namespace) que no
+cambia aunque los pods de atrás sí. `postgres-svc` en este lab es
+además un Service `clusterIP: None` ("headless") — necesario porque
+`commerce-postgres` es un StatefulSet y cada pod necesita su propia
+identidad de red estable, no una IP compartida balanceada entre
+réplicas.
+
+## Ingress
+
+**En una frase:** la regla que dice "las peticiones a este dominio y
+esta ruta van a este Service", para tráfico HTTP que entra al cluster.
+
+**Detalle:** `ecommerce-ingress` enruta por *path*, no solo por
+dominio: `shop.cruzcloud.net/api/catalog` va a `catalog-api-svc`, y
+todo lo demás (`/`) va a `frontend-svc` — un solo objeto Ingress con
+dos backends distintos según el prefijo de la URL. Es Traefik (el
+Ingress Controller del cluster) quien lee estos objetos y hace el
+enrutamiento real; el Ingress en sí es solo la declaración de la
+regla, no el componente que la ejecuta.
+
+## StatefulSet
+
+**En una frase:** el controlador para cargas de trabajo **con
+estado** — cada réplica tiene identidad propia y almacenamiento propio
+que sobrevive a que el pod se reinicie.
+
+**Detalle:** `commerce-postgres` es un StatefulSet, no un Deployment,
+porque el frontend puede tener 2 réplicas idénticas sin datos propios,
+pero Postgres no — sus datos tienen que vivir en un disco específico
+que sobreviva un reinicio del pod y sepa exactamente cuál pod es
+"el suyo". Cada pod de un StatefulSet obtiene su propio
+`PersistentVolumeClaim` vía `volumeClaimTemplates` (acá,
+`postgres-data`, 10Gi en `local-path`) y un nombre de red estable y
+numerado (`commerce-postgres-0`), a diferencia de los nombres
+aleatorios de un Deployment. Meilisearch, Redis y MinIO en este lab
+siguen el mismo patrón por la misma razón — todos guardan datos en
+disco que tienen que sobrevivir al pod.
+
+## Init container
+
+**En una frase:** un contenedor que corre **antes** que los
+contenedores principales de un pod, y tiene que terminar bien (exit
+0) para que el resto arranque.
+
+**Detalle:** `medusa-deploy` usa dos, y a propósito están separados en
+vez de ser uno solo: `wait-for-postgres` (`pg_isready` en loop, solo
+espera a que Postgres acepte conexiones) y `migrations`
+(`db:migrate`, corre las migraciones de verdad). Un incidente real
+mostró por qué separarlos importa: cuando el chequeo de disponibilidad
+reusaba la misma `DATABASE_URL` completa de la app (con
+`?sslmode=disable` incluido), el parser de `pg_isready` no la entendía
+y el pod quedaba atascado en `Init` — reportado como si fuera
+`CrashLoopBackOff`, sin serlo. El fix fue que el chequeo de
+disponibilidad use el mínimo de parámetros posible
+(host/puerto/usuario/db), nunca la URI completa pensada para la app.
+Ver el [playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
diff --git a/workloads/docs-portal/docs/arquitectura/red-y-exposicion.md b/workloads/docs-portal/docs/arquitectura/red-y-exposicion.md
index 686e970..3b12e42 100644
--- a/workloads/docs-portal/docs/arquitectura/red-y-exposicion.md
+++ b/workloads/docs-portal/docs/arquitectura/red-y-exposicion.md
@@ -1,8 +1,7 @@
# Red y exposición
-> **TODO (fase 2+):** explicar cada dominio, por qué está proxied vs
-> DNS-only en Cloudflare, y el manejo de TLS extremo a extremo. Tabla
-> de dominios abajo es real, pendiente de expandir con capturas/ejemplos.
+Los 5 dominios de abajo son reales y comparten exactamente el mismo
+camino de red — ninguno tiene un caso especial.
## Dominios activos
@@ -14,12 +13,63 @@
| `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) |
| `docs.cruzcloud.net` | Este portal | `docs` | Cloudflare (edge) |
-## TODO
+## Por qué todos van "proxied" (naranja), ninguno "DNS-only"
-- Diagrama de TLS termination (dónde termina TLS realmente: en
- Cloudflare, no en Traefik — el tráfico interno NPM→k3d es HTTP plano).
-- Explicar por qué el transporte del túnel se fijó a HTTP/2 en vez de
- QUIC (ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
- y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)).
-- Patrón de diagnóstico "interno sano, público roto" (ver
- [playbook de imágenes NPM](../playbooks/incidente-imagenes-npm.md)).
+Un registro DNS-only en Cloudflare expondría directamente la IP real
+del túnel — pero acá no hay IP pública fija que exponer: el NAS está
+detrás de NAT residencial. El modo proxied es lo que hace que
+Cloudflare Tunnel tenga sentido en primer lugar (el tráfico entra por
+el edge de Cloudflare, nunca directo al origen), así que no hay ningún
+dominio de este lab para el que DNS-only sea una opción real.
+
+## Dónde termina TLS de verdad
+
+```mermaid
+flowchart LR
+ User(["Usuario"]) -->|HTTPS| CF["Cloudflare edge
🔒 TLS termina acá"]
+ CF -->|túnel cifrado, HTTP/2| CFD["cloudflared"]
+ CFD -->|HTTP plano| NPM["Nginx Proxy Manager"]
+ NPM -->|HTTP plano| LB["k3d-proxy :90"]
+ LB -->|HTTP plano| TR["Traefik"]
+ TR -->|HTTP plano| SVC["Service interno"]
+```
+
+TLS termina en el **edge de Cloudflare**, no en Traefik ni en NPM. De
+ahí hacia adentro (`cloudflared` → NPM → `k3d-proxy` → Traefik →
+Service) todo el tráfico va en HTTP plano — seguro en este contexto
+porque ese tramo entero vive dentro de la red local del NAS, nunca
+sale a internet. Ningún componente interno necesita certificado propio
+ni gestionar renovación de TLS.
+
+## Por qué HTTP/2 y no QUIC en el túnel
+
+`cloudflared` puede usar QUIC (UDP) o HTTP/2 (TCP) para sus conexiones
+salientes hacia el edge. Este lab fuerza HTTP/2 explícitamente — no es
+el default. Un incidente real de `504 Gateway Timeout` intermitente en
+**todos** los subdominios (no solo uno) se rastreó hasta fallos
+recurrentes del "control stream" de QUIC sobre la red doméstica del
+NAS. Ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
+para la decisión completa y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)
+para el diagnóstico paso a paso.
+
+## Patrón de diagnóstico: "interno sano, público roto"
+
+Cuando un dominio público falla pero todo dentro del cluster responde
+bien (pods `Running`, probes verdes, `kubectl exec` con `curl` al
+Service funciona), la causa casi nunca está en Kubernetes — está en la
+capa NPM. La forma rápida de confirmarlo es saltarse NPM a propósito:
+`curl -H "Host: " http://192.168.68.61:90/` habla directo con
+`k3d-proxy`/Traefik sin pasar por NPM. Si eso funciona pero el dominio
+público no, el problema está específicamente en el `forward_host`/
+`forward_port` del proxy host en NPM (ej.: un scheme o un path colado
+en `forward_host`, que rompe todo el enrutamiento).
+
+Este patrón se repitió dos veces en este lab con la misma causa raíz
+exacta: el [incidente de imágenes en `media.cruzcloud.net`](../playbooks/incidente-imagenes-npm.md)
+(playbook completo) y un incidente posterior de `commerce.cruzcloud.net`
+devolviendo 400 por el mismo tipo de `forward_host` roto (sin playbook
+propio todavía). Una lección adicional de ese segundo incidente:
+editar el `proxy_host` a mano en el sqlite de NPM **no** regenera el
+`.conf` de nginx automáticamente — el cambio tiene que pasar por la
+propia UI/API de NPM, o el `.conf` hay que parchearlo a mano y
+recargar nginx.
diff --git a/workloads/docs-portal/docs/arquitectura/vision-general.md b/workloads/docs-portal/docs/arquitectura/vision-general.md
index 3160061..d564346 100644
--- a/workloads/docs-portal/docs/arquitectura/vision-general.md
+++ b/workloads/docs-portal/docs/arquitectura/vision-general.md
@@ -1,10 +1,10 @@
# Visión general
-> **TODO (fase 2+):** narrativa completa explicando cada capa del
-> diagrama, con foco en el "por qué" de cada frontera de confianza. El
-> diagrama de abajo es real (validado contra `docs/known-issues.md` y
-> los playbooks de incidentes de `apps-registry`), pero el texto que lo
-> acompaña es solo el esqueleto.
+Este lab corre sobre un NAS ZimaOS doméstico, sin IP pública ni
+presupuesto de nube — cada capa del diagrama de abajo existe por una
+razón concreta de ese contexto, no por moda arquitectónica. Esta
+página explica el "por qué" de cada frontera; el "cómo" vive en las
+páginas enlazadas al final de cada sección.
## Flujo de una petición pública
@@ -40,4 +40,52 @@ flowchart LR
| Argo CD | Sincroniza el estado del cluster con Git (patrón App of Apps) | Namespace `argocd` |
| Gitea | Origen de verdad de los manifiestos y del código de las apps | Contenedor propio, expuesto vía túnel |
+## Por qué cada frontera está donde está
+
+**Cloudflare Tunnel, no port-forwarding.** El NAS vive detrás de NAT
+residencial, sin IP pública fija. En vez de abrir puertos en el router
+(superficie de ataque directa contra el hogar), `cloudflared` abre una
+conexión **saliente** hacia Cloudflare y todo el tráfico público viaja
+hacia adentro por esa misma conexión — el origen nunca expone un
+puerto escuchando en internet. El transporte de esa conexión se fijó a
+HTTP/2 en vez del QUIC por defecto tras un incidente real de
+inestabilidad; ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
+
+**Nginx Proxy Manager, fuera del cluster.** No todo lo que sirve
+`*.cruzcloud.net` vive dentro de k3d — Gitea, por ejemplo, corre como
+contenedor Docker aparte. NPM es el único punto de enrutamiento por
+dominio que necesita conocer *ambos* mundos (cluster y no-cluster), así
+que vive fuera de Kubernetes a propósito, no por descuido.
+
+**`k3d-lab-cluster-serverlb`, la frontera de entrada al cluster.** Es
+el load balancer que el propio k3d monta automáticamente para publicar
+puertos del host hacia los nodos internos — no es un componente que
+este lab configuró a mano, viene con la topología multi-nodo elegida
+en [ADR 0001](../decisiones/0001-por-que-k3d.md).
+
+**Traefik, enrutamiento por `Host` dentro del cluster.** Una vez el
+tráfico entra al cluster por un único puerto (90/9443), Traefik es
+quien decide a qué Service mandarlo según el header `Host` de cada
+petición — así NPM solo necesita saber "todo `*.cruzcloud.net` va al
+puerto 90 del cluster", sin tener que aprender un forward_host nuevo
+cada vez que se agrega un servicio interno. Cuando esta capa falla en
+silencio (`forward_host`/`forward_port` mal apuntado en NPM), el
+síntoma es siempre el mismo: "internamente sano, públicamente roto" —
+ver [Red y exposición](red-y-exposicion.md).
+
+**Argo CD, el único escritor del cluster.** Ningún pipeline de CI ni
+ninguna persona corre `kubectl apply` contra producción — Argo CD es
+la única pieza con permisos de escritura, y reconstruye
+automáticamente cualquier diferencia entre Git y el cluster real
+(`selfHeal: true`). El porqué completo de esa elección está en
+[ADR 0002](../decisiones/0002-por-que-argocd-vs-manual.md); el paso a
+paso de un commit real hasta quedar corriendo, en
+[Flujo GitOps](gitops-flujo.md).
+
+**Gitea, autoalojado.** Además de alojar el código, Gitea corre el CI
+(Gitea Actions) y el registry de imágenes en el mismo lugar — un solo
+componente autoalojado en vez de depender de la disponibilidad y los
+límites de un servicio externo gratuito para un lab que ya de por sí
+no tiene presupuesto de nube.
+
Ver también: [Red y exposición](red-y-exposicion.md) · [Flujo GitOps](gitops-flujo.md) · [Glosario](glosario.md)
diff --git a/workloads/docs-portal/docs/decisiones/0001-por-que-k3d.md b/workloads/docs-portal/docs/decisiones/0001-por-que-k3d.md
index 96c2ed8..a84d324 100644
--- a/workloads/docs-portal/docs/decisiones/0001-por-que-k3d.md
+++ b/workloads/docs-portal/docs/decisiones/0001-por-que-k3d.md
@@ -1,33 +1,67 @@
# ADR 0001 — Por qué k3d (y no k3s directo, minikube, o un cluster cloud)
-> **TODO (fase 2+):** completar contexto/opciones/consecuencias con
-> criterio real (recursos del NAS ZimaOS, necesidad de multi-nodo para
-> reproducir el blackhole de MTU documentado en `known-issues.md`,
-> costo cero vs. cluster gestionado).
-
-**Estado:** aceptada (placeholder — pendiente de redactar)
-**Fecha:** TODO
+**Estado:** aceptada
+**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
## Contexto
-TODO
+El lab corre sobre un NAS ZimaOS doméstico, sin presupuesto para un
+cluster gestionado y con recursos modestos y compartidos con otras
+apps del NAS (Gitea, Nginx Proxy Manager, este mismo portal). El
+propósito del lab es doble: servir de portafolio técnico real y de
+material de estudio para el hijo del usuario (7mo semestre de
+Ingeniería de Sistemas) — ver [guía del estudiante](../guia-estudiante/README.md).
+Para que ambos objetivos tengan sentido, el cluster necesitaba
+comportarse como un Kubernetes real y multi-nodo, no como una caja de
+arena de un solo proceso: patrones como afinidad de pods, StatefulSets
+con almacenamiento persistente por nodo, y tráfico cross-node debían
+poder reproducirse y depurarse de verdad, no simularse.
+
+También pesó la restricción del propio sistema operativo: `/` en
+ZimaOS es un `squashfs` de solo lectura y `/usr` es un overlay
+igualmente inmutable — instalar un binario nativo como `k3s` que
+espera escribir en `/usr/local/bin`, `/etc/rancher`, etc. de forma
+persistente no encaja bien con ese filesystem sin trabajo extra de
+por medio.
## Opciones consideradas
| Opción | A favor | En contra |
|---|---|---|
-| k3d | | |
-| k3s directo (sin Docker) | | |
-| minikube | | |
-| Cluster gestionado (cloud) | | |
+| **k3d** | Multi-nodo real corriendo como contenedores Docker (server + agent), se crea/destruye en segundos, no toca el filesystem inmutable de ZimaOS más allá de `/DATA` | Al ser Docker-in-Docker, hereda las capas de red de Docker — un blackhole de MTU/PMTUD real apareció por esto (ver Consecuencias) |
+| k3s directo (sin Docker) | Sin la capa extra de red de Docker-in-Docker, más cercano a un nodo "real" | Necesita instalar servicios systemd y escribir en rutas de sistema (`/usr/local/bin`, `/etc/rancher`) que en ZimaOS son de solo lectura o requieren workarounds — solo `/DATA` y `/etc` quedan escribibles |
+| minikube | Instalación simple, muy documentado, ideal para aprender lo básico | Single-node por defecto — no permite reproducir tráfico cross-node, afinidad de pods, ni el tipo de incidentes de infraestructura real que este lab busca documentar como aprendizaje |
+| Cluster gestionado (cloud) | Cero mantenimiento de infraestructura, SLA real | Costo recurrente que este lab no tiene presupuestado; además reduce el valor de aprendizaje — gran parte de lo documentado en `playbooks/` (MTU, DNS, límites de recursos) es justamente lo que un proveedor gestionado abstrae |
## Decisión
-TODO
+k3d, multi-nodo (hoy: 1 server + 1 agent, `k3d-lab-cluster-server-0` /
+`k3d-lab-cluster-agent-0`), corriendo como contenedores Docker sobre
+el propio NAS.
## Consecuencias
-TODO — mencionar explícitamente el blackhole de red cross-node
-(MTU/PMTUD) como consecuencia real y documentada de correr multi-nodo
-sobre Docker-in-Docker sin ajuste de MTU (ver
-[playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)).
+- **Se ganó** un cluster real y gratuito, reproducible con un solo
+ script (`scripts/deploy-lab.sh`), y suficientemente parecido a un
+ Kubernetes de producción como para que los incidentes documentados
+ en `playbooks/` sean transferibles a un entorno cloud real.
+- **Se pagó** con un blackhole de red cross-node real: cualquier pod
+ agendado en un nodo distinto al de `commerce-postgres-0`
+ (StatefulSet, fijo a un nodo) fallaba de forma consistente al hablar
+ con Postgres — diagnosticado hasta el bit exacto (payload interno
+ 1420 bytes OK, 1450 bytes blackhole total, sin ICMP "frag needed")
+ y resuelto ajustando el MTU real de la interfaz `flannel.1` a 1400
+ en cada nodo. Ver [known-issues.md](https://gitea.cruzcloud.net/devops/apps-registry/src/branch/main/docs/known-issues.md)
+ y el [playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)
+ para el diagnóstico completo. El fix no persiste solo — está
+ automatizado en `scripts/deploy-lab.sh` (función `fix_flannel_mtu`),
+ se reaplica cada vez que el cluster arranca o se recrea.
+- **Deuda aceptada a propósito**: el `podAffinity` que fuerza a
+ `medusa-deploy` a compartir nodo con Postgres se dejó como red de
+ contención extra después del fix de fondo — no es estrictamente
+ necesario, pero es barato mantenerlo.
+- **Para revertir esta decisión** haría falta migrar a un cluster
+ gestionado o a `k3s` nativo, lo que implicaría asumir el costo
+ recurrente (cloud) o resolver las restricciones de filesystem
+ inmutable de ZimaOS (k3s nativo) — ninguna de las dos se justifica
+ hoy con el tamaño real del lab.
diff --git a/workloads/docs-portal/docs/decisiones/0002-por-que-argocd-vs-manual.md b/workloads/docs-portal/docs/decisiones/0002-por-que-argocd-vs-manual.md
index 28bac03..653c29a 100644
--- a/workloads/docs-portal/docs/decisiones/0002-por-que-argocd-vs-manual.md
+++ b/workloads/docs-portal/docs/decisiones/0002-por-que-argocd-vs-manual.md
@@ -1,30 +1,80 @@
# ADR 0002 — Por qué Argo CD (y no `kubectl apply` manual o un script de CI)
-> **TODO (fase 2+):** completar con el criterio real detrás de elegir
-> reconciliación continua (`selfHeal`) sobre despliegue imperativo desde
-> CI.
-
-**Estado:** aceptada (placeholder — pendiente de redactar)
-**Fecha:** TODO
+**Estado:** aceptada
+**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
## Contexto
-TODO
+Desde el principio el lab iba a tener más de un repo Git (hoy:
+`apps-registry` para las Applications y los manifiestos reales de los
+workloads, `platform-infra` para la gobernanza de cada namespace,
+`scripts` para bootstrap/operación) y más de una app corriendo en el
+cluster (ecommerce, docs-portal, nginx, cada una con su propia
+gobernanza). Se necesitaba una forma de mantener el estado del
+cluster sincronizado con lo declarado en Git que escalara a "agregar
+una app nueva" sin volverse un proceso manual que dependiera de que
+una sola persona recordara qué `kubectl apply` correr y en qué orden.
+
+También importaba la trazabilidad: en un lab que sirve de portafolio,
+poder señalar "este commit es exactamente lo que está corriendo en
+producción" (y por qué) tiene valor por sí mismo — no solo operativo.
## Opciones consideradas
| Opción | A favor | En contra |
|---|---|---|
-| Argo CD (GitOps, pull-based) | | |
-| `kubectl apply` manual | | |
-| `kubectl apply` desde el pipeline de CI (push-based) | | |
+| **Argo CD (GitOps, pull-based)** | Reconciliación continua: un proceso dentro del cluster compara el estado real contra Git y corrige la diferencia solo, sin que nadie tenga que acordarse de aplicar nada. Patrón App of Apps nativo — agregar una app nueva es un archivo YAML más en `apps/`, no un paso manual nuevo | Componente extra que mantener sano (su propio namespace, su propio consumo de recursos) |
+| `kubectl apply` manual | Cero infraestructura extra, control total en cada comando | Sin detección de drift — si alguien edita algo a mano y lo olvida, nadie se entera hasta que rompe; no escala más allá de un solo operador que recuerde el estado completo de memoria |
+| `kubectl apply` desde el pipeline de CI (push-based) | No agrega un componente nuevo al cluster, reutiliza el CI que ya existe para construir imágenes | El pipeline de CI necesitaría credenciales de escritura amplias sobre el cluster — más superficie de riesgo si esas credenciales se filtran. Push-based además significa que nada vuelve a comparar el estado después de aplicado: un drift posterior (alguien con `kubectl` directo) queda invisible hasta el siguiente deploy |
## Decisión
-TODO
+Argo CD, patrón App of Apps: una Application raíz
+(`apps-registry/application.yaml`) vigila la carpeta `apps/`, y cada
+archivo ahí dentro es una Application hija que apunta a un workload
+real. Toda Application se declara con el mismo `syncPolicy`:
+
+```yaml
+syncPolicy:
+ automated:
+ prune: true
+ selfHeal: true
+ syncOptions:
+ - CreateNamespace=true
+```
+
+(configuración real de `apps/ecommerce-app.yaml`, repetida en cada
+Application del repo)
## Consecuencias
-TODO — mencionar el patrón App of Apps (`application.yaml` +
-`apps/*.yaml`) y cómo `selfHeal:true` cambia el modelo mental de
-"quién puede tocar el cluster".
+- **`selfHeal: true` cambia el modelo mental de quién puede tocar el
+ cluster de verdad.** No es solo que Argo CD detecte diferencias y
+ las muestre como `OutOfSync` — las revierte activamente. Si alguien
+ corre `kubectl scale` o `kubectl edit` directo contra un recurso
+ gestionado, en el siguiente ciclo de reconciliación (poll periódico,
+ o inmediato si el commit llegó por webhook) Argo CD lo vuelve a
+ dejar exactamente como dice Git. Git deja de ser "la intención" y
+ pasa a ser literalmente la única fuente de la que el cluster puede
+ aprender su propio estado — ver [flujo GitOps](../arquitectura/gitops-flujo.md).
+- **`prune: true`** significa que borrar un recurso del repo (por
+ ejemplo, quitar `meilisearch.yaml` de una `kustomization.yaml`) lo
+ borra también del cluster en el siguiente sync, sin un paso manual
+ de limpieza — confirmado en la práctica en varias fases de este lab
+ (ver los ADRs de rediseño del storefront y de Meilisearch).
+- **El rol de CI se redujo a "construir, publicar imagen, y dejar un
+ commit en Git"** — nunca aplica nada directo al cluster. Esto evitó
+ tener que darle a CI credenciales de cluster, y dejó un problema real
+ documentado (la condición de carrera de promoción cuando dos
+ pipelines corren en serie sobre el mismo commit) resoluble
+ comparando contra Git (`git merge-base --is-ancestor`) en vez de
+ contra el estado del cluster.
+- **Se paga con un componente más que mantener sano**: Argo CD corre
+ en su propio namespace, con su propio consumo de recursos y su
+ propia superficie de fallo — un Argo CD caído no rompe lo que ya
+ está corriendo (el cluster sigue con el último estado aplicado),
+ pero sí detiene la sincronización de cambios nuevos.
+- **Para revertir esta decisión** haría falta volver a un modelo
+ push-based (CI aplicando directo) o puramente manual, perdiendo la
+ reconciliación continua y aceptando que un drift manual pueda pasar
+ desapercibido.