From 5ce7030c4d669542ac04214e513b4190b3374cf2 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Tue, 25 Aug 2026 18:10:28 -0500 Subject: [PATCH 1/2] docs(adr): propuesta de motor de busqueda y facetas (Fase 5 Etapa 3) Etapa 1 (auditoria/propuesta, sin codigo) de la Etapa 3 de Fase 5: motor de busqueda y filtros facetados reales, reemplazando el filtrado/conteo 100% client-side que dejo la sub-fase 3 de Etapa 2. Verificado contra documentacion oficial vigente (no memoria de entrenamiento): - Medusa no tiene modulo de busqueda propio integrado con Meilisearch; el Search Module nativo llego en Medusa 2.19, y el plugin comunitario @rokmohar/medusa-plugin-meilisearch se conecta a el solo desde su version ^2.0.0 (requiere Medusa >=2.19). Nuestro backend corre Medusa 2.18.0 -- la version compatible es la linea ^1.4.1 del plugin (Medusa >=2.15 <2.19), documentada como deuda tecnica explicita si se sube Medusa mas adelante. - Impacto de recursos evaluado contra el estado real del host (swap al 100%, mismo tipo de contencion que el incidente de Gitea): Meilisearch recomendado sobre Typesense por menor huella de RAM y por ser el unico con integracion Medusa mantenida para nuestra version -- comparacion mantenida breve porque la diferencia es clara, sin profundizar mas de lo necesario. - Diseno de indexacion (campos buscables/filtrables/ordenables), sincronizacion (mismo patron de subscriber por eventos que revalidate-storefront.ts, sumando product-category.* al set de eventos) y UI de facetas/autocompletado (reutiliza catalog-client.tsx y CatalogProvider.searchProducts ya existentes, sin componentes nuevos). Cross-referencia agregada en ADR 0004 (que ya habia identificado Meilisearch como paso natural) y entrada nueva en mkdocs.yml. Etapa 2 (implementacion) NO arranca hasta aprobacion explicita de esta propuesta. --- .../0004-evaluacion-stack-multi-categoria.md | 5 +- .../decisiones/0006-motor-busqueda-facetas.md | 256 ++++++++++++++++++ workloads/docs-portal/mkdocs.yml | 1 + 3 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 workloads/docs-portal/docs/decisiones/0006-motor-busqueda-facetas.md diff --git a/workloads/docs-portal/docs/decisiones/0004-evaluacion-stack-multi-categoria.md b/workloads/docs-portal/docs/decisiones/0004-evaluacion-stack-multi-categoria.md index d2b149d..3b64fb9 100644 --- a/workloads/docs-portal/docs/decisiones/0004-evaluacion-stack-multi-categoria.md +++ b/workloads/docs-portal/docs/decisiones/0004-evaluacion-stack-multi-categoria.md @@ -109,7 +109,10 @@ Un motor de búsqueda dedicado (Meilisearch, autohospedado) queda identificado como el siguiente paso natural una vez el catálogo crezca más allá de ~100 productos activos, pero no es parte del alcance inmediato de Etapa 2 — se revisará cuando ese techo esté -cerca. +cerca. **Actualización 2026-08-25:** terminada la Etapa 2 (ver +[ADR 0005](0005-rediseno-storefront.md)), el disparador real resultó +ser la complejidad de facetas de la taxonomía, no el volumen de +productos — ver [ADR 0006](0006-motor-busqueda-facetas.md). **Compatibilidad con Medusa Admin confirmada:** ninguna de las tres opciones evaluadas, incluida la elegida, toca diff --git a/workloads/docs-portal/docs/decisiones/0006-motor-busqueda-facetas.md b/workloads/docs-portal/docs/decisiones/0006-motor-busqueda-facetas.md new file mode 100644 index 0000000..618caf3 --- /dev/null +++ b/workloads/docs-portal/docs/decisiones/0006-motor-busqueda-facetas.md @@ -0,0 +1,256 @@ +# ADR 0006 — Motor de búsqueda y filtros facetados (Fase 5, Etapa 3) + +**Estado:** propuesta — pendiente de aprobación antes de Etapa 2 (implementación) +**Fecha:** 2026-08-25 + +## Contexto + +[ADR 0004](0004-evaluacion-stack-multi-categoria.md) ya identificó un +motor de búsqueda dedicado (Meilisearch, autohospedado) como "el +siguiente paso natural" para ARI Shopping, pero lo dejó fuera del +alcance de Etapa 2 explícitamente hasta que el catálogo se acercara al +techo real de ~100 productos activos que impone +`fetchProducts(filters, limit = 100)` en `medusa.ts`. + +**Ese techo todavía no llegó** — el catálogo real hoy son 26 productos +(17 legacy + 9 demo, ver [ADR 0005](0005-rediseno-storefront.md) +sección 5), lejos de 100. El disparador de esta propuesta no es el +techo de volumen sino la **complejidad de facetas**: la Etapa 2 ya +construyó una taxonomía real de 6 categorías raíz / 21 nodos +(navegación, mega-menú, breadcrumb — PRs #36-42) y un filtrado con +conteo por valor (`categoryCounts`/`brandCounts`/`ageCounts` en +`catalog-client.tsx`, PR #40) que hoy se calcula **100% client-side** +sobre los productos ya traídos — el mismo patrón de "deuda de +alcance" que el hallazgo #4 de ADR 0004 ya señaló, ahora con más +dimensiones de filtro que multiplicar. Sigue siendo tratable a 26 +productos; deja de serlo mucho antes de llegar a 100 si el catálogo +crece a la vez en número de productos y en dimensiones filtrables. + +Restricción no negociable, igual que en toda la Fase 5: el flujo +**Admin → evento `product.updated` → `revalidate-storefront.ts` → +`revalidateTag("medusa-products")`**, que hoy mantiene el storefront +sincronizado sin intervención manual, no se toca. Cualquier índice de +búsqueda debe sincronizarse por el mismo mecanismo de eventos, nunca +por un cron ni un paso manual. + +## 1. Estado real de la integración Medusa v2 + Meilisearch + +Verificado contra la documentación oficial vigente el 2026-08-25, no +contra memoria de entrenamiento: + +- **Medusa no tiene un módulo de búsqueda propio integrado con + Meilisearch hoy.** La guía oficial + (`docs.medusajs.com/resources/integrations/guides/meilisearch`) es + un tutorial de módulo custom desde cero (`npm install meilisearch` + + workflows/subscribers propios) — no un paquete instalable. +- **Medusa sí introdujo un "Search Module" propio en la versión + 2.19.0.** El paquete comunitario `@rokmohar/medusa-plugin-meilisearch` + (el más usado y mantenido para Meilisearch+Medusa) se conecta a ese + Search Module desde su versión `^2.0.0`, pero esa versión del plugin + **requiere Medusa `^2.19.0`**. +- **Nuestro backend corre Medusa `2.18.0`** (`commerce-backend/package.json`) + — justo por debajo de ese corte. La tabla de compatibilidad del + plugin (`github.com/rokmohar/medusa-plugin-meilisearch`) resuelve + esto: la línea `^1.4.1` soporta `Medusa >= 2.15 < 2.19`, servidor + Meilisearch `>= 1.5`, Node `>= 22`. El propio repo lo explica: *"The + 2.19 release removed the search interface the v1 line was written + against, so the two lines do not overlap: v1 stops at Medusa 2.18, + v2 starts at 2.19."* +- Node de `commerce-backend` ya es `22.18.0` (Dockerfile) — cumple el + mínimo del plugin sin cambios. + +**Implicación concreta:** para adoptar Meilisearch sin subir de +versión Medusa (fuera de alcance de este ADR — sería su propia +migración), la pieza a instalar es `@rokmohar/medusa-plugin-meilisearch@^1.4.1`, +no la última versión del plugin. Esto es deuda técnica documentada, +no un obstáculo: si en el futuro se sube Medusa a `2.19+`, migrar a la +línea `^2.0.0` del plugin (y al Search Module nativo) es el camino +recto. + +## 2. Impacto de recursos + +Estado real del host ZimaOS al momento de esta auditoría (`free -h`, +`docker stats`): 15 GiB RAM totales, **swap al 100% (5.2/5.2 GiB +usados)**, solo 351 MiB realmente libres (6.5 GiB "available" vía +cache reclamable), 4 vCPU — la misma clase de contención que ya forzó +el fix de `cpu_shares` de Gitea. Cualquier servicio nuevo en este host +es una decisión de capacidad real, no teórica. + +- **Meilisearch no publica un mínimo de RAM oficial.** Su propio FAQ + (`meilisearch.com/docs/resources/help/faq`) solo dice que "más RAM + implica búsquedas más rápidas" y recomienda ~10x el tamaño del + dataset crudo en disco — sin piso duro. La cifra de "~1 GiB + cómodo" que circula en blogs de la comunidad es una estimación, no + una garantía del proveedor — se cita aquí como referencia, no como + hecho verificado. +- Meilisearch usa almacenamiento memory-mapped: el índice puede vivir + parcialmente en disco cuando no entra en RAM, degradando latencia en + vez de fallar. A la escala actual (26 productos, dataset de KB) el + uso real de RAM del índice en sí es insignificante — el costo es el + proceso base de Meilisearch, no el dataset. +- **Comparación con Typesense** (pedida explícitamente, mantenida + breve porque la diferencia relevante para este caso es clara y no + requiere más profundidad): Typesense mantiene el índice completo en + RAM (~30% más RAM que Meilisearch para el mismo dataset, según + benchmarks públicos) y, a diferencia de Meilisearch, **no tiene + ningún plugin ni integración con Medusa** — habría que escribir la + sincronización completa desde cero igual que con la guía "custom" + de Meilisearch, pero sin ninguna base comunitaria mantenida + compatible con nuestra versión de Medusa. Con Meilisearch ya + teniendo un plugin activo para `Medusa 2.18` y menor huella de RAM, + no hay motivo para evaluar Typesense más a fondo en este caso. +- **Recomendación:** desplegar Meilisearch en k3d con `requests`/`limits` + explícitos y conservadores desde el día uno (ej. `request: 256Mi/0.1 + CPU`, `limit: 768Mi/0.5 CPU` — a ajustar con datos reales de uso, no + supuestos), y tratarlo como el mismo tipo de riesgo operativo que ya + se vivió con Gitea: monitorear consumo real post-despliegue antes de + darlo por estable. Dado el swap ya saturado, este es el punto de + mayor incertidumbre de toda la propuesta — más que la elección de + motor de búsqueda en sí. + +## 3. Modelo de indexación + +Basado en la taxonomía real ya viva en Medusa (Etapa 2) y en +`Product`/`Category` de `lib/headless/types.ts`: + +| Campo | Buscable (texto, tolerante a typos) | Filtrable / faceta | Ordenable | +|---|---|---|---| +| `name` | Sí | No | No | +| `shortDescription` / `description` | Sí | No | No | +| `category` (nombre de la hoja real) | Sí | Sí (con conteo) | No | +| `collection` | Sí | Sí (con conteo) | No | +| `brand` | Sí | Sí (con conteo) | No | +| `ageRange` | No | Sí (con conteo) | No | +| `tags` | Sí | No | No | +| `price` | No | Sí (rango) | Sí | +| `compareAtPrice` | No | No | No (solo se usa para el badge, no como filtro) | +| `isNew` / `featured` | No | No | Sí (orden "Novedades"/"Destacados", igual que hoy) | +| `available` | No | Sí (booleano) | No | +| `slug`, `thumbnail`, `id` | No | No | No (solo se devuelven, no se indexan como facetas) | + +La jerarquía de categorías (raíz/hija) no se indexa como un campo +aparte: el índice guarda el nombre de la hoja real tal como hoy lo +hace `product.category`, y el breadcrumb/mega-menú siguen resolviendo +la jerarquía contra `/store/product-categories` (sin cambios, ya +funciona desde la sub-fase 2). El motor de búsqueda resuelve +**búsqueda de texto + conteo de facetas**, no reemplaza la fuente de +la jerarquía. + +## 4. Diseño de sincronización + +Mismo patrón que `revalidate-storefront.ts`, no uno nuevo: un +subscriber adicional en `commerce-backend/src/subscribers/` (ej. +`sync-search-index.ts`) que reacciona a eventos y le pide al plugin +que reindexe el documento afectado — nunca Medusa escribiendo +directamente en Meilisearch fuera de este canal. + +Eventos a escuchar, confirmados en la documentación oficial de +eventos de Medusa (`docs.medusajs.com/resources/events-reference`): + +- `product.created` / `product.updated` / `product.deleted` +- `product-variant.created` / `product-variant.updated` / `product-variant.deleted` + (mismo set que ya usa `revalidate-storefront.ts`, hoy validado en + producción) +- **Nuevo respecto al subscriber actual:** `product-category.created` + / `product-category.updated` / `product-category.deleted` — un + cambio de nombre o jerarquía de categoría debe refrescar las + facetas del índice, y hoy `revalidate-storefront.ts` no escucha + estos eventos porque el storefront los resuelve en cada request + contra `/store/product-categories` directamente, sin necesidad de + revalidación. El índice de búsqueda sí necesita enterarse. + +**Punto a verificar en vivo en Etapa 2, no asumido aquí:** existe al +menos un reporte público de un evento `product-collection.updated` +que no se disparaba de forma confiable en ciertas versiones de Medusa +(`github.com/medusajs/medusa/issues/11197`). Antes de dar por cerrado +el diseño de sincronización hay que repetir exactamente la validación +que ya se hizo para `revalidate-storefront.ts` en la auditoría de +Etapa 1 de Fase 5: crear/editar una categoría real vía Admin y +confirmar en vivo que el evento llega al subscriber, no confiar en que +la documentación coincide con el comportamiento real de `2.18.0` +(el mismo tipo de brecha que ya se encontró una vez con la relación +`+categories` de `/store/products`). + +## 5. Diseño de UI — filtros facetados y búsqueda + +**Filtros facetados (sidebar de `/catalog`):** mismo layout que ya +construyó la sub-fase 3 (`catalog-client.tsx`, PR #40) — sidebar con +"Categoría"/"Marca"/"Edad" como pills con conteo entre paréntesis y +slider de precio máximo. Lo único que cambia es **de dónde sale el +conteo**: hoy `categoryCounts`/`brandCounts`/`ageCounts` se calculan +recorriendo el arreglo de productos ya traído en el cliente; con el +motor de búsqueda, esos conteos vienen del propio Meilisearch +(`facetDistribution`) en la misma respuesta que trae los productos +filtrados, sin recorrer nada en el navegador. El breadcrumb no cambia +— sigue resolviéndose contra la jerarquía real de categorías, no +contra el índice de búsqueda. + +``` +Inicio > Mochilas y morrales > Marvel breadcrumb real (sin cambios) +┌────────────┬──────────────────────────────────────┐ +│ Categoría │ [buscador con autocompletado] │ ← nuevo +│ ☐ Marvel(6)│ [card][card][card] │ +│ ☐ Frozen(4)│ [card][card][card] │ +│ Marca │ │ +│ ☐ Disney(2)│ conteos ahora vienen de Meilisearch, │ +│ Precio │ no de recorrer el arreglo en cliente │ +│ [====○===] │ │ +└────────────┴──────────────────────────────────────┘ +``` + +**Búsqueda con autocompletado (barra del navbar):** hoy +`PredictiveSearch` (`components/search/predictive-search.tsx`) filtra +por substring sobre los productos ya cargados en memoria del cliente — +funciona a 26 productos, no escala y no tolera errores de tipeo (ADR +0004, hallazgo #5). Se reemplaza la fuente de datos del mismo +componente visual por `searchProducts(query)` del `CatalogProvider` +(interfaz que **ya existe** en `lib/headless/types.ts`, sin necesidad +de un tipo nuevo) apuntando a Meilisearch en vez de al `?q=` de +Medusa: tolerancia a typos y ranking por relevancia nativos del motor, +mismo contrato de datos que ya consume el dropdown de resultados — +cero cambio de componente React, solo de qué hay detrás de +`CatalogProvider.searchProducts`. + +## Opciones consideradas + +| Opción | A favor | En contra | +|---|---|---| +| **Meilisearch autohospedado** (`@rokmohar/medusa-plugin-meilisearch@^1.4.1`, sincronizado por eventos) | Plugin comunitario activo y compatible con nuestra versión exacta de Medusa (2.18); menor huella de RAM que la alternativa evaluada; almacenamiento memory-mapped se degrada en vez de fallar bajo presión de memoria; mismo patrón de subscriber que `revalidate-storefront.ts`, cero riesgo para ese flujo | Ningún paquete "oficial" de Medusa (el plugin es comunitario, no del core); atado a la línea `v1` del plugin mientras no se suba Medusa a 2.19+; un servicio más para operar en un host ya con swap saturado | +| **Typesense autohospedado** | Autocompletado ~2x más rápido según benchmarks públicos | Cero integración con Medusa (habría que escribir la sincronización completa desde cero, igual esfuerzo que la guía "custom" de Meilisearch pero sin base comunitaria); ~30% más RAM que Meilisearch para el mismo dataset, el peor trade-off posible dado el estado actual del host | +| **Módulo custom desde cero** (seguir la guía oficial de Medusa, sin el plugin de rokmohar) | Sin dependencia de un paquete de terceros; control total | Reimplementa lo que el plugin comunitario ya resuelve (declaración de índices, reindexado atómico, tracking de escrituras); más superficie propia para mantener sin ganancia clara sobre usar el plugin ya compatible con 2.18 | +| **No adoptar motor de búsqueda todavía** (seguir con filtrado 100% client-side) | Cero riesgo, cero componente nuevo | No resuelve la deuda de facetas ya identificada en ADR 0004 y agravada por la sub-fase 3; el techo de complejidad de filtros llega antes que el de 100 productos si el catálogo sigue creciendo en categorías | + +## Decisión + +**Meilisearch autohospedado**, vía `@rokmohar/medusa-plugin-meilisearch@^1.4.1` +(no la última versión del plugin — esa exige Medusa 2.19+, fuera de +alcance), sincronizado por el mismo patrón de subscriber por eventos +que ya usa `revalidate-storefront.ts`, con `product-category.*` sumado +al set de eventos existente. Se despliega en k3d con límites de +recursos conservadores desde el primer día y se trata como +experimento a monitorear, no como capacidad garantizada, dado el +estado actual de memoria del host. + +**No se sube la versión de Medusa como parte de esta decisión** — +queda como deuda técnica documentada para cuando se justifique +migrar a la línea `v2` del plugin y al Search Module nativo de +Medusa. + +## Consecuencias + +- Se gana: conteo de facetas real (no client-side), búsqueda tolerante + a errores de tipeo con ranking por relevancia, y una arquitectura + que ya no depende del techo de 100 productos de `fetchProducts`. +- Se acepta como deuda técnica: quedar atado a la línea `v1` del + plugin de Meilisearch mientras Medusa siga en `2.18.x`; migrar a + Medusa `2.19+` es un proyecto aparte, no parte de esta Etapa 3. +- Riesgo operativo real y no resuelto todavía por este ADR: el host ya + corre con el swap saturado — el despliegue de Etapa 2 debe empezar + con límites de recursos conservadores y validación en vivo de + consumo real antes de confiar en el servicio, siguiendo la misma + lección que dejó el incidente de recursos de Gitea. +- Revertir esta decisión implica desinstalar el plugin y el + subscriber nuevo, y volver el buscador/filtrado a 100% client-side + — no hay migración de datos que deshacer, porque Meilisearch nunca + es la fuente de verdad, solo una copia de solo lectura indexada + desde Medusa. diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml index 11fbcc0..b8ecaaa 100644 --- a/workloads/docs-portal/mkdocs.yml +++ b/workloads/docs-portal/mkdocs.yml @@ -91,6 +91,7 @@ nav: - "0003 · HTTP/2 sobre QUIC": decisiones/0003-por-que-http2-sobre-quic-tunnel.md - "0004 · Stack multi-categoría": decisiones/0004-evaluacion-stack-multi-categoria.md - "0005 · Rediseño storefront": decisiones/0005-rediseno-storefront.md + - "0006 · Motor de búsqueda y facetas": decisiones/0006-motor-busqueda-facetas.md - Plantilla ADR: decisiones/plantilla-adr.md - Playbooks: - Crashloop Medusa: playbooks/incidente-crashloop-medusa.md From c243f02ec44918297e6f18f15ab43b0469fea93c Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Wed, 26 Aug 2026 14:56:19 -0500 Subject: [PATCH 2/2] =?UTF-8?q?docs(adr):=20marcar=20ADR=200006=20como=20a?= =?UTF-8?q?ceptada,=20nota=20de=20implementaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aprobada tras el diagnóstico de memoria del host (swap saturado resuelto, ~500-700MiB liberados al desactivar monitoring). Agrega nota de implementación con 3 desviaciones reales encontradas al verificar el código fuente del plugin en el tag v1.4.1 antes de escribir código: no hace falta subscriber propio (el plugin trae los suyos), las rutas del plugin no devuelven facetDistribution (se agrega una ruta Store API propia), y se conecta vía plugins: no modules:. Ninguna cambia la decisión, solo el cómo. --- .../decisiones/0006-motor-busqueda-facetas.md | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/workloads/docs-portal/docs/decisiones/0006-motor-busqueda-facetas.md b/workloads/docs-portal/docs/decisiones/0006-motor-busqueda-facetas.md index 618caf3..117db3a 100644 --- a/workloads/docs-portal/docs/decisiones/0006-motor-busqueda-facetas.md +++ b/workloads/docs-portal/docs/decisiones/0006-motor-busqueda-facetas.md @@ -1,6 +1,6 @@ # ADR 0006 — Motor de búsqueda y filtros facetados (Fase 5, Etapa 3) -**Estado:** propuesta — pendiente de aprobación antes de Etapa 2 (implementación) +**Estado:** aceptada — Etapa 2 en implementación (aprobada 2026-08-26 tras diagnóstico de memoria del host) **Fecha:** 2026-08-25 ## Contexto @@ -254,3 +254,44 @@ Medusa. — no hay migración de datos que deshacer, porque Meilisearch nunca es la fuente de verdad, solo una copia de solo lectura indexada desde Medusa. + +## Nota de implementación (2026-08-26, antes de escribir código de Etapa 2) + +Antes de tocar código se verificó el código fuente real de +`@rokmohar/medusa-plugin-meilisearch` en el tag exacto `v1.4.1` +(`github.com/rokmohar/medusa-plugin-meilisearch`), no su README +genérico ni memoria de entrenamiento — misma disciplina que ya destapó +el bug de `+categories` en `/store/products`. Aparecieron tres +desviaciones reales respecto al diseño de §4/§5 de este ADR: + +1. **No se escribe un subscriber nuevo.** El plugin trae sus propios + subscribers (`meilisearch-product-upsert.ts`, + `meilisearch-category-upsert.ts`, variantes/colección/inventario), + auto-registrados al instalar el paquete — el propio README dice + *"You don't need custom subscribers anymore, you can remove them."* + Escribir `sync-search-index.ts` como describe §4 causaría doble + indexado. La escucha de `product-category.*` que pedía §4 ya viene + incluida en los subscribers del plugin. +2. **Las rutas Store API del plugin no devuelven `facetDistribution`**, + pese a que §5 asume que sí. El servicio interno + (`MeiliSearchService.search()`) sí puede pedirlo vía + `additionalOptions: { facets: [...] }`, pero las rutas + empaquetadas nunca lo piden ni lo reenvían. Se agrega una ruta + Store API propia (`src/api/store/search/route.ts`) que resuelve el + servicio del plugin desde el container y arma + `{ products, facetDistribution }`. Consecuencia positiva no + buscada: Meilisearch queda 100% `ClusterIP`-only, sin Ingress, sin + key pública ni ruta de red navegador→Meilisearch — todo pasa por + Medusa. +3. **El plugin se conecta vía un array `plugins:` nuevo en + `medusa-config.ts`**, no otra entrada en `modules:` (el único + precedente en este repo, el file-provider S3, usa `modules:`), y + usa nombres de env var propios (`MEILISEARCH_HOST`/ + `MEILISEARCH_API_KEY`, leídos por el plugin) distintos del env var + del servidor Meilisearch en sí (`MEILI_MASTER_KEY`, leído por el + contenedor `getmeili/meilisearch`) — mismo valor, dos nombres de + secret. + +Ninguna de las tres cambia la decisión ni sus consecuencias — cambian +el cómo, no el qué. Plan de implementación completo, fase por fase, +en la sesión que aprobó esta Etapa 2 (2026-08-26).