diff --git a/.github/workflows/docs-quality.yml b/.github/workflows/docs-quality.yml
index 5484b56f..0c7a203c 100644
--- a/.github/workflows/docs-quality.yml
+++ b/.github/workflows/docs-quality.yml
@@ -44,13 +44,29 @@ jobs:
- name: Check for TODO/demo references
run: |
- TODO_COUNT=$(grep -r "TODO\|FIXME\|DEMO\|HACK" docs/ --include="*.md" | wc -l)
- if [ $TODO_COUNT -gt 0 ]; then
- echo "Found $TODO_COUNT TODO/FIXME/DEMO/HACK references"
- grep -r "TODO\|FIXME\|DEMO\|HACK" docs/ --include="*.md"
+ # Se busca el MARCADOR (`TODO:`, `TODO(`, `FIXME`…), no la palabra suelta: en prosa
+ # española «TODOS los perfiles» no es deuda técnica, y con la coincidencia laxa el
+ # gate disparaba sobre texto corriente.
+ #
+ # Quedan fuera los documentos que hablan DE los marcadores en vez de tenerlos: el
+ # registro de deuda técnica, el TODO del proyecto (que es su inventario), las actas
+ # de release y la especificación que cita sus propios TD-xxx. Gatearlos obligaba a
+ # borrar el inventario de deuda para poder publicar documentación.
+ # El `sed` vacía los code spans antes de buscar: citar `TODO(G-069)` para explicar
+ # dónde está la deuda no es tener deuda.
+ MATCHES=$(grep -rn "" docs/ --include="*.md" \
+ --exclude-dir=releases \
+ --exclude="TODO.md" --exclude="TODO.es.md" \
+ --exclude="technical-debt*.md" \
+ --exclude="*parameterization-system-spec.md" \
+ | sed 's/`[^`]*`//g' \
+ | grep -E "(TODO[:(]|FIXME|HACK[:(]|\bDEMO\b)" || true)
+ if [ -n "$MATCHES" ]; then
+ echo "Found $(echo "$MATCHES" | wc -l) TODO/FIXME/DEMO/HACK markers"
+ echo "$MATCHES"
exit 1
fi
- echo "No TODO/FIXME/DEMO/HACK references found"
+ echo "No TODO/FIXME/DEMO/HACK markers found"
link-validation:
name: Internal & External Link Validation
@@ -100,8 +116,12 @@ jobs:
- name: Extract mermaid diagrams
run: |
- grep -r "```mermaid" docs/ --include="*.md" -A 50 | grep -v "^--$" > mermaid-diagrams.md
- echo "Found $(grep -c "graph\|flowchart\|sequence\|class\|state\|er\|gantt" mermaid-diagrams.md || echo 0) potential mermaid blocks"
+ # Comillas SIMPLES: entre dobles, los tres backticks del patrón abren una sustitución
+ # de comandos y el paso moría con `unexpected EOF while looking for matching`.
+ # El `|| true` cubre el caso sin coincidencias, que en grep es salida 1 y bajo `bash -e`
+ # tumbaba el paso igual.
+ grep -r '```mermaid' docs/ --include="*.md" -A 50 | grep -v "^--$" > mermaid-diagrams.md || true
+ echo "Found $(grep -c 'graph\|flowchart\|sequence\|class\|state\|er\|gantt' mermaid-diagrams.md || echo 0) potential mermaid blocks"
- name: Validate Mermaid syntax
run: |
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index ca6cdfe6..84838c3d 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -169,6 +169,11 @@ jobs:
name: npm Vulnerability Audit
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
+ # El workspace npm/nx vive bajo src/, no en la raíz: sin esto `npm ci` moría con ENOENT
+ # buscando un package.json que nunca existió ahí. Mismo patrón que ci.yml.
+ defaults:
+ run:
+ working-directory: src
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -176,14 +181,19 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
- node-version: ${{ env.NODE_VERSION }}
+ # React Router v8 exige node >= 22; el NODE_VERSION global (20) no basta.
+ node-version: 22
cache: 'npm'
+ cache-dependency-path: src/package-lock.json
- name: Install dependencies
run: npm ci
- name: Audit for vulnerabilities
- run: npm audit --audit-level=high
+ # `--omit=dev` por la misma razón que en ci.yml: se audita lo que se DESPLIEGA. Las
+ # high restantes son de tooling de build y no llegan al runtime; auditarlas aquí
+ # dejaría el gate en rojo permanente sin señalar riesgo real de lo entregado.
+ run: npm audit --omit=dev --audit-level=high
dockerfile-scan:
name: Docker Image Security Scan
@@ -193,17 +203,20 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
+ # `@v3` no existe como tag en hadolint-action: el job moría en «Set up job» sin llegar a
+ # ejecutar nada. v3.3.0 es el release real de esa línea.
- name: Hadolint Lint
- uses: hadolint/hadolint-action@v3
+ uses: hadolint/hadolint-action@v3.3.0
with:
- dockerfile: Dockerfile
+ dockerfile: src/apps/ums.api/Dockerfile
continue-on-error: true
+ # No hay Dockerfile en la raíz, así que el `if` de antes nunca se cumplía: no se construía
+ # imagen alguna y Trivy escaneaba una referencia inexistente (lo tapaba su
+ # continue-on-error). El contexto de build es src/, porque el grafo de proyectos alcanza
+ # Ums.ReadModels y libs/sdk/dotnet, fuera de apps/ums.api.
- name: Build Docker image for scan
- run: |
- if [ -f "Dockerfile" ]; then
- docker build -t ums-app:test . --quiet
- fi
+ run: docker build -f src/apps/ums.api/Dockerfile -t ums-app:test src/ --quiet
- name: Run Trivy scanner
uses: aquasecurity/trivy-action@master
@@ -214,11 +227,15 @@ jobs:
severity: 'CRITICAL,HIGH'
continue-on-error: true
+ # `upload-security-results` no existe en codeql-action —de ahí que el job muriera en
+ # «Set up job», antes de ejecutar nada—. La acción real es `upload-sarif`, y v2 está
+ # retirada. El `if` cubre que Trivy no llegue a escribir el SARIF: sube solo si hay
+ # fichero, en vez de fallar el paso.
- name: Upload Trivy results
- uses: github/codeql-action/upload-security-results@v2
- if: always()
+ uses: github/codeql-action/upload-sarif@v3
+ if: always() && hashFiles('trivy-results.sarif') != ''
with:
- tool_name: 'Trivy'
+ category: 'trivy'
sarif_file: 'trivy-results.sarif'
tenant-isolation-tests:
diff --git a/docs/architecture/adrs/0071-auth-graph-engine.md b/docs/architecture/adrs/0071-auth-graph-engine.md
index fa9e4490..e855f811 100644
--- a/docs/architecture/adrs/0071-auth-graph-engine.md
+++ b/docs/architecture/adrs/0071-auth-graph-engine.md
@@ -37,7 +37,7 @@ AuthorizationGraph
├── context — user, tenant, systemSuite, role, profile, branch
├── authentication — method (Local|IDP), provider, mfaRequired, expiry
├── actions[] — all registered actions in the SystemSuite
-├── menuAccess[] — Module→Menu→SubMenu→Option tree with AccessEffect per option
+├── menuAccess[] — Module→MenuNode recursive tree (ADR-0090), AccessEffect per node action
├── domainPermissions[] — domain resources with effect per action (Aggregate/Entity)
├── featureFlags[] — flags evaluated against user context at auth-time
├── effectiveConfig — tenant-resolved parameters (session timeout, MFA, etc.)
diff --git a/docs/architecture/adrs/0090-recursive-menu-node-tree.es.md b/docs/architecture/adrs/0090-recursive-menu-node-tree.es.md
new file mode 100644
index 00000000..c906587e
--- /dev/null
+++ b/docs/architecture/adrs/0090-recursive-menu-node-tree.es.md
@@ -0,0 +1,97 @@
+# ADR-0090: El Árbol Recursivo `MenuNode` Sustituye a `Menu` / `SubMenu` / `Option`
+
+**Estado:** Aceptado
+**Fecha:** 2026-08-10
+**Responsable de Decisión:** Arquitectura
+**Reemplaza:** La jerarquía rígida de cuatro niveles Suite → Módulo → Menú → Submenú → Opción
+**Relacionado:** [ADR-0071](./0071-auth-graph-engine.es.md) · gap G-029 · decisión D-009
+
+---
+
+> **Registro retroactivo.** La decisión se implementó antes de escribirse. Este ADR se reconstruye
+> a partir del código en producción (`Ums.Domain/Authorization/SystemSuite/MenuNode/`) y de los más
+> de quince documentos que ya la citan como aceptada. Documenta lo que el sistema hace hoy; no
+> propone un cambio.
+
+## Contexto
+
+La topología de navegación de una suite se modelaba como una **cadena fija de cuatro niveles**:
+
+```
+SystemSuite → Module → Menu → SubMenu → Option
+```
+
+Tres entidades propias distintas —`Menu`, `SubMenu`, `Option`— expresaban tres niveles de la misma
+idea: *un sitio en un árbol*. Esa rigidez traía problemas concretos:
+
+1. **Un nivel obligatorio que nadie quería.** Colgar una opción directamente de un menú era
+ imposible: había que inventar un submenú de relleno. Esos rellenos existen en los datos sembrados
+ y en las suites de clientes, y en la interfaz se ven como ramas vacías.
+2. **La relación funcionalidad↔opción era 1:1 y débil.** El vínculo era un `ActionCode` en texto
+ sobre la opción, sin clave ajena. Una misma funcionalidad no podía alcanzarse desde dos sitios
+ del menú, y nada impedía que una opción nombrara una acción inexistente.
+3. **Sin metadatos de gobernanza por nodo.** `Status` existía en la suite y en el módulo. El nodo
+ —lo que una persona navega, y por lo que pregunta una auditoría— no llevaba responsable, ni
+ criticidad, ni trazabilidad al artefacto SDLC que lo justifica.
+4. **Tres de todo.** Tres agregados de comandos, manejadores, validadores, configuraciones EF y
+ registros para expresar un único concepto recursivo. Añadir una regla obligaba a añadirla tres
+ veces, y las tres copias derivaban.
+
+## Decisión
+
+Modelar la topología de navegación como una **única entidad recursiva**, `MenuNode`, propiedad de
+`Module`.
+
+- Un `Module` posee una colección de **nodos raíz**; cada nodo puede anidar hijos recursivamente
+ (lista de adyacencia mediante `ParentNodeId`).
+- El papel del nodo se clasifica con `NodeKind` —`Menu`, `SubMenu`, `Option`— **sin fijar la
+ profundidad**. `Menu` y `SubMenu` actúan como rama y `Option` como hoja. Los nombres sobreviven
+ como *roles*, no como tipos, porque es el vocabulario que el negocio ya usa.
+- El vínculo con la funcionalidad pasa a ser **N:M** mediante la tabla puente
+ `SystemSuiteNodeActions`, de modo que una funcionalidad se alcanza desde varios sitios y todo
+ vínculo apunta a una acción que existe.
+- Cada nodo lleva `MenuNodeMetadata`, un objeto de valor con campos de gobernanza SDLC
+ (responsable, criticidad, producto impactado, componente técnico, dependencias, evidencias,
+ trazabilidad SDLC). Todos opcionales; se reemplazan de forma atómica.
+
+| Dimensión | Modelo rígido (retirado) | Árbol `MenuNode` |
+|---|---|---|
+| Profundidad | Fija de 4 niveles, submenú obligatorio | Variable; submenú opcional |
+| Funcionalidad↔opción | 1:1 débil (`ActionCode` sin FK) | **N:M** vía `SystemSuiteNodeActions` |
+| Metadatos de gobernanza | Solo `Status` en suite/módulo | **Metadatos SDLC por nodo** (`MenuNodeMetadata`) |
+| Entidades | `Menu`, `SubMenu`, `Option` | Un único `MenuNode` recursivo |
+
+Las operaciones siguen en la raíz de agregado `SystemSuite`, que delega en `Module`/`MenuNode`:
+`AddModuleRootNode`, `AddModuleChildNode`, `UpdateModuleNode`, `RemoveModuleNode` (el nodo **y su
+subárbol**), `ActivateModuleNode` / `DeactivateModuleNode`, `LinkModuleNodeAction` /
+`UnlinkModuleNodeAction`, `SetModuleNodeMetadata`.
+
+## Consecuencias
+
+**Se gana.** Una entidad, un juego de reglas. La profundidad la marca el producto y no el esquema.
+Una funcionalidad alcanzable desde dos menús es expresable. Cada nodo puede responder «quién es su
+responsable y por qué existe».
+
+**Se paga.** El árbol se guarda plano y se reconstruye en memoria
+(`AuthorizationAggregateFactory.RehydrateNode`, agrupando por `ParentNodeId`); una suite profunda
+cuesta un recorrido de sus nodos al cargar. La recursión admite ciclos que una cadena fija no
+permitía, así que la invariante «un nodo no es su propio ancestro» pasa a exigir vigilancia en vez
+de darse por supuesta.
+
+**Se retira.** `Menu`, `SubMenu` y `Option`, con sus comandos, manejadores, validadores,
+configuraciones EF y registros, **ya no existen en el código**. Tampoco `SystemSuiteMenuRecord`,
+`SystemSuiteSubMenuRecord` ni `SystemSuiteOptionRecord`. La documentación que siga describiendo la
+cadena de cuatro niveles como vigente está obsoleta, no describe una alternativa.
+
+## Persistencia
+
+- `ums_authorization.SystemSuiteNodes` — lista de adyacencia (`ParentNodeId`), con columnas de
+ metadatos SDLC.
+- `ums_authorization.SystemSuiteNodeActions` — puente N:M, nodo ↔ `ActionCode`.
+- Migración `20260715165202_AddSystemSuiteNodes`.
+
+## Referencias
+
+- Ficha de dominio: [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md)
+- Agregado: [`domain-es/authorization/system-suite.md`](../../domain-es/authorization/system-suite.md)
+- Código: `src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/`
diff --git a/docs/architecture/adrs/0090-recursive-menu-node-tree.md b/docs/architecture/adrs/0090-recursive-menu-node-tree.md
new file mode 100644
index 00000000..28721401
--- /dev/null
+++ b/docs/architecture/adrs/0090-recursive-menu-node-tree.md
@@ -0,0 +1,102 @@
+---
+adr: 0090
+title: Recursive MenuNode tree replaces the rigid Menu/SubMenu/Option hierarchy
+status: Accepted
+date: 2026-08-10
+tags: [EvolithSatellite, authorization, system-suite, navigation, domain-model]
+supersedes: none
+relates: [ADR-0071 auth graph engine, ADR-0081 semantic auth graph client contract]
+gap: G-029
+decision: D-009
+---
+
+# ADR-0090 — A Recursive `MenuNode` Tree Replaces `Menu` / `SubMenu` / `Option`
+
+> **Retroactive record.** The decision was implemented before it was written down. This ADR is
+> reconstructed from the shipped code (`Ums.Domain/Authorization/SystemSuite/MenuNode/`) and from
+> the fifteen-plus documents that already cite it as accepted. It documents what the system does
+> today; it does not propose a change.
+
+## Status
+
+Accepted. Implemented and in production.
+
+## Context
+
+The navigation topology of a system suite was modelled as a **fixed four-level chain**:
+
+```
+SystemSuite → Module → Menu → SubMenu → Option
+```
+
+Three separate owned entities — `Menu`, `SubMenu`, `Option` — expressed three levels of the same
+idea: *a place in a tree*. That rigidity produced concrete problems:
+
+1. **A mandatory level nobody wanted.** Anchoring an option directly under a menu was impossible;
+ a filler submenu had to be invented for it. Those fillers exist in seeded data and in customer
+ suites, and they show up in the UI as empty branches.
+2. **Functionality ↔ option was 1:1 and weak.** The link was an `ActionCode` string on the option,
+ with no foreign key. The same functionality could not be reached from two places in the menu,
+ and nothing stopped an option from naming an action that did not exist.
+3. **No governance metadata per node.** `Status` existed on the suite and the module. A node — the
+ thing a person actually navigates to, and the thing an auditor asks about — carried no owner,
+ no criticality, no traceability to the SDLC artefact that justifies it.
+4. **Three of everything.** Three aggregates' worth of commands, handlers, validators, EF
+ configurations and records to express one recursive concept. Adding a rule meant adding it
+ three times, and the three copies drifted.
+
+## Decision
+
+Model the navigation topology as a **single recursive entity**, `MenuNode`, owned by `Module`.
+
+- A `Module` owns a collection of **root nodes**; every node may nest children recursively
+ (adjacency list via `ParentNodeId`).
+- The node's role is classified by `NodeKind` — `Menu`, `SubMenu`, `Option` — **without fixing the
+ depth**. `Menu` and `SubMenu` behave as branches, `Option` as a leaf. The names survive as
+ *roles*, not as types, because that is the vocabulary the business already uses.
+- Functionality binding becomes **N:M** through the `SystemSuiteNodeActions` bridge table, so one
+ functionality can be reached from several places and every link points at an action that exists.
+- Every node carries `MenuNodeMetadata`, a value object with SDLC governance fields (owner,
+ criticality, impacted product, technical component, dependencies, evidence, SDLC traceability).
+ All optional; replaced atomically.
+
+| Dimension | Rigid model (withdrawn) | `MenuNode` tree |
+|---|---|---|
+| Depth | Fixed, 4 levels, submenu mandatory | Variable; submenu optional |
+| Functionality ↔ option | 1:1, weak (`ActionCode` string, no FK) | **N:M** via `SystemSuiteNodeActions` |
+| Governance metadata | `Status` on suite/module only | **Per-node SDLC metadata** (`MenuNodeMetadata`) |
+| Entities | `Menu`, `SubMenu`, `Option` | One recursive `MenuNode` |
+
+Operations stay on the `SystemSuite` aggregate root, which delegates to `Module`/`MenuNode`:
+`AddModuleRootNode`, `AddModuleChildNode`, `UpdateModuleNode`, `RemoveModuleNode` (node **and its
+subtree**), `ActivateModuleNode` / `DeactivateModuleNode`, `LinkModuleNodeAction` /
+`UnlinkModuleNodeAction`, `SetModuleNodeMetadata`.
+
+## Consequences
+
+**Gained.** One entity, one set of rules. Depth follows the product instead of the schema. A
+functionality reachable from two menus is expressible. Every node can answer "who owns this and
+why does it exist".
+
+**Paid.** The tree is stored flat and rebuilt in memory
+(`AuthorizationAggregateFactory.RehydrateNode`, grouping by `ParentNodeId`); a deep suite costs one
+pass over its nodes on load. Recursion admits cycles that a fixed chain could not, so the invariant
+"a node is not its own ancestor" now has to be enforced rather than assumed.
+
+**Migrated away.** `Menu`, `SubMenu` and `Option` and their commands, handlers, validators, EF
+configurations and records are **gone from the code**. `SystemSuiteMenuRecord`,
+`SystemSuiteSubMenuRecord` and `SystemSuiteOptionRecord` no longer exist. Documentation still
+describing the four-level chain as current is stale, not describing an alternative.
+
+## Persistence
+
+- `ums_authorization.SystemSuiteNodes` — adjacency list (`ParentNodeId`), with SDLC metadata columns.
+- `ums_authorization.SystemSuiteNodeActions` — N:M bridge, node ↔ `ActionCode`.
+- Migration `20260715165202_AddSystemSuiteNodes`.
+
+## References
+
+- Domain sheet: [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md) ·
+ [`domain/authorization/menu-node.md`](../../domain/authorization/menu-node.md)
+- Aggregate: [`domain-es/authorization/system-suite.md`](../../domain-es/authorization/system-suite.md)
+- Code: `src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/`
diff --git a/docs/architecture/adrs/0164-branch-closure-is-terminal.es.md b/docs/architecture/adrs/0164-branch-closure-is-terminal.es.md
new file mode 100644
index 00000000..805eb809
--- /dev/null
+++ b/docs/architecture/adrs/0164-branch-closure-is-terminal.es.md
@@ -0,0 +1,96 @@
+# ADR-0164: Cerrar una Sucursal Es Terminal y Lógico
+
+**Estado:** Aceptado
+**Fecha:** 2026-08-10
+**Responsable de Decisión:** Arquitectura
+**Reemplaza:** `Tenant.RemoveBranch` — el borrado físico de sucursales
+**Relacionado:** [ADR-0071](./0071-auth-graph-engine.es.md)
+
+---
+
+> **Registro retroactivo.** La decisión se implementó antes de escribirse. Este ADR se reconstruye
+> a partir del código en producción (`Ums.Domain/Identity/Tenant/Tenant.cs`, `…/Branch/`) y de los
+> documentos que ya la citan como aceptada.
+
+## Contexto
+
+`Tenant.RemoveBranch` quitaba la sucursal de la colección del agregado, y el reconciliador de
+colecciones hijas de EF lo traducía en un `DELETE` real. Tres cosas estaban mal:
+
+1. **Dejaba huérfanos en silencio.** `Profiles.BranchId` y `UserAccounts.BranchId` **no tienen clave
+ ajena** contra `TenantBranches`. Al borrar la sucursal, esas filas quedaban apuntando a nada, y
+ nada protestaba.
+2. **Hacía inexplicable el pasado.** Un despacho de 2024 registra la sucursal de la que salió. Una
+ vez borrada, la pregunta «¿de dónde salió esto?» no tiene respuesta. Para un operador aduanero
+ eso no es una pérdida cosmética.
+3. **Liberaba el código de la sucursal.** Una sucursal nueva podía tomar el código de una borrada,
+ así que una consulta sobre datos históricos no podía decir a cuál de las dos se refería.
+
+Había además una confusión más sutil: desactivar y eliminar se trataban como puntos de un mismo eje
+—desactiva y luego borra— cuando responden a preguntas distintas. «¿Esta sucursal opera ahora
+mismo?» es reversible. «¿Esta sucursal sigue existiendo como lugar?» no lo es.
+
+## Decisión
+
+### §2.1 — El cierre es lógico y terminal; la colección nunca encoge
+
+`CloseBranch` sustituye a `RemoveBranch`. No hay borrado físico. La fila permanece para que las
+operaciones pasadas sigan siendo explicables, y la colección de sucursales del agregado **nunca
+encoge**: la vía de escritura necesita ver las cerradas, y resolver una sucursal por id desde un
+perfil antiguo tiene que seguir encontrándola. Quien *lista* sucursales para un humano filtra por
+`!IsClosed` (véase `GetBranchesByTenantIdQueryHandler`).
+
+### §2.2 — El cierre lo bloquean las referencias VIVAS, y se dice cuáles
+
+Una sucursal no se cierra mientras existan registros ACTIVOS de `UserAccount` o `Profile` que la
+apunten. Lo ya eliminado o desactivado no bloquea: el recuento solo mira lo vivo. La guarda se
+verifica **antes** de actuar y rechaza nombrando qué bloquea —el análogo de un `ON DELETE
+RESTRICT`—, nunca arrastra en cascada ni huerfaniza. Ambas clases de bloqueo se informan a la vez,
+mediante `BlockingDependency`, para no obligar a quien opera a descubrirlas de una en una. El código
+de error único es `BRANCH_HAS_LIVE_REFERENCES`.
+
+Los recuentos viven en otros agregados, así que los aporta la aplicación y el dominio solo decide
+con ellos —la misma forma que `UserAccount.Delete(activeProfileCount)`—.
+
+### §2.3 — El código de una sucursal cerrada no se libera nunca
+
+La unicidad del código dentro del inquilino se evalúa **incluyendo las sucursales cerradas**.
+Liberarlo permitiría dos sucursales distintas con el mismo código bajo el mismo inquilino, y una
+consulta sobre un despacho de 2024 no podría decir a cuál se refiere. El índice único de la base
+tampoco filtra por estado, así que ambos lados dicen lo mismo.
+
+### §2.4 — Desactivar y cerrar son verbos DISTINTOS
+
+Una sucursal cerrada no se reactiva ni se desactiva, y al estado terminal **no se llega manipulando
+`IsActive`**. Desactivar es una pausa reversible; cerrar no se revierte. Ninguno lleva al otro:
+cerrar no exige desactivar antes, y desactivar no acerca al cierre.
+
+### §2.5 — Cada episodio del ciclo de vida se anota
+
+Apertura, desactivación, reactivación y cierre se anotan en `TenantBranchLifecycleEntries` **dentro
+de la misma transacción**, con fecha, actor y la foto de la sucursal (nombre y geocerca) de esa
+época. La bitácora no viaja con el agregado: se lee aparte.
+
+## Consecuencias
+
+**Se gana.** La historia sigue siendo explicable. No hay huérfanos. Un código significa una
+sucursal, para siempre. Las dos preguntas del ciclo de vida las responden dos verbos que ya no se
+confunden.
+
+**Se paga.** La colección de sucursales crece de forma monótona, así que un inquilino longevo
+arrastra al cargar todas las sucursales que tuvo. Las vías de lectura tienen que acordarse de
+filtrar `!IsClosed`; olvidarlo enseña sucursales cerradas a los usuarios, y esa es una clase de
+error real que este diseño introduce.
+
+**Se retira.** `RemoveBranchCommand` con su manejador, validador y respuesta **ya no existen en el
+código**, ni `BranchRemovedEvent` —lo sustituye `BranchClosedEvent`, que además lleva el código,
+porque quien lo consuma necesita saber QUÉ código queda ocupado y no puede resolverlo releyendo una
+fila que ya no debe listarse—. La documentación que siga describiendo `RemoveBranch`, o que dibuje
+la salida del agregado como un borrado condicionado a la inactividad, está obsoleta.
+
+## Referencias
+
+- Ficha de dominio: [`domain-es/identity/tenant.md`](../../domain-es/identity/tenant.md)
+- Código: `src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs` (`CloseBranch`),
+ `src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/`
+- Migración: `20260804194508_AddBranchClosureAndLifecycleLog`
diff --git a/docs/architecture/adrs/0164-branch-closure-is-terminal.md b/docs/architecture/adrs/0164-branch-closure-is-terminal.md
new file mode 100644
index 00000000..192c403c
--- /dev/null
+++ b/docs/architecture/adrs/0164-branch-closure-is-terminal.md
@@ -0,0 +1,100 @@
+---
+adr: 0164
+title: Closing a branch is terminal and logical; there is no physical deletion
+status: Accepted
+date: 2026-08-10
+tags: [EvolithSatellite, identity, tenant, lifecycle, auditability]
+supersedes: none
+relates: [ADR-0071 auth graph engine]
+---
+
+# ADR-0164 — Closing a Branch Is Terminal and Logical
+
+> **Retroactive record.** The decision was implemented before it was written down. This ADR is
+> reconstructed from the shipped code (`Ums.Domain/Identity/Tenant/Tenant.cs`, `…/Branch/`) and from
+> the documents that already cite it as accepted. It documents what the system does today.
+
+## Status
+
+Accepted. Implemented and in production.
+
+## Context
+
+`Tenant.RemoveBranch` removed the branch from the aggregate's collection, and the EF child-collection
+reconciler turned that into a real `DELETE`. Three things were wrong with it:
+
+1. **It orphaned rows in silence.** `Profiles.BranchId` and `UserAccounts.BranchId` have **no foreign
+ key** against `TenantBranches`. Deleting the branch left those pointing at nothing, and nothing
+ complained.
+2. **It made the past unexplainable.** A dispatch from 2024 records the branch it left from. Once
+ that branch is deleted, the question "where did this leave from?" has no answer. For a customs
+ operator that is not a cosmetic loss.
+3. **The branch code was freed.** A new branch could take the code of a deleted one, so a query over
+ historical data could not tell which of the two it meant.
+
+There was also a subtler confusion: deactivation and removal were treated as points on one axis —
+deactivate, then delete — when they answer different questions. "Is this branch operating right now?"
+is reversible. "Does this branch still exist as a place?" is not.
+
+## Decision
+
+### §2.1 — Closure is logical and terminal; the collection never shrinks
+
+`CloseBranch` replaces `RemoveBranch`. There is no physical deletion. The row stays so past
+operations remain explainable, and the aggregate's branch collection **never shrinks** — the write
+path needs to see closed branches, and resolving a branch by id from an old profile must still find
+it. Whoever *lists* branches for a human filters by `!IsClosed` (see `GetBranchesByTenantIdQueryHandler`).
+
+### §2.2 — Closure is blocked by LIVE references, and says which
+
+A branch does not close while ACTIVE `UserAccount` or `Profile` records point at it. Already-deleted
+or deactivated records do not block: the count only looks at what is live. The guard runs **before**
+acting and rejects naming what blocks — the analogue of `ON DELETE RESTRICT`, never a cascade and
+never an orphan. Both classes of blocker are reported at once, through `BlockingDependency`, so an
+operator does not discover them one at a time. The single error code is `BRANCH_HAS_LIVE_REFERENCES`.
+
+The counts live in other aggregates, so the application supplies them and the domain only decides —
+the same shape as `UserAccount.Delete(activeProfileCount)`.
+
+### §2.3 — A closed branch's code is never freed
+
+Code uniqueness within a tenant is evaluated **including closed branches**. Freeing the code would
+allow two different branches with the same code under one tenant, and a query about a 2024 dispatch
+could not say which. The database's unique index does not filter by state either, so both sides say
+the same thing.
+
+### §2.4 — Deactivating and closing are different verbs
+
+A closed branch is neither reactivated nor deactivated, and the terminal state is **not reachable by
+manipulating `IsActive`**. Deactivation is a reversible pause; closure does not revert. Neither
+leads to the other: closing does not require deactivating first, and deactivating does not bring a
+branch closer to closure.
+
+### §2.5 — Every lifecycle episode is journalled
+
+Opening, deactivation, reactivation and closure are each recorded in `TenantBranchLifecycleEntries`
+**within the same transaction**, with date, actor, and a snapshot of the branch (name and geofence)
+as it was then. The journal does not travel with the aggregate; it is read separately.
+
+## Consequences
+
+**Gained.** History stays explainable. No orphans. A code means one branch, for good. The two
+lifecycle questions are answered by two verbs that cannot be confused.
+
+**Paid.** The branch collection grows monotonically, so a long-lived tenant carries every branch it
+ever had on aggregate load. Read paths must remember to filter `!IsClosed`; forgetting shows closed
+branches to users, which is a real class of bug this design introduces.
+
+**Migrated away.** `RemoveBranchCommand` and its handler, validator and response are **gone from the
+code**, along with `BranchRemovedEvent` — replaced by `BranchClosedEvent`, which additionally carries
+the code, because a consumer needs to know *which* code stays occupied and cannot resolve it by
+re-reading a row that should no longer be listed. Documentation still describing `RemoveBranch`, or
+drawing branch removal as the aggregate's exit conditioned on inactivity, is stale.
+
+## References
+
+- Domain sheet: [`domain-es/identity/tenant.md`](../../domain-es/identity/tenant.md) ·
+ [`domain/identity/tenant.md`](../../domain/identity/tenant.md)
+- Code: `src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs` (`CloseBranch`),
+ `src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/`
+- Migration: `20260804194508_AddBranchClosureAndLifecycleLog`
diff --git a/docs/architecture/adrs/index.es.md b/docs/architecture/adrs/index.es.md
index 358e0380..a408d726 100644
--- a/docs/architecture/adrs/index.es.md
+++ b/docs/architecture/adrs/index.es.md
@@ -42,6 +42,8 @@ UMS es un repositorio satelite de `evolith_arch32`. El repositorio padre define
| [ADR-0080](./0080-auth-graph-preview-internal-pipeline.es.md) | Preview de auth graph interno | Aceptado |
| [ADR-0081](./0081-semantic-auth-graph-client-contract.es.md) | Contrato semantico del auth graph cliente | Propuesto |
| [ADR-0082](./0082-postgresql-authoritative-persistence-baseline.es.md) | Linea base autoritativa de persistencia PostgreSQL | Aceptado |
+| [ADR-0090](./0090-recursive-menu-node-tree.es.md) | El Árbol Recursivo MenuNode Sustituye a Menu/SubMenu/Option | Aceptado |
+| [ADR-0164](./0164-branch-closure-is-terminal.es.md) | Cerrar una Sucursal Es Terminal y Lógico | Aceptado |
---
diff --git a/docs/architecture/adrs/index.md b/docs/architecture/adrs/index.md
index 5f0a1127..aa124659 100644
--- a/docs/architecture/adrs/index.md
+++ b/docs/architecture/adrs/index.md
@@ -59,12 +59,14 @@ UMS is a satellite repository of `evolith_arch32`. The parent repository defines
| [ADR-0080](./0080-auth-graph-preview-internal-pipeline.md) | Auth Graph Preview — Internal vs External Pipeline | Accepted | > **Evolith candidate** - ADR has zero UMS-specific dependencies and is proposed for extraction to the Evolith parent architecture baseline.
| [ADR-0081](./0081-semantic-auth-graph-client-contract.md) | Semantic Auth Graph Client Contract — Code-First, ID-Optional | Proposed |
| [ADR-0082](./0082-postgresql-authoritative-persistence-baseline.md) | PostgreSQL Authoritative Persistence Baseline | Accepted |
+| [ADR-0090](./0090-recursive-menu-node-tree.md) | Recursive MenuNode Tree Replaces Menu/SubMenu/Option | Accepted |
+| [ADR-0164](./0164-branch-closure-is-terminal.md) | Branch Closure Is Terminal and Logical | Accepted |
---
## Bilingual Coverage (R-01 Compliance)
-All ADRs (0050-0082) now have Spanish translations:
+All ADRs (0050-0082) have Spanish translations, as do ADR-0090 and ADR-0164:
| ADR | Spanish | ADR | Spanish |
|-----|---------|-----|---------|
diff --git a/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.es.md b/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.es.md
index 1c71c0f0..f2b4ad78 100644
--- a/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.es.md
+++ b/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.es.md
@@ -25,7 +25,7 @@ Perfil observado:
| Frontera de aplicacion | MediatR y FluentValidation |
| Superficie API | Comandos REST y consultas GraphQL |
| Versionado | Versionado API por segmento URL |
-| Persistencia | EF Core con baseline SQL Server y soporte SQLite local |
+| Persistencia | EF Core sobre PostgreSQL, único proveedor relacional (ADR-0082). SQL Server y SQLite se retiraron; el esquema lo crean las migraciones de EF, no un bootstrapper |
| Operaciones | Logs estructurados, registro de telemetria, health checks, rate limits, background workers |
| Politicas transversales | Aspectos para auditoria, transacciones, validacion de tenant y logging |
diff --git a/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.md b/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.md
index 4925d011..cdf8fe8e 100644
--- a/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.md
+++ b/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.md
@@ -25,7 +25,7 @@ Observed profile:
| Application boundary | MediatR and FluentValidation |
| API surface | REST commands and GraphQL queries |
| Versioning | URL segment API versioning |
-| Persistence | EF Core with SQL Server baseline and local SQLite support |
+| Persistence | EF Core on PostgreSQL, the single relational provider (ADR-0082). SQL Server and SQLite were withdrawn; the schema is created by EF migrations, not by a bootstrapper |
| Operations | Structured logs, telemetry registration, health checks, rate limits, background workers |
| Cross-cutting policies | Aspects for audit, transactions, tenant validation, and logging |
diff --git a/docs/architecture/blueprints-es/database-design-er.md b/docs/architecture/blueprints-es/database-design-er.md
index cdef39e1..143c3395 100644
--- a/docs/architecture/blueprints-es/database-design-er.md
+++ b/docs/architecture/blueprints-es/database-design-er.md
@@ -1,5 +1,12 @@
# Modelo Entidad-Relación (E/R) - SQL Server 2022
+> **Parcialmente superado (ADR-0090).** La cadena `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` /
+> `FUNCTIONAL_OPTION` que aparece más abajo **ya no existe**. La navegación es hoy una única tabla
+> recursiva, `ums_authorization.SystemSuiteNodes` (lista de adyacencia por `ParentNodeId`), más la
+> tabla puente N:M `ums_authorization.SystemSuiteNodeActions`. El resto del documento sigue siendo
+> válido. Fuente de verdad actual: [ADR-0090](../adrs/0090-recursive-menu-node-tree.es.md) y
+> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md).
+
**Tipo de Documento:** Diseño de Base de Datos
**Estado:** Refactorizado (Alcance por Rol y Jerarquía Estricta)
**Arquitectura:** Marco Maestro Jerárquico (Control de 5 Niveles)
diff --git a/docs/architecture/blueprints-es/er-export-formats.md b/docs/architecture/blueprints-es/er-export-formats.md
index de58c236..bf3e00fb 100644
--- a/docs/architecture/blueprints-es/er-export-formats.md
+++ b/docs/architecture/blueprints-es/er-export-formats.md
@@ -1,5 +1,12 @@
# UMS E/R Model - Export Formats & Alternatives
+> **Parcialmente superado (ADR-0090).** La cadena `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` /
+> `FUNCTIONAL_OPTION` que aparece más abajo **ya no existe**. La navegación es hoy una única tabla
+> recursiva, `ums_authorization.SystemSuiteNodes` (lista de adyacencia por `ParentNodeId`), más la
+> tabla puente N:M `ums_authorization.SystemSuiteNodeActions`. El resto del documento sigue siendo
+> válido. Fuente de verdad actual: [ADR-0090](../adrs/0090-recursive-menu-node-tree.es.md) y
+> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md).
+
If Mermaid visualization is failing or insufficient, use these industry-standard formats to visualize the **Advanced IGA, Role Evolution & Hierarchical Configuration Framework**.
## 1. dbdiagram.io (DBML - Recommended)
diff --git a/docs/architecture/blueprints/data-model-consistency-review.md b/docs/architecture/blueprints/data-model-consistency-review.md
index fb3810e6..6869062c 100644
--- a/docs/architecture/blueprints/data-model-consistency-review.md
+++ b/docs/architecture/blueprints/data-model-consistency-review.md
@@ -1,5 +1,12 @@
# Data Model Consistency Review
+> **Superseded in part (ADR-0090).** The `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` /
+> `FUNCTIONAL_OPTION` chain shown below **no longer exists**. Navigation is now a single recursive
+> table, `ums_authorization.SystemSuiteNodes` (adjacency list via `ParentNodeId`), plus the N:M
+> bridge `ums_authorization.SystemSuiteNodeActions`. Everything else in this document still holds.
+> Current source of truth: [ADR-0090](../adrs/0090-recursive-menu-node-tree.md) and
+> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md).
+
**Document Type:** Architecture Consistency Review
**Status:** Active Reference
**Scope:** Conceptual model, DDD aggregate model, physical ER, and EF Core persistence records
diff --git a/docs/architecture/blueprints/database-design-er.md b/docs/architecture/blueprints/database-design-er.md
index ed8778a1..330fa5a9 100644
--- a/docs/architecture/blueprints/database-design-er.md
+++ b/docs/architecture/blueprints/database-design-er.md
@@ -1,5 +1,12 @@
# Entity-Relationship (E/R) Model - SQL Server 2022
+> **Superseded in part (ADR-0090).** The `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` /
+> `FUNCTIONAL_OPTION` chain shown below **no longer exists**. Navigation is now a single recursive
+> table, `ums_authorization.SystemSuiteNodes` (adjacency list via `ParentNodeId`), plus the N:M
+> bridge `ums_authorization.SystemSuiteNodeActions`. Everything else in this document still holds.
+> Current source of truth: [ADR-0090](../adrs/0090-recursive-menu-node-tree.md) and
+> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md).
+
**Document Type:** Database Design
**Status:** Refactored (Role-Scoped & Strict Hierarchy)
**Architecture:** Hierarchical Master Framework (5-Level Control)
diff --git a/docs/architecture/blueprints/er-export-formats.md b/docs/architecture/blueprints/er-export-formats.md
index 3a8263ac..d3f769ca 100644
--- a/docs/architecture/blueprints/er-export-formats.md
+++ b/docs/architecture/blueprints/er-export-formats.md
@@ -1,5 +1,12 @@
# UMS E/R Model - Export Formats & Alternatives
+> **Superseded in part (ADR-0090).** The `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` /
+> `FUNCTIONAL_OPTION` chain shown below **no longer exists**. Navigation is now a single recursive
+> table, `ums_authorization.SystemSuiteNodes` (adjacency list via `ParentNodeId`), plus the N:M
+> bridge `ums_authorization.SystemSuiteNodeActions`. Everything else in this document still holds.
+> Current source of truth: [ADR-0090](../adrs/0090-recursive-menu-node-tree.md) and
+> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md).
+
If Mermaid visualization is failing or insufficient, use these industry-standard formats to visualize the **Advanced IGA, Role Evolution & Hierarchical Configuration Framework**.
## 1. dbdiagram.io (DBML - Recommended)
diff --git a/docs/architecture/blueprints/service-entity-map.md b/docs/architecture/blueprints/service-entity-map.md
index 45dbad49..c88203a5 100644
--- a/docs/architecture/blueprints/service-entity-map.md
+++ b/docs/architecture/blueprints/service-entity-map.md
@@ -1,5 +1,12 @@
# Service-Entity Map & Data Ownership
+> **Superseded in part (ADR-0090).** The `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` /
+> `FUNCTIONAL_OPTION` chain shown below **no longer exists**. Navigation is now a single recursive
+> table, `ums_authorization.SystemSuiteNodes` (adjacency list via `ParentNodeId`), plus the N:M
+> bridge `ums_authorization.SystemSuiteNodeActions`. Everything else in this document still holds.
+> Current source of truth: [ADR-0090](../adrs/0090-recursive-menu-node-tree.md) and
+> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md).
+
This document serves as the authoritative mapping between system entities, their Bounded Contexts, owning services, and database schemas within the UMS enterprise ecosystem.
---
diff --git a/docs/architecture/e2e-dashboard-parallelization-plan.es.md b/docs/architecture/e2e-dashboard-parallelization-plan.es.md
new file mode 100644
index 00000000..6224262b
--- /dev/null
+++ b/docs/architecture/e2e-dashboard-parallelization-plan.es.md
@@ -0,0 +1,253 @@
+# Plan de paralelización — cerrar la integración E2E UMS ↔ Tablero SDLC
+
+**Repositorios:** `ums` (rama `develop`) · `evolith-core` (rama `develop`)
+**Fecha:** 2026-08-02 · **Autor:** Arquitecto Enterprise · **Tipo:** Plan de ejecución
+**Insumo:** [análisis de integración E2E](./analisis-integracion-e2e-ums-tablero-sdlc.md) · [diseño de selección de sistema](./diseno-seleccion-de-sistema-en-autenticacion.md)
+**Norma vigente:** `ADR-0156` (grafo por API, sistema solicitado, multi-perfil) y `ADR-0157` (firma asimétrica, token corto), ambos `Aceptado` en `evolith-core`
+
+> **Para qué sirve este documento.** El trabajo pendiente está registrado en dos repositorios y se puede atacar por varios frentes a la vez. Lo que sigue reparte ese trabajo en **lotes que no se pisan**, separa lo que **bloquea la prueba** de lo que es **deuda que puede esperar**, y dice **qué no se puede cerrar hoy** — porque descubrirlo a media tarde cuesta más que leerlo ahora.
+
+---
+
+## 0. Estado real del entorno, medido hoy
+
+Todo lo de esta sección está verificado el 2026-08-02 contra los procesos vivos. Lo que no se pudo medir se declara, no se rellena.
+
+| Pieza | Estado | Cómo se comprobó |
+| :--- | :--- | :--- |
+| UMS | Vivo en `http://localhost:5080`, `/health` → **200** | `curl` |
+| Suite `SDLC` | **Cargada** en el inquilino `BEYONDNET` | `GET /api/v1/profiles` la devuelve en 10 perfiles |
+| Cuentas del Tablero | **8**, activas, con contraseña determinista | `admin.sdlc.sdlc@`, `arquitecto.sdlc@`, `auditor.sdlc@`, `directorio.sdlc@`, `equipo.sdlc@`, `pmo.sdlc@`, `product.owner.sdlc@`, `tech.lead.sdlc@` |
+| Multi-perfil | **Dos sujetos reales**: `equipo.sdlc@` (`EQUIPO` + `AUDITOR`) y `pmo.sdlc@` (`PMO` + `DIRECTORIO`) | `GET /api/v1/profiles?page=1&pageSize=100` → 23 perfiles / 21 usuarios |
+| Autenticación de cliente | **200** con grafo `2.3.0` y `context.systemSuite.code = SDLC` | `POST /api/v1/client/authenticate?format=json` |
+| **Concesiones del grafo** | **Vacías para todos los roles**: `menuAccess: []`, `domainPermissions: []`, `scopes: []` | Tres roles con matrices muy distintas (`TECH_LEAD` 20 concesiones, `ARQUITECTO` 11, `AUDITOR` 1) devuelven grafos **idénticos** |
+| Causa | Las **8 plantillas de la suite `SDLC` están en `Draft`**; el cargador no las publica | `GET /api/v1/permission-templates` → 9 logísticas `Published`, 8 de `SDLC` `Draft` |
+| Tablero — servidor | Vivo en `:4317`, `GET /api/auth/estado` → `{"configurado":true}` | `curl` |
+| Tablero — web | Viva en `:5317` | `curl` |
+| Tablero — login | **Corregido** el 502: `interpretarGrafo()` acepta la cadena serializada | `procesarLogin` → **200 con `Set-Cookie`** |
+| Tablero — formulario | Sin campo de inquilino; lo aplica el servidor | `LoginPage.jsx` |
+| **Tablero — sesión** | **`{"autenticado":false}` con cookie válida** | `resolverSesion` sobre la cookie que acaba de emitir el login |
+
+### 0.1 El 401 del Tablero: diagnosticado
+
+El encargo lo describía como un bloqueante sin causa. **Tiene causa, y está cerrada la cadena entera:**
+
+1. El proceso del servidor del Tablero corría con `UMS_BASE_URL=http://localhost:5000`. UMS escucha en **5080**.
+2. En macOS el puerto 5000 lo ocupa el receptor AirPlay de `ControlCenter`: `POST http://localhost:5000/api/v1/client/authenticate` responde **`403 Forbidden`** con `Server: AirTunes/950.7.1`.
+3. `procesarLogin` colapsa **cualquier** 401 o 403 del destino en `{"error":"Credenciales inválidas."}`. De ahí el 401 con credenciales correctas.
+4. La misma credencial contra `:5080` devuelve **200**, y `procesarLogin` con `UMS_BASE_URL=http://localhost:5080` devuelve **200 con cookie**.
+
+Es decir: **el mensaje de error señalaba a la única pieza que estaba bien.** El arreglo inmediato es un valor de entorno; el arreglo duradero es que el servidor compruebe que su destino es UMS y no lo dé por supuesto — registrado como `G-283` en `evolith-core`.
+
+### 0.2 El bloqueante que el 401 tapaba
+
+Con `UMS_BASE_URL` corregido el login pasa, y aparece el siguiente: **la sesión no se sostiene**. `resolverSesion` sigue haciendo `grafoDeToken(payload)` sobre un token que —desde `ADR-0156` §2.3— ya no lleva grafo, así que devuelve `{autenticado:false}` **siempre**. El usuario teclea su contraseña, la aplicación la acepta y lo devuelve al formulario.
+
+Registrado como `G-290` en `evolith-core`. **Es el bloqueante número uno del camino crítico**, y es trabajo nuevo, no configuración: falta la caché de grafo en el servidor que `ADR-0156` §2.3 especifica.
+
+---
+
+## 1. Camino crítico y deuda: la separación
+
+La prueba que justifica el encargo es una sola frase: **el Tablero se autentica contra UMS y su interfaz cambia según el perfil del grafo.** Todo lo que no haga falta para que esa frase sea cierta y observable es deuda, por importante que sea.
+
+### 1.1 Camino crítico — sin esto no hay prueba
+
+| # | Qué | Dónde | Gap | Por qué bloquea |
+| ---: | :--- | :--- | :--- | :--- |
+| 1 | Apuntar `UMS_BASE_URL` a `:5080` | Tablero (entorno) | `G-283` | Sin esto el login es 401 y todo lo demás es invisible |
+| 2 | Sesión por API + caché de grafo en el servidor | `evolith-core` | `G-290` | Sin esto se entra y se sale en la misma pantalla |
+| 3 | Publicar las 8 plantillas de la suite `SDLC` | `ums` | `G-220` | Sin esto el grafo llega vacío: no hay nada que gatear |
+| 4 | Alinear los códigos de menú (decisión **D2**) | ambos | `G-216` / `G-277` | Con concesiones pero códigos distintos, la barra sigue vacía |
+| 5 | `systemCode` en la autenticación de cliente | `ums` | `G-204` | Hoy funciona **por casualidad**: las 8 cuentas solo tienen perfiles `SDLC`. En cuanto una tenga perfil en otra suite, el desempate entrega el grafo equivocado |
+| 6 | `profiles[].id` + `POST /client/switch-profile` | `ums` | `G-205`, `G-206` | El cambio de perfil es requisito funcional confirmado, y hoy el contrato ofrece la operación y retiene su clave |
+
+Sobre el punto 5, con honestidad: **una primera pasada verde es posible sin `systemCode`**, porque las cuentas del Tablero no tienen perfiles fuera de `SDLC`. Lo que no es posible sin él es afirmar que la especificación está implementada. Se mantiene en el camino crítico porque el propio cliente lo puso ahí, pero **no bloquea el primer semáforo verde** — y esa distinción es la que permite paralelizar.
+
+### 1.2 Deuda declarada — no bloquea, y no se olvida
+
+| Qué | Dónde | Gap | Por qué puede esperar |
+| :--- | :--- | :--- | :--- |
+| Migración a RS256 + JWKS (etapas E0–E3) | ambos | `G-199`, `G-278`, `G-203` | HS256 funciona hoy; `ADR-0157` ya fijó el destino y sus etapas |
+| Token de 15 minutos (etapa E4) | `ums` | `G-219` | **Prohibido antes** del refresco por portador (`ADR-0157` §4.6) |
+| Refresco por portador | `ums` | `G-218`, `G-213`, `G-187` | La prueba cabe en la vida del token si ningún escenario dura más de 60 min |
+| SDK: `graph` como cadena; fixtures imposibles | `ums` | `G-207`, `G-208` | El Tablero **no usa el SDK**: vendoriza su contrato. Bloquea a otros consumidores, no a esta prueba |
+| Pin de esquema del arnés RoboSoft en `2.2.0` | `ums` | `G-210` | Afecta al carril existente, no al nuevo |
+| `SameSite=Lax` vs `Strict` | `evolith-core` | `G-284` | Divergencia a declarar; no rompe el flujo |
+| Cookie `Secure` y topología | `evolith-core` | `G-282` | Solo muerde fuera de `localhost`; el primer ciclo es `localhost` |
+| Clúster irreproducible, dev-abierto en el pod | `evolith-core` | `G-285`, `G-281` | El primer ciclo corre contra procesos locales, no contra los clústeres |
+| Desempate por GUID; siembra multi-perfil | `ums` | `G-211`, `G-209` | Hay sujeto multi-perfil vivo; lo que falta es que sea reproducible desde cero |
+| SDK sin verificar firma (los cuatro, no solo Express) | `ums` | `G-217` | Ningún consumidor de esta prueba lo usa. **Sigue siendo grave**: no es «poco importante», es «no bloqueante». Cerrado el 2026-08-03 |
+
+---
+
+## 2. Reglas de reparto
+
+Tres restricciones mandan sobre el reparto, y no son negociables:
+
+1. **Dos agentes sobre la misma solución .NET se estorban.** `dotnet build` bloquea `obj/` y `bin/`; dos compilaciones concurrentes sobre el mismo árbol producen fallos que se leen como errores de código. Todo lote que toque `src/apps/ums.api/**` va en **worktree propio**.
+2. **`ums` y `evolith-core` son repositorios distintos**, luego naturalmente paralelos: dos lotes en repositorios distintos nunca se pisan por construcción.
+3. **El árbol Node del Tablero y el árbol .NET de UMS no comparten nada.** Un lote en `reference/governance/tablero-ejecutivo/app/**` y otro en `src/apps/ums.api/**` pueden correr a la vez sin coordinación.
+
+Y una regla de higiene que ya está aprendida: el `pre-push` escanea todo el árbol con gitleaks, así que **ningún lote versiona artefactos de prueba con credenciales**. Las contraseñas de las cuentas `*.sdlc@` son deterministas y derivables; no hace falta anotarlas en ninguna parte.
+
+---
+
+## 3. Los lotes
+
+### Ola 1 — arranca ya, los cuatro a la vez
+
+#### Lote A · Sesión y caché de grafo en el Tablero — **camino crítico**
+
+| Campo | Contenido |
+| :--- | :--- |
+| **Repositorio** | `evolith-core` |
+| **Gaps** | `G-290` (bloqueante), `G-280`, `G-283` |
+| **Toca** | `reference/governance/tablero-ejecutivo/app/server/src/auth-ums.js` (`resolverSesion` + caché), `server/test/auth-ums.test.mjs`, arranque en `server/src/index.js` para la comprobación de destino |
+| **NO toca** | Nada bajo `app/web/**` (es del lote B) · Nada de `ums` · `net-guard.js`, que es correcto donde está y solo se aplica a `evidencia_url` |
+| **Verificación** | `POST /api/auth/login` seguido de `GET /api/auth/sesion` devuelve `autenticado:true` **con grafo**, en el mismo proceso y tras reiniciarlo; prueba en negativo con grafo vencido → `autenticado:false`; arrancar con `UMS_BASE_URL` apuntando a un no-UMS **falla o lo dice**, en vez de acusar a las credenciales |
+| **Esfuerzo** | **Medio** |
+
+La caché es el único componente de servidor genuinamente nuevo del gate. Se indexa por `jti` o `session_tracking_id`, vive hasta `graph_valid_until` y revalida contra `GET /api/v1/client/graph`. Ante UMS caído **no** se confunde «no puedo revalidar» con «no estás autenticado» mientras la copia siga vigente (`ADR-0156` §2.10).
+
+#### Lote B · Publicación de plantillas y códigos de menú — **camino crítico**
+
+| Campo | Contenido |
+| :--- | :--- |
+| **Repositorio** | `ums` (cargador) **+** `evolith-core` (cliente web) — coordinado por la decisión **D2** |
+| **Gaps** | `G-220` (bloqueante), `G-216`, `G-222`, `G-277` |
+| **Toca** | `src/provisioning/sdlc/cargar-en-ums.mjs` (publicar las plantillas al cierre y **verificar** el estado resultante; normalizar `correoDe()`), `src/provisioning/sdlc/sdlc-suite.json` **o** `app/web/src/components/common.jsx` — uno de los dos, según D2 |
+| **NO toca** | **Nada de `src/apps/ums.api/**`**: el cargador es Node y no compila la solución. Nada del servidor del Tablero (lote A) |
+| **Verificación** | `GET /api/v1/permission-templates` no devuelve ninguna plantilla de `SDLC` en `Draft`; y los grafos de `TECH_LEAD`, `ARQUITECTO` y `AUDITOR` traen **`menuAccess` distinto entre sí y no vacío**, con códigos que el cliente web reconoce |
+| **Esfuerzo** | **Corto** el publicado; **corto o largo** la alineación de códigos, según D2 |
+
+> **La decisión D2 sigue abierta y hay que tomarla antes de empezar el lote.** `ADR-0156` §2.9 mantiene la convención `TABLERO.*` y no elige lado. Cambiar cinco constantes en `common.jsx` cuesta minutos; renombrar 64 nodos ya trazados a 96 rutas de API arriesga la trazabilidad del inventario (`SD-05`). **Recomendación: que ceda el cliente web en el primer ciclo, declarando la divergencia respecto del ADR**, con la adopción de la convención como destino. Lo que no vale es aplicarlo en silencio.
+
+#### Lote C · Contrato de UMS: `systemCode`, `accessState` y `profiles[].id` — **camino crítico (especificación)**
+
+| Campo | Contenido |
+| :--- | :--- |
+| **Repositorio** | `ums`, **en worktree propio** |
+| **Gaps** | `G-204`, `G-205`, `G-184` (mitad de contrato), `G-211` |
+| **Toca** | `ClientAuthEndpoints.cs`, `AuthenticateUserCommand`, `AuthorizationGraphBuilderService`, `AuthGraphPayload.cs`, esquema del grafo a `2.4.0` |
+| **NO toca** | `AuthEndpoints.cs` en su bloque `switch-profile`/`switch-tenant`: **es del lote D** y los dos lotes chocarían en el mismo archivo · Ningún archivo del Tablero · El cargador de provisión (lote B) |
+| **Verificación** | `POST /client/authenticate` con `systemCode: "SDLC"` devuelve el grafo de `SDLC`; con un código inexistente y con uno sin perfil devuelve **el mismo 200** con `accessState: "NoProfileInSystem"` —indistinguibles, sin consultar el catálogo—; `profiles[].id` presente **siempre**, con `AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA` en `false` |
+| **Esfuerzo** | **Medio** |
+
+La propiedad anti-enumeración es estructural, no cosmética: el filtro se aplica **sobre los perfiles que el usuario ya tiene** y tiene prohibido consultar el catálogo de sistemas por código. Si hubiera dos ramas, alguna acabaría divergiendo en un mensaje, un status o un tiempo.
+
+#### Lote E · Deuda de contrato de los SDK — **no bloquea**
+
+| Campo | Contenido |
+| :--- | :--- |
+| **Repositorio** | `ums`, árbol principal (TypeScript y JSON, sin compilar .NET) |
+| **Gaps** | `G-207`, `G-208`, `G-210`, `G-217` |
+| **Toca** | `src/libs/sdk/typescript/**`, `src/libs/sdk/contracts/fixtures/**`, `src/tests/e2e-functional/robosoft/contexts/configuration.py` |
+| **NO toca** | `src/apps/ums.api/**` (lote C) · el cargador de provisión (lote B) · nada del Tablero |
+| **Verificación** | Un login por SDK contra la API real devuelve grafo, no `AuthGraphSchemaMissing`; ningún fixture declara `profiles: []` junto a `onboardingPending: false`; el pin de `schemaVersion` **se deriva del contrato publicado** en vez de repetirlo |
+| **Esfuerzo** | **Medio** |
+
+Se puede arrancar hoy y terminar después del lote C — pero **el pin de esquema y los fixtures nuevos se cierran cuando el contrato `2.4.0` exista**, no antes. Ver §4.
+
+### Ola 2 — depende de la ola 1
+
+#### Lote D · Cambio de perfil por el carril de satélite
+
+| Campo | Contenido |
+| :--- | :--- |
+| **Repositorio** | `ums`, **mismo worktree que el lote C, después de él** |
+| **Gaps** | `G-206`, `G-177` (mitad de consumo), `G-201` |
+| **Toca** | `ClientAuthEndpoints.cs` (endpoint nuevo `POST /client/switch-profile`), reutilizando `SwitchProfileCommand` y `BuildForProfileAsync` **sin tocarlos** |
+| **NO toca** | `POST /auth/switch-profile`: **no se extiende**, porque valida el token a mano con `ValidateIssuer=false` (`G-201`) y devuelve una cookie de portal que el satélite no tiene |
+| **Depende de** | Lote C: sin `profiles[].id` el cliente no tiene qué enviar |
+| **Verificación** | `equipo.sdlc@` cambia de `AUDITOR` a `EQUIPO` con su portador y recibe un grafo distinto y coherente; el mismo intento sin `profileId` válido → 4xx |
+| **Esfuerzo** | **Medio** |
+
+#### Lote F · El robot E2E
+
+| Campo | Contenido |
+| :--- | :--- |
+| **Repositorio** | `ums`, árbol principal |
+| **Gaps** | cierra la mitad de validación de `G-276` (`evolith-core`) |
+| **Toca** | `src/tests/e2e-functional/robosoft/integracion-tablero/**` (nuevo) y `scripts/certify-e2e.sh` (`--carril c`) |
+| **NO toca** | `robosoft/api/**` ni `robosoft/contexts/**`: son el carril existente. **No se crea un `tests/e2e/` paralelo** |
+| **Depende de** | Lotes A y B **cerrados**. Escribir el robot antes es escribir pruebas rojas que describen funcionalidad ausente — el error que ya documenta `G-189` |
+| **Verificación** | La aserción es de **concordancia, no de presencia**: el conjunto de secciones visibles es exactamente el conjunto de códigos con `Allow` efectivo en el grafo de ese perfil. Así falla igual quien ve de más y quien ve de menos. «El administrador ve más que el auditor» pasaría hoy en verde sobre tres interfaces vacías, y ese es justo el falso positivo contra el que existe la prueba |
+| **Esfuerzo** | **Largo** |
+
+#### Lote G · Entorno reproducible
+
+| Campo | Contenido |
+| :--- | :--- |
+| **Repositorio** | ambos |
+| **Gaps** | `G-214` (`ums`), `G-285`, `G-281` (`evolith-core`) |
+| **Toca** | `scripts/` de entorno en UMS, `k8s/kind-cluster.yaml` y `k8s/app.yaml` del Tablero |
+| **NO toca** | Ningún archivo de aplicación de los lotes A–E |
+| **Verificación** | Una ejecución en frío desde cero, **dos veces**, con el mismo punto de acceso y el mismo resultado |
+| **Esfuerzo** | **Largo** |
+
+Es independiente del resto **y no está en el camino crítico del primer ciclo**, porque el primer ciclo corre contra procesos locales. Se puede arrancar en paralelo desde el minuto uno con quien sobre.
+
+### 3.1 Mapa de concurrencia
+
+| | A (arch/server) | B (ums/prov + arch/web) | C (ums/.NET wt) | E (ums/sdk) | G (infra) |
+| :--- | :---: | :---: | :---: | :---: | :---: |
+| **A** | — | ✅ | ✅ | ✅ | ✅ |
+| **B** | ✅ | — | ✅ | ✅ | ✅ |
+| **C** | ✅ | ✅ | — | ⚠️ | ✅ |
+| **E** | ✅ | ✅ | ⚠️ | — | ✅ |
+| **G** | ✅ | ✅ | ✅ | ✅ | — |
+
+⚠️ **C y E no chocan en archivos, sí en secuencia**: E cierra sus fixtures y su pin contra el contrato que C publica. Corren a la vez; E termina después.
+
+**Cuatro agentes a la vez sin coordinación:** A, B, C y E. G entra como quinto si hay a quién asignarlo. D y F entran cuando sus precondiciones estén cerradas.
+
+---
+
+## 4. Dependencias duras
+
+Estas no son preferencias de orden: violarlas produce trabajo que hay que rehacer.
+
+| # | Antes | Después | Por qué |
+| ---: | :--- | :--- | :--- |
+| 1 | **Contrato `2.4.0`** (lote C) | SDK, fixtures y pin del arnés (lote E) | Un fixture escrito contra el contrato viejo hay que reescribirlo; capturarlo de la API real exige que la API real ya lo emita |
+| 2 | **`profiles[].id`** (lote C) | `POST /client/switch-profile` (lote D) | El cliente no puede enviar una clave que el contrato no publica |
+| 3 | **Refresco por portador** (`G-218`) | **Token de 15 minutos** (`G-219`) | `ADR-0157` §4.6 lo declara condición de secuencia no negociable: sin refresco, acortar el token pide contraseña **cuatro veces por hora**, y una medida de seguridad que entrena a teclear credenciales sin pensar es una pérdida neta |
+| 4 | **Verificadores dobles** (E1 de `ADR-0157`) | **Conmutación a RS256** (E2) | Publicar la clave pública y seguir aceptando HS256 abre la confusión de algoritmo de RFC 8725 §2.1: peor que el statu quo |
+| 5 | **Conmutación** (E2) + una vida de token | **Borrado de `UMS_JWT_SECRET`** (E3) | Mientras exista el secreto compartido, existe el emisor paralelo. E3 **no es opcional** y no puede quedar detrás de una bandera |
+| 6 | **Publicar las plantillas** (lote B) | **Robot de gating** (lote F) | Con el grafo vacío, el robot pasa en verde sin ejercer nada |
+| 7 | **Sesión sostenida** (lote A) | **Robot de sesión y gating** (lote F) | Sin sesión no hay segunda petición que gatear |
+| 8 | **Decisión D2** | Cualquier línea de código del lote B sobre códigos | Alinear hacia el lado equivocado cuesta dos veces |
+
+---
+
+## 5. Qué NO se puede cerrar hoy, y por qué
+
+Escrito antes de empezar, no al final.
+
+| Qué | Por qué no |
+| :--- | :--- |
+| **La migración a RS256 completa (`G-199`, `G-278`, `G-203`)** | No es una tarea: es una secuencia de cinco etapas con esperas obligadas entre ellas. E2→E3 exige **dejar pasar una vida de token completa** antes de borrar el secreto. Se puede empezar E0 hoy; no se puede terminar hoy |
+| **El token de 15 minutos (`G-219`)** | Depende de `G-218` (refresco por portador), que es trabajo de UMS y no está hecho. `ADR-0157` §4.6 lo prohíbe expresamente antes |
+| **La siembra reproducible del multi-perfil (`G-209`)** | Hoy hay sujeto vivo —`equipo.sdlc@` y `pmo.sdlc@`—, pero lo creó el cargador contra la instancia, **no la siembra**. Una base recreada desde cero vuelve a no tener ninguno. Cerrarlo exige tocar el sembrador bajo `SeedDevData && !IsProduction`, que es otro lote y otra clase de riesgo |
+| **El escenario de dos clústeres (`G-214`, `G-285`)** | Los dos clústeres existen y **ninguno está en estado de servir**: el namespace `ums` a 0 réplicas, `ums-uat` sin Ingress, el clúster del Tablero sin controlador de Ingress y publicando un puerto que su manifiesto no declara. Reconstruirlo de forma determinista es un lote largo por sí solo |
+| **TLS real (T2)** | Decidido fuera del primer ciclo (**D4**). Introducirlo ahora mezcla fallos de certificado con fallos de contrato, y los de certificado enmascaran a los otros |
+| **La versión del plugin (`G-287`)** | La serie publicada no es monótona (llegó a `3.1.0` y volvió a `1.37.0`), así que una caché tibia ancla al consumidor a un estándar viejo. Recuperar la monotonía o retirar las etiquetas 2.x/3.x es una **decisión de gobernanza del núcleo**, no una tarea de este encargo |
+| **Las 18 fichas de gap que faltan (`G-289`)** | Se ha puesto la comprobación para que el tablero no prometa fichas inexistentes, pero crear dieciocho documentos de gobernanza es trabajo propio, no un efecto colateral de este |
+| **Afirmar que la prueba «pasa»** | Hasta que los lotes A y B estén cerrados, cualquier verde es un falso positivo: hoy el gating no puede fallar porque no hay nada que conceder |
+
+---
+
+## 6. Secuencia recomendada
+
+1. **Ahora, sin esperar a nadie:** corregir `UMS_BASE_URL` a `:5080` y **tomar la decisión D2**. Son minutos y desbloquean dos lotes.
+2. **Ola 1 en paralelo:** A (Tablero/servidor), B (provisión + códigos), C (contrato .NET, en worktree), E (SDK y fixtures). Opcionalmente G.
+3. **Puerta de la ola 1 — una sola comprobación:** `POST /api/auth/login` seguido de `GET /api/auth/sesion` devuelve `autenticado:true` **con grafo no vacío**, y dos perfiles distintos producen dos `menuAccess` distintos. Mientras eso no ocurra, **no se escribe el robot**.
+4. **Ola 2:** D (cambio de perfil) y F (robot), en paralelo entre sí.
+5. **Deuda, por su cuenta:** las etapas E0–E3 de `ADR-0157`, el refresco por portador y la siembra reproducible.
+
+---
+
+
diff --git a/docs/architecture/e2e-sdlc-dashboard-integration-analysis.es.md b/docs/architecture/e2e-sdlc-dashboard-integration-analysis.es.md
new file mode 100644
index 00000000..8d5b5105
--- /dev/null
+++ b/docs/architecture/e2e-sdlc-dashboard-integration-analysis.es.md
@@ -0,0 +1,748 @@
+# Análisis previo de la integración E2E — UMS ↔ Tablero Ejecutivo SDLC en dos clústeres kind
+
+**Repositorios:** `ums` (rama `develop`, commit `361fdd6`) · `evolith-core` (rama `develop`, commit `ab5c85b`)
+**Fecha:** 2026-08-02 · **Revisión:** 2026-08-02 (§0 — especificación confirmada por el cliente) · **Autor:** Arquitecto Enterprise · **Tipo:** Análisis de arquitectura previo a construcción
+**Método:** verificación adversarial. Toda afirmación de este documento se apoya en una ruta de archivo con línea, en una respuesta HTTP real capturada contra la instancia viva de UMS en `http://localhost:5080`, o en la salida de `kubectl`/`docker` sobre los clústeres existentes. Lo que no se pudo verificar se declara como **incógnita**, no se rellena.
+
+> **Por qué existe este documento.** El encargo anterior escribió una capa de autenticación nueva en el Tablero ignorando que `ADR-0155` ya la tenía implementada en `develop` — el mismo error queda registrado en [`G-189`](../../GAPS.md), que invalidó a `G-186` por haberse comprobado sobre un checkout desactualizado. Este análisis se escribe para que la tercera vez no ocurra: la §3 es un **inventario de lo que NO hay que volver a escribir**, y es la sección que debe leerse antes que ninguna otra.
+
+---
+
+## 0. Actualización 2026-08-02 — especificación confirmada por el cliente
+
+El cliente confirmó las siete decisiones de §10 (**D1–D7**) y precisó la especificación funcional. Esta sección registra qué cambió, qué se produjo y **qué queda invalidado de lo escrito antes**. Prevalece sobre cualquier afirmación anterior de este documento que la contradiga.
+
+### 0.1 La especificación, tal como quedó
+
+1. **Perfil = inquilino + sistema + usuario**; el **rol** es la dimensión que varía. Un mismo usuario, mismo inquilino y mismo sistema, puede tener más de un perfil por rol.
+2. El **inquilino es `BEYONDNET` siempre**, salvo indicación contraria.
+3. El **login pide usuario y contraseña**. Ni rol, ni inquilino.
+4. El **cliente envía el código de sistema** cuando quiere acotar. El del Tablero es `SDLC` y lo envía siempre.
+5. El `systemCode` es **opcional**: sin él, UMS devuelve los perfiles del usuario en el inquilino **de todos los sistemas** (multiproducto); con él, filtra.
+6. Con **más de un perfil**, el cliente ofrece **cambio de perfil**, reutilizando lo que UMS ya publica.
+7. El Tablero **no calcula permisos**: usa exclusivamente el grafo.
+
+### 0.2 Lo producido
+
+| Artefacto | Ruta | Qué fija |
+| :--- | :--- | :--- |
+| **ADR-0156** (nuevo, `Aceptado`, supersede a `ADR-0155`) | `evolith-core` · `reference/architecture/adrs/core/0156-autenticacion-tablero-ums-sistema-solicitado-grafo-por-api.es.md` | El grafo se obtiene por API y se cachea (§2.3); el cliente declara su sistema (§2.5); el multi-perfil se resuelve con el mecanismo de UMS (§2.6); vigencia y revalidación del grafo (§2.10) |
+| **ADR-0155** | `evolith-core` · `…/core/0155-autenticacion-obligatoria-tablero-contra-ums.es.md` | Pasa a `Supersedido`, con nota de continuidad: lo que sigue vigente y lo que se retira |
+| **Diseño del contrato de UMS** | [`diseno-seleccion-de-sistema-en-autenticacion.md`](./diseno-seleccion-de-sistema-en-autenticacion.md) | Dónde entra `systemCode`, cómo filtra, qué devuelve con 0/1/N perfiles, versión `2.4.0` del grafo, SDK y fixtures, cambio de perfil por el carril de satélite |
+
+**La contradicción de gobernanza está resuelta.** `ADR-0155` §2.3 ya no rige; `D-031` y `ADR-0156` §2.3 dicen lo mismo. **Ya se puede tocar `auth-ums.js`** sin violar `S-06`, que es lo que §7.1 y el bloque 0.1 de §8 exigían como prerrequisito.
+
+### 0.3 Lo que la especificación invalida de este documento
+
+| Dónde | Qué decía | Qué rige ahora |
+| :--- | :--- | :--- |
+| §1.4, §4.6, §7.1, §8 bloque 0.1 | «Hay una contradicción abierta entre `ADR-0155` §2.3 (`Aceptado`) y `D-031`; corregir el Tablero antes de enmendar es escribir contra la norma vigente» | **Cerrado.** `ADR-0155` está `Supersedido` por `ADR-0156`, que fija el grafo por API. El prerrequisito de gobernanza **está cumplido** |
+| §5 · V-01 | «El arreglo es de una función del Tablero y **no hay que cambiar el contrato ni tocar UMS**» | **Invalidado en su segunda mitad.** Arreglar `procesarLogin` sigue siendo necesario, pero **no es suficiente**: sin `systemCode` el Tablero recibe el grafo de otro sistema, y sin `profiles[].id` no puede ofrecer cambio de perfil. **UMS sí cambia** |
+| §4.2 | «El arreglo del Tablero es sustituir `grafoDeToken()` por una llamada a `/client/graph`… No hay que cambiar el contrato» | Igual que la anterior: la frase describe el transporte del grafo, no su **contenido**. El contenido está mal acotado |
+| §5 · V-02 | El desajuste de códigos se plantea como el único bloqueo de la autorización | **Sigue siendo bloqueante, pero ya no es el único.** Aunque los códigos coincidieran, hoy el Tablero recibiría el grafo de `SIL` y ningún código casaría de todas formas. **V-12 (§5) es anterior a V-02 en el orden de causas** |
+| §7.4 y §7.5 | «Un perfil por usuario cubre la comparación» y «con un usuario por perfil el problema del cambio de perfil no se plantea» | **Invalidado.** La especificación pone el multi-perfil en el camino principal: es requisito funcional, no escenario opcional. Y la siembra actual **no tiene ningún usuario con dos perfiles**, así que el caso central no es reproducible sin sembrarlo (§5, V-14) |
+| §10 · D7 | «Se acepta que la prueba no ejercite multi-perfil por usuario» | **Revocado por el propio cliente.** D7 se confirmó en su parte de TLS; su parte de multi-perfil queda anulada por el punto 6 de la especificación |
+| §11 (incógnitas) | «¿Cómo llega el Tablero a `/client/graph` tras un `switch-profile`?» — sin resolver | **Resuelto y peor de lo previsto:** `POST /api/v1/auth/switch-profile` devuelve **`401`** con el portador semántico del satélite. No es que el Tablero no consuma el token nuevo: **no puede ni pedirlo** |
+| §3.5 | `POST /api/v1/client/authenticate` listado como pieza cerrada que se reutiliza tal cual | **Matizado.** El endpoint se reutiliza, pero su **cuerpo de petición cambia** (campo `systemCode` opcional) y su grafo sube a `2.4.0` |
+
+### 0.4 Hechos nuevos verificados el 2026-08-02
+
+Todos contra `http://localhost:5080`, base resembrada.
+
+| # | Hecho | Evidencia |
+| :--- | :--- | :--- |
+| 1 | **El grafo no publica el identificador del perfil.** Las claves de `profiles[0]` son `system`, `role`, `branch`, `scope`, `isCurrent`. **Sin `id`** | `AuthGraphPayload.cs:133` lo emite vía `WithId(meta,…)`, y `IncludeTechnicalMetadata` es `false` por defecto |
+| 2 | **`POST /api/v1/auth/switch-profile` devuelve `401` con el portador de satélite** | `AuthEndpoints.LeerTokenDeGrafo` (`:673-707`) exige `sub` GUID y claim `tenant_id`; el token semántico lleva `sub` = correo y `tenant_code` |
+| 3 | **1 y 2 juntos hacen inejecutable el punto 6 de la especificación.** «Reutilizar `switch-profile`» exige antes hacerlo alcanzable y darle su clave | — |
+| 4 | **`admin@beyondnet.com.pe` tiene exactamente un perfil, y es de `SIL`** | `GET /api/v1/profiles` · `context.systemSuite.code` = `SIL`. **El grafo de `SIL` no es fruto de un desempate desafortunado: es el único perfil que existe.** El desempate no eligió mal — nadie le preguntó por `SDLC` |
+| 5 | **Ningún usuario del inquilino tiene más de un perfil** (13 perfiles / 13 usuarios) | `GET /api/v1/profiles?page=1&pageSize=100` |
+| 6 | **La suite `SDLC` sigue sin cargar** — 6 suites: `ADUANAS`, `WMS`, `FACTURACION`, `PORTAL_CLIENTE`, `SIL`, `TMS` | `GET /api/v1/system-suites`. Confirma §5 V-02 tras la resiembra |
+| 7 | **Ambos SDK están rotos contra el endpoint real.** Tipan `graph` como objeto; la API lo devuelve como **cadena** de 10 172 caracteres | `sdk-client/src/client.ts:62` → `undefined` → `AuthGraphSchemaMissing`; `UmsAuthClient.cs:75` igual. **Es el mismo defecto que dejó al Tablero en 502**, y demuestra que ningún SDK ha ejercido nunca el endpoint |
+| 8 | **Los 12 golden fixtures codifican un estado imposible:** `profiles: []` con `onboardingPending: false`. Cero perfiles implica grafo lobby, que fija `true` | `src/libs/sdk/contracts/fixtures/*.json`. **Ningún fixture ejerce el bloque `profiles`**: por eso el hecho 1 pasó inadvertido |
+| 9 | **El arnés RoboSoft pinea `schemaVersion == "2.2.0"` y el servidor emite `2.3.0`** | `src/tests/e2e-functional/robosoft/contexts/configuration.py:418-420`. Ya estaba desfasado antes de este cambio |
+| 10 | **El filtro por `systemCode` proyectado en `diseno-cambio-de-perfil.md` §4.2 nunca se construyó** | `LoginRequest` (`AuthEndpoints.cs:861-865`) y `ClientAuthRequest` (`ClientAuthEndpoints.cs:320-325`) no tienen el campo |
+
+---
+
+## 1. Resumen ejecutivo
+
+La integración **no está a medio construir: está construida y rota en un punto exacto y demostrable**. Las dos aplicaciones existen, se despliegan de forma independiente, cada una tiene su clúster kind vivo, y el Tablero ya tiene login, cliente hacia UMS, sesión por cookie `httpOnly`, verificación de firma HS256 y gating por grafo. Lo que falta es de configuración y de contrato, no de funcionalidad.
+
+Cinco conclusiones, en orden de gravedad:
+
+1. **El login del Tablero contra el UMS real devuelve HTTP 502 hoy — no «se degrada»: falla.** Verificado ejecutando la propia función del Tablero (`procesarLogin`) contra `http://localhost:5080` con el administrador de `BEYONDNET`: `status: 502`, `{"error":"El grafo de autorización de UMS es inválido o de versión incompatible."}`. La causa es una línea: `auth-ums.js:321` resuelve el grafo como `grafoDeToken(v.payload) ?? data?.graph`, y en la respuesta real el token no lleva grafo (D-031) mientras `data.graph` es una **cadena serializada de 10 172 caracteres**, no un objeto — así que `validarGrafo()` la rechaza en `auth-ums.js:322`. Esto es **peor** de lo que describe [`G-190`](../../GAPS.md), que afirma que «el login funciona y la siguiente petición devuelve `autenticado:false`». No entra ni la primera vez.
+
+2. **El endpoint que `G-190` pide como faltante YA EXISTE y funciona.** `GET /api/v1/client/graph` está implementado en [`ClientAuthEndpoints.cs:54-57`](../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs) bajo la política `UmsSatelite`, y verificado en vivo responde **200 con 5 288 bytes** de grafo con portador válido y **401 + `WWW-Authenticate: Bearer`** sin él. `G-190` sigue marcado `Pendiente` en [`GAPS.md`](../../GAPS.md) y su evidencia es `—`: el registro de gaps va por detrás del código. El trabajo restante está **entero del lado del Tablero**, no de UMS.
+
+3. **Aunque se arregle el login, ningún perfil verá nada — y la causa NO es la que parece.** Existe ya una especificación declarativa completa del Tablero como sistema de UMS en `src/provisioning/sdlc/` (suite `SDLC`, tenant `BEYONDNET`, 6 módulos, 64 nodos de navegación, 13 acciones, 22 recursos de dominio, 8 roles y 8 plantillas de permiso) con su cargador `cargar-en-ums.mjs`. **Pero sus códigos y los que el Tablero gatea no coinciden en ninguno:** la spec declara menús `SATELITES`, `DASHBOARDS`, `DEM_TABLERO`, `PER_REGISTRO`, `CFG_CALENDARIO`, `CFG_ARTEFACTOS`; el Tablero exige `TABLERO.SATELITES`, `TABLERO.DASHBOARDS`, `TABLERO.DEMANDAS`, `TABLERO.PERSONAS`, `TABLERO.CONFIG` (`web/src/components/common.jsx:10-16`). Intersección: **cero de cinco**. Como el recorrido es fail-closed, la barra lateral queda vacía para todos los perfiles incluso después de aprovisionar. A esto se suma que la suite **no está cargada** en la instancia viva (las 6 suites de `BEYONDNET` son `ADUANAS`, `WMS`, `FACTURACION`, `PORTAL_CLIENTE`, `SIL`, `TMS`) y que ninguna cuenta `*.sdlc@beyondnet.com.pe` existe todavía.
+
+4. ~~**Hay una contradicción de gobernanza abierta entre un ADR aceptado y una decisión local.**~~ **CERRADO el 2026-08-02 (§0.2).** `ADR-0155` §2.3 fijaba que el servidor del Tablero «**rehidrata el grafo** desde el token», contra [`D-031`](../../DECISIONS.md), que decide el grafo por API. `ADR-0155` pasa a `Supersedido`; rige `ADR-0156` §2.3, alineado con `D-031`. **Ya no hay prerrequisito de gobernanza pendiente para tocar `auth-ums.js`.**
+
+5. **El escenario «dos clústeres» está montado a medias y no como dice su propio manifiesto.** Ambos clústeres existen (`evolith-ums-cluster` y `beyondnet-arch-management`), pero: en el de UMS **el namespace `ums` tiene todos sus deployments a 0 réplicas** y `http://localhost:8080/health` devuelve **503**; el namespace `ums-uat` sí corre pero **no tiene Ingress**, así que no es alcanzable desde el host; el clúster del Tablero **no tiene controlador de Ingress en absoluto** y su `tablero-web` es `ClusterIP`; y el nodo publica `0.0.0.0:4337->30017` cuando `k8s/kind-cluster.yaml` declara `hostPort: 4317`. Recrear el clúster desde su manifiesto **no reproduce el clúster vivo**.
+
+> **Corrección de la recomendación global tras §0.** «No construir nada nuevo» era correcto para el **transporte** del grafo y sigue siéndolo. No lo es para su **contenido**: la especificación confirmada exige un cambio de contrato en UMS —`systemCode`, `accessState`, `profiles[].id`, cambio de perfil por el carril de satélite— que no existe hoy y no se puede sustituir con configuración. Ver [`diseno-seleccion-de-sistema-en-autenticacion.md`](./diseno-seleccion-de-sistema-en-autenticacion.md).
+
+**Recomendación global (redacción original, 2026-08-02):** no construir nada nuevo. Enmendar `ADR-0155`, corregir **una** función del Tablero (`procesarLogin`/`resolverSesion`), **alinear los códigos de menú y ejecutar el cargador que ya existe** (`src/provisioning/sdlc/cargar-en-ums.mjs`), y reutilizar el arnés RoboSoft de `src/tests/e2e-functional/robosoft/` con su runner `scripts/certify-e2e.sh`. El único componente genuinamente nuevo es el carril de UI del Tablero dentro de ese arnés, más una caché de grafo en el servidor del Tablero.
+
+---
+
+## 2. Arquitectura real de cada aplicación
+
+### 2.1 UMS — proveedor de identidad y autorización
+
+| Aspecto | Estado verificado |
+| :--- | :--- |
+| Backend | .NET 10, Clean Architecture + CQRS. `src/apps/ums.api` |
+| Frontend | React 19 + Vite, servido por nginx que además proxya `/api` al backend |
+| Empaquetado | Chart Helm único `src/infra/ums-helm`; imágenes `ums/backend` y `ums/frontend` con `pullPolicy: Never` (precarga por `kind load`) |
+| Clúster | `kind` `evolith-ums-cluster`, definido en `src/infra/kind-config.yaml`: un nodo control-plane con `ingress-ready=true` y `extraPortMappings` 80→8080 y 443→8443 |
+| Ingress | `ingress-nginx` instalado; `Ingress` `ums-frontend` con host `ums.local` y `ums-frontend-localhost` con host `*`. **`tls: []`** en `src/infra/ums-helm/values/frontend.yaml` — sin certificado |
+| Puerta de entrada | El Ingress apunta **solo al frontend**: nginx sirve la SPA y proxya `/api/` al backend. No hay ruta de Ingress directa al backend |
+| Secretos | El chart plantilla `ums-db-secret` y `ums-admin-secret` (`templates/secret.yaml`) |
+| Observabilidad | OTel Collector + Loki + Tempo + Prometheus + Alertmanager + Grafana en el mismo namespace (`observability.enabled: true`) |
+
+**Estado vivo (verificado con `kubectl`, 2026-08-02):**
+
+| Namespace | Pods | Ingress | Veredicto |
+| :--- | :--- | :--- | :--- |
+| `ums` | **0/0 en todos los deployments** (`ums-backend`, `grafana`, `loki`, `prometheus`, `tempo`, `otel-collector`, `alertmanager`) | `ums-frontend` (`ums.local`) y `ums-frontend-localhost` (`*`) | Ingress apunta a un Service **sin endpoints** → `http://localhost:8080/health` = **503** |
+| `ums-uat` | 4/4 corriendo (`ums-backend`, `ums-frontend`, `ums-postgres`, `ums-redis`) | **ninguno** | Vivo pero **inalcanzable desde el host** |
+
+**La instancia que responde en `http://localhost:5080` NO es el clúster.** Es un proceso local; el único contenedor de datos publicado es `ums_postgres` en `0.0.0.0:5433->5432`. Todas las verificaciones de contrato de este documento se hicieron contra ella.
+
+**Configuración que UMS necesita y hoy no recibe en el chart:**
+
+- **`Jwt__Secret` no se inyecta.** `src/infra/ums-helm/templates/backend-deployment.yaml` inyecta `ConnectionStrings__DefaultConnection` y `ADMIN_PASSWORD` por `secretKeyRef`, pero **ninguna** variable `Jwt__*`. `appsettings.json` trae el marcador `YOUR_VERY_LONG_SECRET_KEY_HERE_CHANGE_IN_PRODUCTION_MIN_32_CHARS` y `appsettings.Development.json` su equivalente. Es exactamente [`G-203`](../../GAPS.md), y **es bloqueante para esta prueba**, no solo un riesgo de seguridad: el Tablero necesita ese mismo secreto para verificar la firma, y hoy no hay una fuente única de la que ambos lo tomen.
+- **`AllowedOrigins` está vacío** en `appsettings.json`, `appsettings.Production.json` y `appsettings.UAT.json`; solo `Development` lista `localhost:5173/5174/5175/3000`. Ver §6.4: **para esta integración es irrelevante**, y creer lo contrario es la trampa principal del plan anterior.
+
+### 2.2 Tablero Ejecutivo SDLC
+
+| Aspecto | Estado verificado |
+| :--- | :--- |
+| Ubicación | `evolith-core/reference/governance/tablero-ejecutivo/app`, monorepo npm con workspaces `shared`, `server`, `web` |
+| Backend | Node 24 + Express, puerto 4317. Imagen `tablero-sdlc:local` (`server/Dockerfile`, base `node:24-bookworm-slim`, incluye JRE + graphviz + `plantuml.jar` fijado por checksum) |
+| Frontend | React + Vite, servido por nginx. Imagen `tablero-web:local` (`web/Dockerfile`, base `nginx:alpine`) |
+| Persistencia | PostgreSQL 16 (`StatefulSet` en `k8s/postgres.yaml`); SQLite como motor de rollback (`DB_ENGINE`) |
+| Clúster | `kind` `beyondnet-arch-management` (`k8s/kind-cluster.yaml`): control-plane + worker, `extraPortMappings` 30017→**4317** |
+| Exposición | `tablero-app` es `NodePort` 30017; `tablero-web` es **`ClusterIP`** — el frontend **no está publicado**; el README instruye `kubectl port-forward svc/tablero-web 8080:80` |
+| Ingress | **No hay controlador de Ingress instalado** en ese clúster (`kubectl get ingress -A` → `No resources found`) |
+| CSP | `web/nginx.conf` fija `connect-src 'self'` — el navegador **no puede** llamar a UMS por diseño |
+
+**Estado vivo (verificado, 2026-08-02):**
+
+- Pods `tablero-app`, `tablero-web` y `tablero-postgres` corriendo con las imágenes `tablero-sdlc:local` y `tablero-web:local`.
+- El nodo publica `0.0.0.0:4337->30017/tcp`. **El manifiesto dice `hostPort: 4317`.** Divergencia real entre `k8s/kind-cluster.yaml` y el clúster vivo — probablemente para no chocar con el 4317 de OTLP. Recrear el clúster desde el manifiesto cambia el punto de acceso.
+- **El deployment no tiene ninguna variable `UMS_*`.** Verificado enumerando `spec.template.spec.containers[0].env`: `DB_ENGINE`, `DATABASE_URL`, `PORT`, `OTEL_*`, `LOG_LEVEL`, `GITHUB_*`. Nada más.
+- Consecuencia verificada ejecutando dentro del pod: `GET /api/auth/estado` → **`{"configurado":false}`**. El Tablero desplegado opera hoy en **modo dev-abierto**: sin gate, sin login, acceso anónimo total (`App.jsx:134`).
+
+---
+
+## 3. Inventario de lo YA IMPLEMENTADO — lo que NO hay que volver a escribir
+
+Esta sección es normativa para el encargo de construcción. Cada fila es funcionalidad **existente y probada**; reimplementarla es incumplir la restricción del cliente.
+
+### 3.1 Servidor del Tablero — `server/src/auth-ums.js` (453 líneas, ADR-0155)
+
+| Pieza | Función | Líneas | NO reescribir |
+| :--- | :--- | :--- | :--- |
+| Detección de configuración | `authConfigurada()` | 29-31 | Exige `UMS_BASE_URL` + `UMS_JWT_SECRET`; sin ambos el gate degrada a 501 sin bypass |
+| Verificación HS256 | `verificarJwtHs256()` | 72-98 | Con `node:crypto`, sin dependencias. Rechaza `alg` ≠ HS256 (bloquea `alg:none`), compara la firma en **tiempo constante** (`timingSafeEqual`) y valida `exp`. Está bien hecho |
+| Firma de tokens de prueba | `firmarJwtHs256()` | 55-61 | Útil para el arnés E2E: permite fabricar tokens caducados o de firma alterada sin tocar UMS |
+| Vigencia del grafo | `grafoVigente()` | 114-119 | Comprueba `schemaVersion` en rango y `validUntil` futuro |
+| Gating por menú | `menuPermitido()` | 150-153 | Espejo fail-closed del `AuthorizationValidator` del SDK |
+| Gating por scope | `scopePermitido()` | 179-186 | `deny-wins` sobre `menuAccess` y `domainPermissions`; comparación case-insensitive |
+| Feature flags | `featureFlagActivo()` | 195-199 | Fail-closed, alineado con ADR-0060 |
+| Cookie de sesión | `serializarCookieSesion()` / `cookieDeLimpieza()` / `leerCookie()` | 209-238 | `HttpOnly`, `Path=/`, `Max-Age` acotado |
+| Resolución de sesión | `resolverSesion()` | 250-262 | **Único punto a modificar** (§4.1) |
+| Login proxy | `procesarLogin()` | 273-345 | Proxy servidor-a-servidor a `/api/v1/client/authenticate?format=json`, clasificación de errores (401 vs 502), `Max-Age` = mín(`expiresIn`, restante del grafo). **Solo hay que tocar las líneas 321-327** |
+| Alta de cuenta | `procesarSignup()` | 382-420 | Delega en `POST /api/v1/auth/user-signup`. Devuelve **202**, no 201, porque en UMS la cuenta queda solicitada |
+| Recuperación | `procesarRecuperacion()` | 431-452 | Delega en `POST /api/v1/auth/forgot-password`. **Descarta deliberadamente `simulatedTemporaryPassword`** que UMS devuelve en el cuerpo. Esta decisión es correcta y no debe revertirse |
+
+### 3.2 Contrato vendorizado — `server/src/lib/ums-contracts.js`
+
+`SCHEMA_VERSION` (Actual `2.3.0`, rango `[2.0.0, 3.0.0)`), `esSchemaSoportado()` y `validarGrafo()`. Copia mínima del paquete `@ums/sdk-contracts`, que **no está publicado en npm**. Verificado en vivo: el grafo real declara `schemaVersion: "2.3.0"` — el rango es correcto y no hay que tocarlo.
+
+### 3.3 Rutas HTTP del Tablero — `server/src/index.js:100-142`
+
+`GET /api/auth/estado` (107) · `POST /api/auth/login` (111) · `GET /api/auth/sesion` (119) · `POST /api/auth/signup` (127) · `POST /api/auth/recuperar` (134) · `POST /api/auth/logout` (140). **El contrato HTTP hacia el navegador está cerrado y no debe cambiar.**
+
+### 3.4 Cliente web — gate, login y gating de interfaz
+
+| Pieza | Ruta | Qué hace |
+| :--- | :--- | :--- |
+| Máquina de arranque | `web/src/App.jsx:121-172` | Tres fases: `dev-abierto` (UMS no configurado, con banner), `login` (configurado y sin sesión → **solo** `LoginPage`), `autenticado`. Fail-closed: error de red cae al login, nunca a la app anónima |
+| Pantalla de acceso | `web/src/components/LoginPage.jsx` | Formulario tenant + usuario + contraseña, `autoComplete` correcto, mensajes de error diferenciados por status |
+| Contexto de sesión | `web/src/lib/sesion.jsx` | `SesionProvider`, `construirSesion()` y los tres helpers `puedeVerMenu` / `puedeAccion` / `featureActiva`. El recorrido de `menuAccess` **hereda el cierre** y aplica `deny-wins` entre ramas |
+| Consumo del gating | `web/src/components/common.jsx:75-81,108` y `AppBar.jsx:225,238` | La barra lateral y la navegación móvil ya filtran por `puedeVerMenu(code)` |
+| Cliente HTTP | `web/src/api.js:46-54` | `authEstado`, `authSesion`, `login`, `logout` |
+
+### 3.5 Del lado de UMS
+
+| Pieza | Ruta | Estado |
+| :--- | :--- | :--- |
+| `POST /api/v1/client/authenticate` | [`ClientAuthEndpoints.cs:33-40`](../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs) | Anónimo. Anti-enumeración `G-053` aplicada: tenant inexistente/inactivo y credencial inválida colapsan al mismo 401 |
+| `GET /api/v1/client/graph` | [`ClientAuthEndpoints.cs:54-57`](../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs) | **Existe y funciona.** Política `UmsSatelite` (portador exclusivo). **Reconstruye** el grafo, no devuelve copia guardada (`:109-115`) |
+| Esquema de política `UmsAuto` | [`AuthenticationExtensions.cs:35-49`](../../src/apps/ums.api/Ums.Presentation/Extensions/AuthenticationExtensions.cs) | Reenvía a portador si hay `Authorization: Bearer`, a cookie si no. Cerrado por `D-034`/[`G-191`](../../GAPS.md) |
+| Arnés E2E RoboSoft | `src/tests/e2e-functional/robosoft/` | Dos carriles: API (`api/tests/*.spec.ts`, 11 suites) + helpers (`auth.ts`, `provision.ts`, `invariant.ts`) |
+| Runner de certificación | `scripts/certify-e2e.sh` | Ejecuta ambos carriles contra `E2E_BASE_URL`, verifica `/health` antes de empezar (fail-fast SD-06) |
+| Playwright | `src/apps/ums.web-app/playwright.config.ts` + `@playwright/test 1.60.0` | Ya soporta despliegue externo por `E2E_BASE_URL` sin levantar `webServer` |
+| Ciclo reproducible | `scripts/uat-env.sh` | `up`/`reset`/`smoke`/`status` |
+| **Alta del Tablero como sistema de UMS** | `src/provisioning/sdlc/sdlc-suite.json` (47 KB) + `README.md` | **Especificación declarativa completa y trazada** (`SD-05`): suite `SDLC`, tenant `BEYONDNET`, 6 módulos (`GOB`, `PORT`, `PRD`, `DEM`, `PER`, `CFG`), 11 + 19 + 34 nodos de tipo `Menu`, `SubMenu` y `Option` (el cargador los da de alta como `MenuNode` sobre `/modules/{id}/nodes`, ADR-0090), 13 acciones, 84 vínculos opción↔acción, 22 `DomainResource`, 4 `AppSetting`, 8 roles, 8 plantillas. Extraída de 96 rutas de API, 22 tablas y 7 vistas del propio Tablero |
+| **Cargador del alta** | `src/provisioning/sdlc/cargar-en-ums.mjs` | Aplica la spec contra la API de UMS. **Bajo modificación activa** en el árbol de trabajo (idempotencia en reejecución) |
+| Render del grafo esperado | `src/provisioning/sdlc/render-auth-graph.mjs` + `auth-graph/` | Materializa el grafo que debería producir cada rol |
+
+### 3.6 Lo que ESTORBA o induce a error
+
+| Elemento | Ruta | Problema | Qué hacer |
+| :--- | :--- | :--- | :--- |
+| Prueba que fija el contrato equivocado | `server/test/auth-ums.test.mjs:142-157` | Firma tokens **con el grafo embebido** (`firmarJwtHs256({sub, exp, graph: g}, SECRETO)`) y afirma que `resolverSesion` los acepta. Verde contra un supuesto que la API real contradice. Es el mecanismo exacto por el que `G-190` pasó inadvertido | Reescribir junto con `resolverSesion`. **No borrar**: convertir en prueba de que un token **sin** grafo se resuelve pidiéndolo por API |
+| Fallback muerto | `auth-ums.js:321` (`?? data?.graph`) | Parece un camino alternativo válido y no lo es: `data.graph` es **cadena**, nunca objeto | Eliminar o parsear explícitamente. Dejarlo ambiguo es lo que produjo el 502 |
+| `SameSite=Lax` vs ADR | `auth-ums.js:214` (`'SameSite=Lax'`) | `ADR-0155` §2.3 fija `SameSite=Strict` | Divergencia no declarada: o se corrige el código, o el ADR lo justifica |
+| Manifiesto de clúster desincronizado | `k8s/kind-cluster.yaml` (`hostPort: 4317`) | El clúster vivo publica **4337** | Alinear antes de automatizar nada |
+| Endpoints con validación propia de portador | `AuthEndpoints.cs` (`switch-profile`, `switch-tenant`) | [`G-201`](../../GAPS.md): validan a mano con `ValidateIssuer=false`, `ValidateAudience=false` y `ClockSkew` de 5 min | **No apoyar el robot en ellos** para comparar perfiles mientras siga abierto: la prueba pasaría por la puerta más floja |
+| `net-guard.js` — **falso positivo, no tocar** | `server/src/net-guard.js` | Bloquea destinos privados/loopback y **prohíbe `localhost`** | Solo se aplica a `evidencia_url` (`index.js:1979`). **No** intercepta la llamada a UMS. No «arreglarlo»: es correcto donde está |
+
+---
+
+## 4. Cómo llega hoy la autorización al Tablero
+
+### 4.1 El hecho central: el grafo NO viaja en el JWT
+
+Verificado contra `POST /api/v1/client/authenticate?format=json` con `admin@beyondnet.com.pe` / tenant `BEYONDNET`:
+
+| Medida | Valor real |
+| :--- | :--- |
+| Claves de la respuesta | `token`, `tokenType`, `expiresIn`, `issuedAt`, `format`, `graph`, `requestId` |
+| Longitud del token | **1 149** caracteres |
+| Tipo de `graph` | **`str`** (cadena JSON serializada), **10 172** caracteres |
+| Claims del token | `sub`, `email`, `name`, `tenant_code`, `tenant_name`, `auth_method`, `graph_generated_at`, `graph_valid_until`, `session_tracking_id`, `jti`, `sys_suite`, `sys_suite_name`, `role`, `role_name`, `profile_scope`, `scope[]`, `exp`, `iss: ums-api`, `aud: ums-web-app` |
+| Claim `graph` | **ausente** |
+
+El Tablero espera `payload.graph` (`auth-ums.js:101-104`). No existe. Cae al fallback `data?.graph`, que es una **cadena**, y `validarGrafo()` exige objeto (`ums-contracts.js:77`). Resultado medido:
+
+```text
+procesarLogin({username:'admin@beyondnet.com.pe', password:}, env={UMS_BASE_URL:'http://localhost:5080', …})
+ → status: 502
+ → body: {"error":"El grafo de autorización de UMS es inválido o de versión incompatible."}
+ → cookie: null
+```
+
+**Corrección de [`G-190`](../../GAPS.md):** su enunciado («el login funciona y la siguiente petición devuelve `autenticado:false`») es optimista. El login **no funciona**. La sesión no llega a abrirse.
+
+### 4.2 Se consulta por API: el endpoint existe y sirve el mismo contrato
+
+`GET /api/v1/client/graph` con el portador de `/client/authenticate`:
+
+| Prueba | Resultado medido |
+| :--- | :--- |
+| Con portador válido | **200**, 5 288 bytes |
+| Sin portador | **401** + `WWW-Authenticate: Bearer`, **sin** `Location` |
+
+El payload es **estructuralmente idéntico** al `graph` de `/client/authenticate` — mismas 14 claves de primer nivel (`schemaVersion`, `onboardingPending`, `context`, `authentication`, `actions`, `profiles`, `menuAccess`, `domainPermissions`, `featureFlags`, `effectiveConfig`, `settings`, `scopes`, `generatedAt`, `validUntil`); solo difieren las marcas de tiempo, porque **se reconstruye en cada llamada** ([`ClientAuthEndpoints.cs:109-115`](../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs)). Eso es justamente lo que hace posible la revocación que motiva [`D-031`](../../DECISIONS.md).
+
+**Consecuencia de diseño:** el arreglo del Tablero es sustituir `grafoDeToken()` por una llamada a `/client/graph` con el portador, cacheada en el servidor. **No hay que cambiar el contrato ni tocar UMS.**
+
+### 4.3 No se cachea, y no hay invalidación
+
+Hoy el Tablero es *stateless* por diseño (`ADR-0155` §2.3: «no guarda estado de sesión en BD»). Con `D-031` **necesita** una caché de servidor. Ninguna existe: no hay estructura de caché en `auth-ums.js` ni en `index.js` para el grafo. Es trabajo nuevo, pequeño y acotado, y es el único componente de servidor que hay que añadir.
+
+Las claves de invalidación ya viajan en el token y no hay que inventarlas: `graph_generated_at` y `graph_valid_until`.
+
+### 4.4 La ventana de vigencia del grafo es la mitad que la del token — y ahí muere la sesión
+
+Medido sobre una respuesta real:
+
+| Marca | Valor | Duración |
+| :--- | :--- | :--- |
+| `graph_generated_at` | `2026-08-02T15:04:08.878Z` | — |
+| `graph_valid_until` | `2026-08-02T15:34:08.878Z` | **30 min** |
+| `expiresIn` / `exp` | 3 600 s | **60 min** |
+| `effectiveConfig.sessionTimeoutMinutes` | 30 | 30 min |
+
+`procesarLogin` calcula `Max-Age = mín(expiresIn, restante del grafo)` (`auth-ums.js:329-332`) → **30 minutos**. A los 30 minutos `grafoVigente()` devuelve `false` y `resolverSesion` responde `{autenticado:false}` **con un token todavía válido otros 30 minutos**. El usuario es expulsado al login sin que su credencial haya caducado, y **no existe camino para renovar el grafo sin volver a pedir contraseña**. Esto cuantifica la mitad abierta de [`G-187`](../../GAPS.md).
+
+### 4.5 La superficie de sesión por portador, medida hoy
+
+| Endpoint | Con portador | Veredicto |
+| :--- | :--- | :--- |
+| `GET /api/v1/client/graph` | **200** | Cerrado por `D-034`/[`G-191`](../../GAPS.md) |
+| `GET /api/v1/auth/session` | **200** | Cerrada la mitad «verificación» de [`G-187`](../../GAPS.md). **Pero** devuelve `tenantId: ""` y `permissions: []` — es [`G-202`](../../GAPS.md) |
+| `POST /api/v1/auth/logout` | **200** | Funciona |
+| `POST /api/v1/auth/refresh` | **401** | `.RequireAuthorization()` bajo `UmsAuto`, pero el manejador espera la **cookie de sesión** (`AuthEndpoints.cs:44-47`, «Refresh access token using **session cookie**») |
+| `POST /api/v1/auth/refresh-token` (cuerpo) | **401** | Anónimo por diseño, pero exige que el inquilino haya activado la capacidad (fail-closed, ADR-UMS-091). `BEYONDNET` no la tiene activa |
+| `POST /api/v1/auth/login` (portal) | 200, **`refreshToken: null`** | El refresco viaja en la cookie `ums.session`. Mitad abierta de [`G-187`](../../GAPS.md) |
+
+**No existe refresco por portador.** Es la incógnita operativa número uno del escenario E2E y determina si la prueba puede durar más de 30 minutos.
+
+### 4.6 Contraste con las decisiones y gaps registrados
+
+| Registro | Qué dice | Qué muestra la evidencia |
+| :--- | :--- | :--- |
+| [`D-031`](../../DECISIONS.md) | Grafo por API + caché en el satélite; JWT solo identidad y vigencia | **Confirmada del lado de UMS** (token de 1 149 B sin grafo; `/client/graph` operativo). **No implementada del lado del Tablero.** Y **contradice a `ADR-0155` §2.3, que está `Aceptado`** — ver §7.1 |
+| [`D-034`](../../DECISIONS.md) | Esquema `UmsAuto` + política `UmsSatelite`; sin redirección bajo `/api/**` | **Confirmada.** 401 con `WWW-Authenticate: Bearer` y sin `Location` |
+| [`G-187`](../../GAPS.md) | Superficie de sesión no consumible por un satélite | **Parcialmente cerrado.** Verificación por portador: sí. Refresco y `tenantId`: no |
+| [`G-190`](../../GAPS.md) | Falta `GET /client/graph` | **Obsoleto: el endpoint existe y responde 200.** El gap sobrevive con evidencia `—`. Lo que queda abierto es del lado del Tablero, y el enunciado subestima la gravedad (§4.1) |
+| [`G-191`](../../GAPS.md) | La API no autenticaba su propio JWT | **Cerrado y verificado de nuevo hoy** |
+| [`G-199`](../../GAPS.md) | HS256 con secreto compartido no escala a federación | **Confirmado, y es el nudo de configuración de esta prueba.** El Tablero necesita `UMS_JWT_SECRET` para `authConfigurada()` (`auth-ums.js:29-31`); con él **puede forjar tokens de cualquier usuario**. Con el grafo por API (`D-031`) el secreto deja de ser necesario para el grafo, pero **sigue siéndolo** para verificar la firma localmente y para que el gate arranque. Exige ADR aceptado en `evolith-core` antes de tocar código (`S-06`) |
+
+---
+
+## 5. Vacíos que hoy impiden la prueba
+
+Ordenados por bloqueo. Los marcados **[nuevo]** no estaban identificados en el encargo ni en el plan previo.
+
+### V-01 · El Tablero rehidrata el grafo del token y por eso el login devuelve 502 — **bloqueante**
+
+**Evidencia:** `auth-ums.js:321-327`; `resolverSesion` en `:253`; ejecución real → 502.
+**Solución:** en `procesarLogin`, tras verificar la firma, **pedir el grafo a `GET /api/v1/client/graph`** con el token recién obtenido; cachearlo en el servidor indexado por `jti` (o `session_tracking_id`) con TTL hasta `graph_valid_until`. `resolverSesion` deja de leer `payload.graph` y consulta la caché, revalidando contra UMS si expiró. **Cambio acotado a un archivo.** ~~Requiere antes la enmienda de `ADR-0155` §2.3~~ — **desbloqueado el 2026-08-02**: rige `ADR-0156` §2.3 (§0.2).
+
+> **Corrección tras §0.** La afirmación «no hay que cambiar el contrato ni tocar UMS» (§4.2) **queda invalidada**. Arreglar esto es necesario y **no suficiente**: resuelve el transporte del grafo, no su contenido. Ver V-12.
+
+### V-12 · El Tablero no puede decir a UMS qué sistema pide, y por eso recibe el grafo de otro — **bloqueante** — **[nuevo, 2026-08-02]**
+
+**Evidencia:** `ClientAuthRequest` (`ClientAuthEndpoints.cs:320-325`) y `AuthenticateUserCommand` (`AuthenticateUserCommand.cs:16-23`) no tienen campo de sistema. `AuthorizationGraphBuilderService:160-167` resuelve el perfil por desempate y construye el grafo de la suite de ese perfil. Verificado: el administrador de `BEYONDNET` recibe `context.systemSuite.code` = `SIL`.
+
+**Matiz que corrige a §5 V-02:** en la instancia actual `SIL` **no** es fruto de un desempate desafortunado — es el **único** perfil que ese usuario tiene. El desempate no eligió mal: **nadie le preguntó por `SDLC`**. El defecto es de contrato, no de algoritmo.
+
+**Solución:** `systemCode` opcional en la autenticación de cliente, filtrando los perfiles del usuario **sin consultar el catálogo de sistemas**, para que un código inexistente y un código sin perfil sean indistinguibles. Especificado en [`diseno-seleccion-de-sistema-en-autenticacion.md`](./diseno-seleccion-de-sistema-en-autenticacion.md) §3 y §4.
+
+**Es anterior a V-02 en el orden de causas:** alinear los códigos de menú no sirve de nada mientras el grafo entregado sea el de otro sistema.
+
+### V-13 · El cambio de perfil que la especificación exige reutilizar es hoy inalcanzable — **bloqueante** — **[nuevo, 2026-08-02]**
+
+**Evidencia, dos hechos independientes y ambos verificados:**
+
+1. El grafo **no publica el identificador del perfil**: las claves de `profiles[0]` son `system`, `role`, `branch`, `scope`, `isCurrent`. `AuthGraphPayload.cs:133` lo emite vía `WithId(meta,…)` y `IncludeTechnicalMetadata` está en `false` por defecto. **El cliente no tiene nada que enviar.**
+2. `POST /api/v1/auth/switch-profile` responde **`401`** al portador semántico del satélite: `LeerTokenDeGrafo` (`AuthEndpoints.cs:673-707`) exige `sub` GUID y claim `tenant_id`, y el token semántico lleva `sub` = correo y `tenant_code`.
+
+**Consecuencia de gobernanza:** el punto 6 de la especificación — «reutilizar `switch-profile`, no reinventarlo» — es correcto como dirección pero **no ejecutable como está**. Reutilizarlo exige antes **hacerlo alcanzable** y **darle su clave**. Esto no es reinventar: el comando y el constructor de grafo por perfil se reutilizan sin tocarse; lo que se añade es un adaptador HTTP en el carril `/client`.
+
+**Solución:** `profiles[].id` siempre presente y `POST /api/v1/client/switch-profile` bajo la política de portador. Especificado en [`diseno-seleccion-de-sistema-en-autenticacion.md`](./diseno-seleccion-de-sistema-en-autenticacion.md) §5.3 y §8.
+
+### V-14 · El caso multi-perfil no es reproducible con la siembra actual — **[nuevo, 2026-08-02]**
+
+**Evidencia:** `GET /api/v1/profiles?page=1&pageSize=100` devuelve 13 perfiles sobre **13 usuarios distintos**. Ningún usuario tiene dos.
+
+**Consecuencia:** el escenario que la especificación pone en el camino principal —ofrecer cambio de perfil— **no tiene sujeto**. Cualquier prueba de multi-perfil escrita hoy pasaría en verde sin ejercer nada.
+
+**Solución:** sembrar los cuatro casos de [`diseno-seleccion-de-sistema-en-autenticacion.md`](./diseno-seleccion-de-sistema-en-autenticacion.md) §9, con semántica logística real y bajo `SeedDevData && !IsProduction`.
+
+### V-15 · Los SDK nunca han hablado con el endpoint real — **[nuevo, 2026-08-02]**
+
+**Evidencia:** ambos clientes tipan `ClientAuthResult.graph` como objeto (`sdk-client/src/types.ts:16`, `Ums.Sdk.Contracts/AuthorizationGraph.cs`), y la API lo devuelve como **cadena** de 10 172 caracteres. `client.ts:62` evalúa `parsed.graph?.schemaVersion` a `undefined` y devuelve `AuthGraphSchemaMissing`; `UmsAuthClient.cs:75` falla igual.
+
+**Es el mismo defecto que dejó al Tablero en 502** (§4.1), en otro consumidor. Y a esto se suma que **los 12 golden fixtures traen `profiles: []` con `onboardingPending: false`** —un estado que el servidor no puede producir— de modo que **ningún fixture ejerce el bloque `profiles`**. Esa es la razón mecánica por la que la ausencia del `id` (V-13) no se detectó antes.
+
+**Solución:** tipar `graph` como `string` en el DTO de transporte y deserializar según `format` antes de validar `schemaVersion`; corregir los fixtures y añadir uno **capturado de la API real**.
+
+### V-02 · Los códigos de la spec de aprovisionamiento y los del gating del Tablero no coinciden en ninguno — **bloqueante para la prueba de autorización** — **[nuevo]**
+
+**El aprovisionamiento NO hay que escribirlo: existe.** `src/provisioning/sdlc/sdlc-suite.json` es una spec declarativa completa y trazada, y `cargar-en-ums.mjs` la aplica. Proponer «dar de alta la suite con la API» sería exactamente la solución paralela que este encargo prohíbe.
+
+**El defecto real es un desajuste de contrato entre dos artefactos que ya existen:**
+
+| Sección del Tablero | Código que **exige** el gating (`common.jsx:10-16`) | Código que **declara** la spec | ¿Coincide? |
+| :--- | :--- | :--- | :--- |
+| Satélites | `TABLERO.SATELITES` | `SATELITES` (Menu, módulo `GOB`) | **No** — falta el prefijo |
+| Dashboards | `TABLERO.DASHBOARDS` | `DASHBOARDS` (Menu, módulo `PORT`) | **No** — falta el prefijo |
+| Demandas | `TABLERO.DEMANDAS` | `DEM_TABLERO` (Menu, módulo `DEM`) | **No** — nombre distinto |
+| Personas | `TABLERO.PERSONAS` | `PER_REGISTRO` (Menu, módulo `PER`) | **No** — nombre distinto |
+| Configuración | `TABLERO.CONFIG` | `CFG_CALENDARIO` + `CFG_ARTEFACTOS` (dos Menu) | **No** — además 1→2 |
+
+**Intersección: 0 de 5.** Ejecutar el cargador hoy dejaría la interfaz igual de vacía, y el diagnóstico sería caro porque todo lo demás estaría verde: UMS entregaría un grafo rico y correcto, y el Tablero lo descartaría entero por fail-closed. Es la misma clase de defecto que `G-190` —dos lados probados cada uno contra su propio supuesto— aplicada a los códigos en vez de al transporte del grafo.
+
+`ADR-0155` §2.5 fija la convención «jerárquica, punteada, en mayúsculas, con raíz `TABLERO`» y declara que **los `code` reales los define UMS**. La spec no siguió esa convención; el cliente web sí. Uno de los dos tiene que ceder, y es decisión de arquitectura, no de implementación (§10, **D2**).
+
+**Estado adicional verificado:** la suite `SDLC` **no está cargada** en la instancia viva (`GET /system-suites` devuelve `ADUANAS`, `WMS`, `FACTURACION`, `PORTAL_CLIENTE`, `SIL`, `TMS`) y **no existe ninguna cuenta** `*.sdlc@beyondnet.com.pe` (`GET /user-accounts?search=sdlc` → 0). El cargador no se ha ejecutado con éxito todavía.
+
+**Fricción conocida y ya registrada:** [`G-166`](../../GAPS.md) (UMS no ofrece importación declarativa; por eso existe este cargador artesanal) y [`G-193`](../../GAPS.md) (no se pueden **leer** las concesiones de una plantilla, así que la reentrada depende de provocar el error de duplicado — que es justo lo que el cambio en curso sobre `cargar-en-ums.mjs` está intentando resolver).
+
+**Coordinación:** `cargar-en-ums.mjs` está **siendo modificado ahora mismo** en el árbol de trabajo. Cualquier encargo sobre V-02 debe sincronizarse con ese trabajo en vez de abrir una segunda vía.
+
+### V-03 · El despliegue del Tablero no tiene credenciales de UMS — **bloqueante**
+
+**Evidencia:** `k8s/app.yaml` no declara `UMS_BASE_URL`, `UMS_JWT_SECRET` ni `UMS_TENANT_DEFAULT`; verificado en vivo → `{"configurado":false}`.
+**Solución:** `Secret` `tablero-ums` con `UMS_JWT_SECRET`, más `UMS_BASE_URL` y `UMS_TENANT_DEFAULT=BEYONDNET` como env. **Nunca en la imagen.** Encadenado con V-04.
+
+### V-04 · UMS firma con un marcador de posición y no lo inyecta el chart — **bloqueante** — [`G-203`](../../GAPS.md)
+
+**Evidencia:** `backend-deployment.yaml` sin `Jwt__Secret`; `appsettings.Production.json` y `appsettings.UAT.json` sin sección `Jwt`.
+**Solución:** un `Secret` **único** del que beban ambos despliegues, generado por el script de entorno, nunca versionado. Es el punto donde `G-199` deja de ser teórico: **un mismo secreto en dos clústeres distintos**.
+
+### V-05 · No hay refresco por portador: la sesión muere a los 30 minutos sin recuperación — **[nuevo en su cuantificación]**
+
+**Evidencia:** §4.4 y §4.5.
+**Solución posible sin tocar UMS:** con V-01 resuelto, el Tablero **revalida el grafo** contra `/client/graph` mientras el token siga vigente (60 min), lo que extiende la sesión útil de 30 a 60 min. Pasados los 60 min no hay salida sin re-login o sin cerrar la mitad abierta de `G-187`.
+**Decisión previa necesaria:** ¿la prueba acepta re-login a los 60 minutos, o `G-187` entra en alcance?
+
+### V-06 · La cookie de sesión es `Secure` y eso condiciona la topología de red — **[nuevo]**
+
+**Evidencia:** `auth-ums.js:333` (`const seguro = env.NODE_ENV !== 'test'`) y `:218` (`if (seguro) attrs.push('Secure')`). En el pod `NODE_ENV` no está definido ⇒ `undefined !== 'test'` ⇒ **`Secure` siempre activo** en el clúster.
+**Consecuencia:** un navegador solo acepta una cookie `Secure` desde un **origen confiable**. `http://localhost:PUERTO` lo es; `http://tablero.local:PUERTO` **no**. Si se expone el Tablero por un host `.local` sobre HTTP plano, **el login parecerá funcionar (200) y la sesión no se guardará**: el usuario vuelve al formulario sin mensaje de error. Es un fallo mudo, del tipo más caro de diagnosticar.
+**Solución:** o TLS en el Ingress del Tablero, o acceso exclusivamente por `localhost`. **Determina la §6 y hay que decidirlo antes de montar la red.**
+
+### V-07 · Ningún clúster está hoy en estado de servir la prueba — **[nuevo]**
+
+**Evidencia:** §2.1 y §2.2 (`ums` a 0 réplicas, `localhost:8080` → 503; `ums-uat` sin Ingress; clúster del Tablero sin controlador de Ingress; `hostPort` 4337 ≠ 4317 del manifiesto).
+**Solución:** un script de entorno que **construya el estado desde cero de forma determinista**, en la línea de `scripts/uat-env.sh`. Partir del estado actual es partir de algo irreproducible.
+
+### V-08 · La prueba del Tablero fija el contrato equivocado — **[nuevo]**
+
+**Evidencia:** `server/test/auth-ums.test.mjs:142-157`. Ver §3.6.
+**Solución:** reescribirla junto con V-01, y añadir una prueba de contrato que consuma **la respuesta real** de `/client/authenticate` (fixture capturado de la API viva, no inventado). Sin esto, el mismo defecto vuelve.
+
+### V-09 · Sin carril de UI para el Tablero en el arnés E2E — **[nuevo]**
+
+**Evidencia:** ni `package.json`, ni `web/package.json`, ni `server/package.json` del Tablero declaran `playwright`, `cypress` o `test:e2e`.
+**Solución:** añadir el carril como **tercer proyecto** del arnés RoboSoft existente. No crear un `tests/e2e/` paralelo (§7.2).
+
+### V-10 · `tablero-web` no es alcanzable desde el host — **[nuevo]**
+
+**Evidencia:** `tablero-web` es `ClusterIP`; el `extraPortMapping` del clúster solo publica el NodePort **del backend**; el README instruye `port-forward`.
+**Consecuencia:** un robot de UI que dependa de un `port-forward` manual no es reproducible, y `port-forward` cae en silencio bajo carga.
+**Solución:** parte de la decisión de §6.
+
+### V-11 · Incógnitas declaradas — no rellenadas
+
+| Incógnita | Por qué no se resolvió |
+| :--- | :--- |
+| ¿La imagen `tablero-sdlc:local` del clúster corresponde a `develop` con `ADR-0155`? | El pod tiene 12 h y **no** tiene `UMS_*`. No se verificó el digest contra un build de `develop`. Debe reconstruirse antes de concluir nada |
+| ¿Funciona `/api/v1/auth/user-signup` contra `BEYONDNET` extremo a extremo? | No se ejecutó: crearía datos reales en la instancia viva. Debe probarse contra un entorno desechable |
+| ¿`ums-uat` sirve como UMS de la prueba? | Corre pero no tiene Ingress. No se verificó su `ASPNETCORE_ENVIRONMENT` ni su siembra |
+| ¿Cómo llega el Tablero a `/client/graph` tras un `switch-profile`? | El grafo se reconstruye desde el **perfil vigente** del usuario. `switch-profile` emite otro token; el Tablero no lo consume. Si la prueba compara perfiles del **mismo** usuario, hay que diseñarlo. Con un usuario por perfil (§7.4) el problema no se plantea |
+| ¿`cargar-en-ums.mjs` completa hoy una ejecución limpia? | **No se ejecutó**: crearía 8 roles, 8 plantillas, 64 nodos y 8 cuentas en la instancia viva. Además el archivo **está siendo modificado ahora mismo** por otro trabajo en curso. Debe validarse contra un entorno desechable, no aquí |
+| ¿Los grafos de `src/provisioning/sdlc/auth-graph/` corresponden a la spec actual? | `sdlc-suite.json` y ese directorio se tocaron el mismo día (2026-08-01 21:22), pero no se verificó que el render sea reproducible desde la spec vigente. Antes de usarlos como oráculo (§7.5) hay que regenerarlos y comparar |
+| ¿Los 34 `Option` de la spec tienen correspondencia en la interfaz del Tablero? | Se verificó la ausencia de correspondencia en los **5 códigos de navegación** que el cliente gatea hoy. Los otros 59 nodos **no** se contrastaron uno a uno: el cliente no los consulta todavía |
+
+---
+
+## 6. Arquitectura de comunicación entre los dos clústeres
+
+### 6.1 Restricción que decide el diseño (y que el análisis previo pasó por alto)
+
+**El navegador nunca habla con UMS.** La CSP del Tablero fija `connect-src 'self'` (`web/nginx.conf`) y `ADR-0155` §2.2 impone el proxy servidor-a-servidor. Por tanto:
+
+- **CORS no aplica.** La llamada a UMS la hace `fetch` de Node desde `auth-ums.js:290`, no el navegador. No hay preflight ni `Origin`. **Añadir el origen del Tablero a `AllowedOrigins` de UMS es trabajo innecesario que abre una superficie que la arquitectura cierra a propósito.** El plan previo lo listaba como riesgo a mitigar (§3.1 de `plan-e2e-fase1-autenticacion.md`): **queda invalidado**.
+- **La CSP de UMS es irrelevante** para este flujo, por lo mismo.
+- **La cookie `ums.session` de UMS no participa.** El satélite usa portador. Todo el análisis previo sobre `SameSite=None; Secure` entre orígenes **queda invalidado**.
+
+Lo único que cruza la frontera es **una llamada HTTP servidor-a-servidor desde el pod `tablero-app` hacia la API de UMS.** El problema es de **egreso de pod y resolución de nombre**, no de navegador.
+
+### 6.2 Opciones
+
+| Opción | Cómo | Veredicto |
+| :--- | :--- | :--- |
+| **A. Red Docker compartida entre nodos kind** | `docker network connect kind `; el Tablero llama a UMS por la IP/nombre del contenedor del control-plane de UMS, puerto 80 del Ingress | **Elegida.** Los dos nodos kind son contenedores Docker; conectarlos a una red común es una operación soportada y **el tráfico no sale al host**: reproduce «dos redes distintas unidas por un borde», que es la topología real |
+| B. `host.docker.internal` como punto de encuentro | El pod del Tablero llama a `http://host.docker.internal:8080` | Descartada como principal: en Linux no existe sin `--add-host`, y **acopla la prueba al mapeo de puertos del host**, que ya diverge del manifiesto (V-07). Se conserva como **plan de contingencia** |
+| C. Un clúster, dos namespaces | `ums` y `tablero` en el mismo clúster | Descartada: **contradice el encargo** y esconde egreso, DNS y TLS — justo lo que la prueba debe descubrir |
+| D. Ambos por Ingress del host con `.local` en `/etc/hosts` | Cada clúster publica su Ingress; ambos se alcanzan por nombre | Descartada como principal: **choca de frente con V-06** (cookie `Secure` sobre HTTP en host `.local` = fallo mudo) salvo que se implante TLS, lo que la convierte en la opción A con más piezas |
+
+### 6.3 Diseño propuesto
+
+**Red y DNS**
+
+- Una red Docker dedicada, `beyondnet-e2e`, a la que se conectan los nodos control-plane de ambos clústeres. Un alias estable (`--alias ums-ingress`) evita depender de IPs efímeras.
+- Dentro del clúster del Tablero, un `Service` de tipo `ExternalName` **no** sirve (no resuelve nombres de la red Docker desde CoreDNS). Se resuelve con **`UMS_BASE_URL` apuntando al alias**, más una entrada `hostAliases` en el pod `tablero-app` si el alias no resuelve. **A verificar en el montaje: es la única pieza cuya viabilidad no se ha comprobado empíricamente en este análisis.**
+
+**Puertos** — mapa único y versionado:
+
+| Extremo | Dentro de la red | Desde el host |
+| :--- | :--- | :--- |
+| Ingress de UMS | `ums-ingress:80` / `:443` | `localhost:8080` / `:8443` |
+| Web del Tablero | `tablero-web.beyondnet-arch-management.svc:80` | `localhost:8081` (**nuevo `extraPortMapping` + Ingress**) |
+| API del Tablero | `tablero-app…svc:4317` | `localhost:4337` (NodePort 30017, **alinear con el manifiesto**) |
+
+**TLS — la decisión que no se puede diferir.** Por V-06 hay dos caminos, y **solo dos**:
+
+| Camino | Qué implica | Recomendación |
+| :--- | :--- | :--- |
+| **T1 · Todo por `localhost`, sin TLS** | El robot accede al Tablero solo por `http://localhost:8081`. La cookie `Secure` es aceptada porque `localhost` es origen confiable | **Elegido para el primer ciclo.** Es la vía más corta a una prueba que descubra defectos **funcionales**, sin añadir un fallo de certificados |
+| T2 · TLS real con `mkcert` en ambos Ingress | Certificados en el almacén de confianza del navegador de Playwright; hosts `.local` | Fase 2. Se parece más a producción y **descubre problemas reales de certificado**, pero introducirlos en el primer ciclo mezcla dos clases de fallo |
+
+**Riesgo asumido y declarado de T1:** no ejercita TLS, y por tanto **no descubre** los defectos de certificado que sí aparecerían en producción. Se acepta a cambio de que el primer ciclo aísle los defectos de contrato (§5), y se registra como deuda con salida a T2. **Nótese que el tráfico entre clústeres sí es HTTP plano en ambos caminos**: eso es fiel a lo que hoy hace el chart de UMS (`tls: []`).
+
+**ConfigMaps y Secrets**
+
+| Objeto | Clúster | Contenido |
+| :--- | :--- | :--- |
+| `Secret/ums-jwt` | `evolith-ums-cluster` | `Jwt__Secret` — **generado por el script**, nunca versionado (V-04) |
+| `Secret/tablero-ums` | `beyondnet-arch-management` | `UMS_JWT_SECRET` — **el mismo valor** (V-03) |
+| `ConfigMap/tablero-ums` | `beyondnet-arch-management` | `UMS_BASE_URL`, `UMS_TENANT_DEFAULT=BEYONDNET` |
+
+Que el mismo secreto viva en dos clústeres es la manifestación operativa de [`G-199`](../../GAPS.md). El script debe imprimirlo como advertencia en cada ejecución: es la prueba de que la firma asimétrica no es una mejora estética.
+
+**Descubrimiento de servicio.** No hay malla, ni federación, ni DNS compartido — y **no debe haberla**: en producción estos dos sistemas se hablan por HTTP a través de un borde. El acoplamiento es una URL en configuración. Ese es el diseño correcto y ya es el que implementa `auth-ums.js`.
+
+---
+
+## 7. Arquitectura del robot E2E
+
+### 7.1 Prerrequisito de gobernanza — **cumplido el 2026-08-02**
+
+`ADR-0155` §2.3 (`Aceptado`) y [`D-031`](../../DECISIONS.md) se contradecían (§1.4). **Resuelto por supersesión, no por enmienda** (§0.2):
+
+1. `ADR-0155` pasa a **`Supersedido`**, con nota de continuidad que declara qué de él sigue vigente y qué se retira.
+2. Rige **`ADR-0156`**, que fija el grafo por API y lo cachea (§2.3), recalibra el argumento de la verificación local (§2.4) y añade sistema solicitado (§2.5), multi-perfil (§2.6) y vigencia del grafo (§2.10).
+
+**Por qué supersesión y no enmienda:** la premisa «el grafo viaja en el token» no vivía solo en §2.3 — recorría §1, §2.4 y §5. Corregirla en el sitio dejaría un ADR aceptado cuyo texto ya no coincidiría con lo que estuvo en vigor cuando se escribió el código que lo obedeció, que es el agujero de auditoría que `G-189` documenta. Y había tres decisiones **nuevas**, no una corrección.
+
+**Ya se puede escribir el código del Tablero.** Sigue abierto:
+
+* La divergencia `SameSite=Lax` vs `Strict` (§3.6): `ADR-0156` §2.3 mantiene `Strict`, así que **el código diverge de la norma vigente** y debe corregirse o declararse.
+* El desajuste de códigos de menú (§5 V-02): `ADR-0156` §2.9 mantiene la convención `TABLERO.*` y **no elige qué lado cede** — sigue siendo la decisión **D2** de §10.
+
+### 7.2 Herramienta: Playwright — **por reutilización, no por comparación**
+
+La elección **ya está tomada y desplegada**: `@playwright/test 1.60.0` en `src/apps/ums.web-app/package.json`, `playwright.config.ts` que ya soporta despliegue externo por `E2E_BASE_URL`, arnés RoboSoft de dos carriles en `src/tests/e2e-functional/robosoft/` con 11 suites y helpers, runner `scripts/certify-e2e.sh`, y respaldo normativo en `ADR-0109` (`Aceptado`, «Vitest/Playwright»). Por `S-07`, proponer otra herramienta exigiría un ADR nuevo. **La comparativa Playwright/Cypress/Selenium del plan previo es correcta pero ya no es la razón: la razón es que existe.**
+
+**Estructura — extender, no crear en paralelo.** El plan previo proponía un árbol `tests/e2e/` nuevo (`plan-e2e-fase1-autenticacion.md` §5.2). **Se descarta**: duplicaría helpers, fixtures y runner. Se propone:
+
+```text
+src/tests/e2e-functional/robosoft/
+├─ api/ # EXISTE — carril B de UMS, intacto
+├─ contexts/ # EXISTE
+└─ integracion-tablero/ # NUEVO — único añadido
+ ├─ playwright.config.ts # espejo del de api/, con dos baseURL
+ ├─ fixtures/
+ │ ├─ entorno.ts # URLs de ambos sistemas por env
+ │ └─ perfiles.ts # los 13 perfiles reales de BEYONDNET (§7.4)
+ ├─ paginas/ # Page Objects del Tablero, sin aserciones
+ ├─ escenarios/
+ │ ├─ 01-acceso.spec.ts # login, credencial inválida, bloqueado, inactivo
+ │ ├─ 02-sesion.spec.ts # cookie httpOnly, vigencia del grafo, logout
+ │ ├─ 03-gating-perfiles.spec.ts # comparación entre perfiles (el corazón)
+ │ ├─ 04-resiliencia.spec.ts # UMS a 0 réplicas, timeout, token forjado
+ │ └─ 05-alta-recuperacion.spec.ts
+ └─ soporte/
+ ├─ evidencia.ts # HAR + decodificación del JWT + volcado del grafo
+ └─ aserciones.ts
+```
+
+Y `scripts/certify-e2e.sh` gana un `--carril c`, en vez de un runner nuevo.
+
+### 7.3 Levantado y espera
+
+Un script `scripts/e2e-dos-clusteres.sh` con la interfaz de `uat-env.sh` (`up` / `reset` / `test` / `status` / `down`), que ejecuta en orden:
+
+1. **Secreto compartido** — generar `Jwt:Secret` (≥ 32 caracteres, aleatorio) **una vez**, y crear ambos Secrets desde él.
+2. **Clúster UMS** — `kind create --config src/infra/kind-config.yaml` si no existe; `ingress-nginx` + `kubectl wait`; `make build`; `kind load`; `helm upgrade --install` con `values/backend.yaml`, `values/frontend.yaml` y `--set` del secreto.
+3. **Clúster Tablero** — `kind create --config k8s/kind-cluster.yaml` (**corregido**, con `extraPortMapping` para la web); `ingress-nginx`; build de `tablero-sdlc:local` y `tablero-web:local`; `kind load`; `kubectl apply` + Secret/ConfigMap de UMS.
+4. **Red** — conectar ambos control-plane a `beyondnet-e2e` con alias.
+5. **Espera activa, con reintento exponencial y diagnóstico por sistema** (nunca `sleep`):
+ - UMS: `GET /health` = 200 **y** `POST /client/authenticate` con el admin de `BEYONDNET` = 200. Health verde con siembra a medias es un falso positivo.
+ - Tablero: `GET /ready` = 200 **y** `GET /api/auth/estado` = **`{"configurado":true}`**. Sin esta segunda comprobación el robot probaría el modo dev-abierto y **19 escenarios pasarían en verde sin haber autenticado nada** — el peor resultado posible.
+ - Frontera: desde el pod `tablero-app`, `POST /api/auth/login` con el admin de `BEYONDNET` = **200**. Si esto no da 200, el robot **aborta con diagnóstico**.
+6. **Aprovisionar la suite `SDLC`** (V-02) — ejecutar `src/provisioning/sdlc/cargar-en-ums.mjs`, ya idempotente, y **verificar la carga** (`GET /system-suites` contiene `SDLC`; existen las 8 cuentas `*.sdlc@beyondnet.com.pe`). Un cargador que termina sin error pero deja el catálogo a medias es el peor punto de partida para un robot.
+7. **Ejecutar** `certify-e2e.sh --carril c`.
+
+### 7.4 Obtención de los perfiles de `BEYONDNET`
+
+**Hay dos poblaciones de perfiles y la prueba necesita las dos, para cosas distintas.**
+
+**(a) Los perfiles del Tablero — los que dan sentido a la comparación.** Los define `src/provisioning/sdlc/sdlc-suite.json`: 8 roles con matrices de permiso deliberadamente desiguales, que es justo lo que una prueba de gating necesita.
+
+| Rol | Nombre | Nivel | Concesiones en su plantilla |
+| :--- | :--- | ---: | ---: |
+| `ADMIN_SDLC` | Administrador del Tablero | 0 | 1 (comodín) |
+| `DIRECTORIO` | Directorio | 0 | 6 |
+| `AUDITOR` | Auditor de Cumplimiento | 0 | 1 |
+| `PMO` | Oficina de Gestión | 1 | 7 |
+| `ARQUITECTO` | Arquitecto de la Suite | 1 | 11 |
+| `PRODUCT_OWNER` | Product Owner | 2 | 9 |
+| `TECH_LEAD` | Líder Técnico | 2 | **20** |
+| `EQUIPO` | Miembro de Equipo | 3 | 16 |
+
+El cargador crea una cuenta por rol con el patrón `correoDe()` de `cargar-en-ums.mjs:495`. **Ninguna existe todavía** (§5, V-02): esta población es *consecuencia* de aprovisionar, no un dato disponible hoy.
+
+**(b) Los perfiles logísticos sembrados — disponibles ya, útiles para el carril de autenticación.** Verificado contra la API viva:
+
+- **78 cuentas** en el inquilino, con estados reales `Active`, `Pending` y `Blocked` — la prueba de «usuario bloqueado» e «inactivo» tiene sujeto real, sin fabricarlo.
+- **13 perfiles activos** sobre **9 roles** y **4 suites**:
+
+| Suite | Roles |
+| :--- | :--- |
+| `SIL` | `ADMINISTRADOR` (×2), `ANALISTA_DOC`, `AUDITOR`, `EJECUTIVO_CUENTA` (×2) |
+| `ADUANAS` | `AGENTE_ADUANAS` (×2), `DESPACHADOR` |
+| `WMS` | `JEFE_ALMACEN` (×2), `OPERARIO_ALMACEN` |
+| `TMS` | `COORD_TRANSPORTE` |
+
+- **Todas las cuentas semilla de BEYONDNET comparten la contraseña `BeyondNet.Dev.2026`** (`CoreDevDataSeeder.cs:53`, `BeyondNetDevPassword`, aplicada en `IdentityDevDataSeeder.cs:416`). Es dato de desarrollo bajo `SeedDevData && !IsProduction`, no un secreto.
+
+**El robot descubre los perfiles, no los codifica:** `GET /api/v1/profiles?page=1&pageSize=100` con la sesión del administrador devuelve `userEmail`, `roleCode`, `systemSuiteCode` y `scope`. La fixture se genera de ahí, y **cubre las dos poblaciones sin distinguirlas**: tras aprovisionar, los perfiles `SDLC` aparecen en la misma consulta. Si la siembra o la spec cambian, la prueba se adapta sola en vez de mentir.
+
+**Reparto entre carriles:** los perfiles logísticos (b) sirven para `01-acceso` y `02-sesion` —autenticar, cookie, vigencia, logout— porque para eso el contenido del grafo es indiferente. La comparación de gating (`03-gating-perfiles`) **solo tiene sentido con la población (a)**, y por tanto **depende de V-02**.
+
+### 7.5 Comparación entre perfiles — cómo se hace honesta
+
+Diferencia real medida hoy entre dos perfiles logísticos: `ANALISTA_DOC` (suite `SIL`, menús `FILES`/`COST`/`TRACE`, 8 scopes) vs `JEFE_ALMACEN` (suite `WMS`, menús `INV`/`RCV`/`REPORTS`, 12 scopes). **El grafo distingue perfiles con nitidez.** Lo que hoy no puede reflejarlo es la interfaz del Tablero, porque los códigos que gatea no son los que nadie le entrega (V-02).
+
+Con la suite `SDLC` cargada y los códigos alineados, la diferencia esperada es mucho más rica: de 1 concesión (`AUDITOR`) a 20 (`TECH_LEAD`) sobre el mismo árbol de 64 nodos. Ese contraste es el que convierte la prueba en una verificación de autorización y no en un humo de login.
+
+**El grafo esperado por rol ya está materializado** en `src/provisioning/sdlc/auth-graph/` (generado por `render-auth-graph.mjs`): sirve como **oráculo** del carril de contrato, en vez de afirmar contra lo que UMS devuelva —que sería tautológico—.
+
+Por eso la comparación se hace en **tres niveles, y los tres deben concordar**:
+
+| Nivel | Qué compara | Cómo |
+| :--- | :--- | :--- |
+| **Contrato** | El grafo que UMS entrega por perfil | `GET /client/graph`; se afirma sobre `menuAccess`, `scopes` y `context.role` |
+| **Sesión** | Lo que el Tablero expone al cliente | `GET /api/auth/sesion`; el `graph` devuelto debe ser **idéntico** al del nivel anterior |
+| **Interfaz** | Lo que el usuario ve | Elementos presentes en la barra lateral y la `AppBar` |
+
+**La aserción que da valor a la prueba es la de concordancia, no la de presencia:** *el conjunto de secciones visibles es exactamente el conjunto de códigos de menú con `Allow` efectivo en el grafo de ese perfil.* Así, un perfil que ve de más **y** un perfil que ve de menos fallan igual. Comprobar solo que «el administrador ve más» **pasaría en verde con la interfaz vacía de hoy**, que es precisamente el falso positivo contra el que hay que blindarse.
+
+**Matriz mínima:** un perfil por cada uno de los 8 roles `SDLC`, más las tres cuentas no operables (`Pending`, `Blocked`, inexistente) de la población logística.
+
+### 7.6 Evidencias generadas
+
+| Evidencia | Mecanismo | Política |
+| :--- | :--- | :--- |
+| Informe | Reporter HTML de Playwright + JUnit XML | Siempre |
+| Traza navegable | `trace` (DOM + red + consola por paso) | `on-first-retry`, como el config existente |
+| Captura | `screenshot` | `only-on-failure` |
+| Vídeo | `video` | `retain-on-failure` |
+| **HTTP** | HAR por escenario | Siempre. Deja auditable la conversación completa, incluida la llamada del pod a UMS |
+| **JWT** | Cabecera y payload **decodificados y volcados**, con `exp`, `graph_generated_at`, `graph_valid_until`, `jti` y `scope` | Siempre. La firma **no** se adjunta |
+| **Grafo** | El JSON de `/client/graph` por perfil, adjunto | Siempre. Es el contrato contra el que se afirma |
+| **Sesión** | Atributos de la cookie: `HttpOnly`, `Secure`, `SameSite`, `Max-Age`. Y que `localStorage`/`sessionStorage` **no** contienen el token | Siempre. `ADR-0155` §2.3 lo exige y hay que demostrarlo, no suponerlo |
+| Métricas | Duración por escenario, reintentos, flakiness | Siempre. **Una prueba que necesita reintento no es verde: es deuda** |
+
+**Regla anti-fuga:** ningún artefacto puede contener `UMS_JWT_SECRET` ni la firma de un JWT. El HAR de la llamada servidor-a-servidor lleva credenciales de usuario en el cuerpo: el helper de evidencia debe redactarlas. El `pre-push` con gitleaks escanea todo el árbol, y estos artefactos no deben versionarse.
+
+### 7.7 Simulación — solo donde no se puede provocar de verdad
+
+| Escenario | Cómo | Por qué |
+| :--- | :--- | :--- |
+| UMS indisponible | `kubectl scale deploy/ums-backend --replicas=0` | Indisponibilidad **real**. Prueba que el Tablero distingue «no responde» (502) de «responde error» (401) — `auth-ums.js:295-305` ya lo distingue y hay que verificarlo |
+| Token con firma inválida | `firmarJwtHs256(payload, 'otro-secreto')` (`auth-ums.js:55`) | La utilidad ya existe. **Reutilizarla** |
+| Token caducado | El mismo helper con `exp` en el pasado | Sin esperar una hora |
+| Grafo vencido | `validUntil` en el pasado | Verifica `grafoVigente()` sin esperar 30 min |
+| Cuenta bloqueada/pendiente | **Cuentas semilla reales** (`ex.empleado@…` = `Blocked`, `coordinador.flota@…` = `Pending`) | No se simula lo que existe |
+| Timeout | `page.route` con retardo | Único caso sin equivalente real barato |
+
+---
+
+## 8. Orden de ejecución recomendado
+
+### Bloque 0 — Gobernanza (bloquea todo lo demás; ninguna línea de código antes)
+
+| # | Acción | Dónde |
+| :--- | :--- | :--- |
+| 0.1 | **Enmendar `ADR-0155` §2.3/§2.4**: el grafo se obtiene por API y se cachea (alinear con `D-031`) | `evolith-core` |
+| 0.2 | Resolver la divergencia `SameSite` `Lax` vs `Strict` | `evolith-core` |
+| 0.3 | **Resolver el desajuste de códigos** entre `sdlc-suite.json` y `common.jsx:10-16` (0 de 5 coinciden, §5 V-02): ¿la spec adopta el prefijo `TABLERO.` de `ADR-0155` §2.5, o el cliente web adopta los códigos de la spec? | `evolith-core` + `ums` |
+| 0.4 | Decidir el alcance de `G-187` (refresco por portador): ¿dentro o fuera? (§5, V-05) | Cliente |
+| 0.5 | Actualizar el registro de gaps: `G-190` está obsoleto en su parte de UMS y subestimado en su parte de Tablero (§4.6) | `ums` |
+
+### Bloque 1 — Prerrequisitos técnicos (paralelizables entre sí)
+
+| # | Acción | Vacío | Repositorio |
+| :--- | :--- | :--- | :--- |
+| 1.1 | Corregir `procesarLogin`/`resolverSesion`: grafo por API + caché de servidor | V-01 | `evolith-core` (Tablero) |
+| 1.2 | Reescribir `auth-ums.test.mjs` y añadir prueba de contrato con fixture **capturado de la API real** | V-08 | `evolith-core` (Tablero) |
+| 1.3 | **Alinear los códigos** según 0.3 y **ejecutar el cargador existente** `src/provisioning/sdlc/cargar-en-ums.mjs` hasta que sea idempotente. **No escribir un cargador nuevo** — y sincronizar con el trabajo ya en curso sobre ese archivo | V-02 | `ums` |
+| 1.4 | Inyectar `Jwt__Secret` en el chart de UMS por `secretKeyRef` | V-04 | `ums` |
+| 1.5 | Añadir `UMS_BASE_URL` / `UMS_JWT_SECRET` / `UMS_TENANT_DEFAULT` a `k8s/app.yaml` | V-03 | `evolith-core` (Tablero) |
+| 1.6 | Alinear `k8s/kind-cluster.yaml` con la realidad y añadir Ingress + puerto para `tablero-web` | V-07, V-10 | `evolith-core` (Tablero) |
+
+**Puerta de salida del bloque 1 — una sola comprobación:** desde el pod `tablero-app`, `POST /api/auth/login` con `admin@beyondnet.com.pe` / `BEYONDNET` devuelve **200 con `Set-Cookie`**, y `GET /api/auth/sesion` devuelve **`autenticado:true` con grafo**. Mientras esto no ocurra, escribir el robot es escribir 19 pruebas rojas que describen una funcionalidad ausente — el error que ya se cometió y que documenta [`G-189`](../../GAPS.md).
+
+### Bloque 2 — Entorno reproducible
+
+| # | Acción |
+| :--- | :--- |
+| 2.1 | `scripts/e2e-dos-clusteres.sh` (`up`/`reset`/`test`/`status`/`down`), §7.3 |
+| 2.2 | Red Docker `beyondnet-e2e` + alias + verificación de resolución desde el pod (**la pieza no verificada de §6.3**) |
+| 2.3 | Espera activa con las **tres** comprobaciones de §7.3.5, incluida `{"configurado":true}` |
+| 2.4 | Ejecución en frío completa desde cero, dos veces, con el mismo resultado |
+| 2.5 | Integrar el cargador de la suite `SDLC` en el `up` del script, tras la espera activa de UMS |
+
+### Bloque 3 — El robot (esto es la prueba; lo anterior son prerrequisitos)
+
+| # | Acción |
+| :--- | :--- |
+| 3.1 | `integracion-tablero/` dentro del arnés RoboSoft + `--carril c` en `certify-e2e.sh` |
+| 3.2 | Fixtures de perfiles **descubiertos** por `GET /api/v1/profiles` |
+| 3.3 | `01-acceso` y `02-sesion` (autenticación y sesión) |
+| 3.4 | `03-gating-perfiles` — comparación en tres niveles (§7.5). **Es el escenario que justifica el encargo** |
+| 3.5 | `04-resiliencia` y `05-alta-recuperacion` |
+| 3.6 | Helper de evidencia con redacción de credenciales (§7.6) |
+
+### Bloque 4 — Deuda declarada, fuera del primer ciclo
+
+TLS real con `mkcert` (T2 de §6.3) · firma asimétrica [`G-199`](../../GAPS.md) · refresco por portador [`G-187`](../../GAPS.md) · identificador estable en el token semántico [`G-202`](../../GAPS.md) · endpoints con validación propia de portador [`G-201`](../../GAPS.md).
+
+---
+
+## 9. Hallazgos propuestos para registrar
+
+**No se ha modificado [`GAPS.md`](../../GAPS.md) ni [`DECISIONS.md`](../../DECISIONS.md).** Se proponen para registro posterior, con dimensión, criticidad y complejidad según `S-20`:
+
+| # | Hallazgo | Dimensión | Criticidad | Complejidad |
+| :--- | :--- | :--- | :--- | :--- |
+| P-01 | **El login del Tablero contra el UMS real devuelve 502** (§4.1). Corrige y agrava a `G-190`, cuyo enunciado dice que el login funciona | SDLC-Construccion | Alta | Baja |
+| P-02 | **`G-190` está obsoleto en su premisa**: `/api/v1/client/graph` existe, responde 200 y tiene pruebas, pero el gap sigue `Pendiente` con evidencia `—` | SDLC-Validacion | Media | Baja |
+| P-03 | **Contradicción entre `ADR-0155` §2.3 (`Aceptado`) y `D-031`**. Viola `SD-03`/`S-06`: hay código escrito contra cada lado | Arq-Gobernanza | Alta | Media |
+| P-04 | **La ventana del grafo (30 min) es la mitad que la del token (60 min)** y no hay refresco: expulsión silenciosa con credencial válida (§4.4). Cuantifica `G-187` | Arq-Seguridad | Alta | Media |
+| P-05 | **La cookie de sesión es `Secure` en todo entorno desplegado**; sobre HTTP en host no-`localhost` el login falla en silencio (§5, V-06) | Arq-Seguridad | Alta | Baja |
+| P-06 | **La prueba `auth-ums.test.mjs` fija el contrato equivocado** firmando tokens con grafo embebido: verde contra su propio supuesto (§3.6) | SDLC-Validacion | Alta | Baja |
+| P-07 | **`k8s/kind-cluster.yaml` no reproduce el clúster vivo** (`hostPort` 4317 vs 4337) y `tablero-web` no es alcanzable desde el host | SDLC-Entrega | Media | Baja |
+| P-08 | **El Tablero desplegado corre en modo dev-abierto** (`{"configurado":false}`): sin gate, acceso anónimo, con el gate de `ADR-0155` presente en el código | Arq-Seguridad | Alta | Baja |
+| P-09 | **El namespace `ums` está a 0 réplicas y `ums-uat` no tiene Ingress**: no hay hoy un UMS alcanzable en clúster (§2.1) | SDLC-Entrega | Media | Baja |
+| P-10 | **Desajuste total de códigos de menú entre `sdlc-suite.json` y `common.jsx:10-16`** (0 de 5 coinciden): aprovisionar hoy deja la interfaz igual de vacía, con todo lo demás en verde (§5, V-02). Reformula y agrava a `G-277` de `evolith-core`, que lo plantea como «falta el árbol» cuando el árbol existe y lo que falla es el contrato de códigos | SDLC-Construccion | Alta | Media |
+| P-13 | **La suite `SDLC` no está cargada en la instancia viva** y no existe ninguna cuenta `*.sdlc@beyondnet.com.pe`: el cargador nunca completó una ejecución con éxito (§5, V-02) | SDLC-Entrega | Alta | Baja |
+| P-14 | **`sdlc-suite.json` no sigue la convención de `code` de `ADR-0155` §2.5** (raíz `TABLERO`, jerárquica punteada) sin declarar divergencia, y el `README.md` del directorio sigue en estado `Borrador` pese a ser la fuente del alta | Arq-Gobernanza | Media | Baja |
+| P-11 | **`SameSite=Lax` diverge de `ADR-0155` §2.3 (`Strict`)** sin justificación declarada | Arq-Gobernanza | Baja | Baja |
+| P-12 | **El plan `plan-e2e-fase1-autenticacion.md` §3.1 y §8 quedan invalidados**: CORS, CSP de UMS y `SameSite=None` no aplican al proxy servidor-a-servidor (§6.1), y §4 propone construir lo que ya existe | SDLC-Diseno | Media | Baja |
+
+### 9.1 Hallazgos añadidos el 2026-08-02
+
+| # | Hallazgo | Dimensión | Criticidad | Complejidad |
+| :--- | :--- | :--- | :--- | :--- |
+| P-15 | **El contrato de autenticación no admite el sistema solicitado**, de modo que el desempate resuelve una pregunta que nadie hizo y el satélite recibe el grafo de otro sistema (§5, V-12). Es la causa de contrato de [`G-184`](../../GAPS.md), que hoy está registrado como si fuera un defecto de desempate | SDLC-Diseno | Alta | Media |
+| P-16 | **El grafo publica `profiles` sin el identificador de cada perfil**, y `POST /auth/switch-profile` exige `profileId`: el contrato ofrece una operación y retiene su clave (§5, V-13). Reabre la mitad de consumo de [`G-177`](../../GAPS.md), cerrado el 2026-08-01 sin verificar que el cliente pudiera ejecutar el cambio | Arq-Interoperabilidad | Alta | Baja |
+| P-17 | **`POST /api/v1/auth/switch-profile` devuelve `401` al portador semántico del satélite** (`LeerTokenDeGrafo` exige `sub` GUID y `tenant_id`). El carril de satélite no tiene cambio de perfil (§5, V-13). Relacionado con [`G-201`](../../GAPS.md) y con [`G-202`](../../GAPS.md), que documenta la falta de identificador estable en el token semántico | Arq-Interoperabilidad | Alta | Media |
+| P-18 | **Ambos SDK tipan `graph` como objeto y la API lo devuelve como cadena**: todo login por SDK falla con `AuthGraphSchemaMissing`. Ningún SDK ha ejercido nunca el endpoint real (§5, V-15). Misma clase que el 502 del Tablero | SDLC-Validacion | Alta | Baja |
+| P-19 | **Los 12 golden fixtures del contrato codifican un estado imposible** (`profiles: []` con `onboardingPending: false`) y **ninguno ejerce el bloque `profiles`**. Es la razón mecánica por la que P-16 pasó inadvertido (§5, V-15) | SDLC-Validacion | Alta | Baja |
+| P-20 | **Ningún usuario del inquilino tiene más de un perfil** (13 perfiles / 13 usuarios): el escenario multi-perfil, que la especificación pone en el camino principal, no tiene sujeto (§5, V-14) | SDLC-Validacion | Media | Baja |
+| P-21 | **El arnés RoboSoft pinea `schemaVersion == "2.2.0"` mientras el servidor emite `2.3.0`** (`contexts/configuration.py:418-420`): el pin estaba desfasado antes de este cambio | SDLC-Validacion | Media | Baja |
+| P-22 | **El segundo criterio de desempate ordena por `SystemSuiteId` —un GUID— pese a que su comentario dice «luego el sistema»** (`AuthorizationGraphBuilderService.cs:165`). Es el mismo defecto que [`G-177`](../../GAPS.md) corrigió en el primer criterio y dejó sin corregir en el segundo | SDLC-Construccion | Baja | Baja |
+| P-23 | **El filtro por sistema proyectado en `diseno-cambio-de-perfil.md` §4.2 nunca se construyó** y el documento sigue en estado `Propuesta` sin declararlo: un diseño aceptado como referencia describe capacidades que no existen | Arq-Gobernanza | Media | Baja |
+| P-24 | **`ADR-0155` estuvo `Aceptado` describiendo un servidor que no existía** (grafo embebido en el token) porque se escribió contract-first sin UMS desplegado, y hay código del Tablero escrito contra esa descripción. Cerrado por supersesión (§0.2); se registra la **clase de riesgo**: un ADR contract-first debe re-verificarse contra el sistema real antes de que su consumidor se construya | Arq-Gobernanza | Alta | Baja |
+
+**Actualización de P-03:** queda **cerrado** por la supersesión de `ADR-0155` (§0.2). Se conserva en la tabla como registro histórico.
+
+---
+
+## 10. Decisiones que el cliente debe tomar antes de escribir código
+
+> **Estado 2026-08-02: D1–D7 confirmadas por el cliente.** La tabla se conserva como registro. Cambios sobre lo escrito:
+>
+> * **D1** — confirmada, y **ejecutada por supersesión** en vez de por enmienda (§0.2, §7.1). El motivo del cambio de vía está en §7.1.
+> * **D2** — confirmada la necesidad de decidir, **pero la decisión sigue abierta**: `ADR-0156` §2.9 mantiene la convención `TABLERO.*` y no elige lado. Y ahora es **posterior** a V-12: alinear códigos no sirve mientras el grafo entregado sea el de otro sistema.
+> * **D7** — confirmada **solo en su parte de TLS**. Su parte de multi-perfil (**«se acepta que la prueba no ejercite multi-perfil por usuario»**) queda **revocada** por el punto 6 de la especificación: el multi-perfil es requisito funcional, no escenario opcional.
+>
+> **Decisiones nuevas que la especificación abre y que no estaban en esta tabla:**
+>
+> | # | Decisión | Recomendación |
+> | :--- | :--- | :--- |
+> | **D8** | ¿`profiles[].id` se emite siempre, o se activa `AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA` para el inquilino? | **Siempre.** Activar el flag enciende los ids de módulos, nodos y recursos —decorativos— para conseguir uno que no lo es. Se especifica en el diseño §5.3 |
+> | **D9** | ¿Adaptador nuevo `POST /client/switch-profile`, o se extiende `/auth/switch-profile`? | **Adaptador nuevo.** Extender el existente mete al satélite por la puerta con validación manual de `G-201` y le devuelve una cookie de portal. Diseño §8.3 |
+> | **D10** | Con 0 perfiles en el sistema pedido, ¿`200` con estado de acceso o un `4xx`? | **`200`.** Un status de error es en sí mismo un oráculo del catálogo y conflatea «credencial mala» con «falta el perfil». Diseño §5.4 |
+> | **D11** | ¿Entra `systemCode` también en `POST /auth/login` (portal) en esta ola? | **No.** Cierra solo la mitad de contrato de `G-184`; la otra mitad exige una suite para el portal que no existe en el catálogo. Diseño §10 |
+
+
+| # | Decisión | Opciones | Recomendación |
+| :--- | :--- | :--- | :--- |
+| **D1** | **¿Se enmienda `ADR-0155` §2.3 para alinearlo con `D-031`?** | (a) Enmendar el ADR y corregir el Tablero · (b) Revertir `D-031` y volver a embeber el grafo en el token | **(a).** `D-031` está bien argumentada —grafo revocable, token de 1 149 B— y es lo que UMS ya implementa. Pero **la enmienda va primero**: sin ella el código nace violando `S-06` |
+| **D2** | **¿Qué lado cede en el desajuste de códigos de menú?** Hoy `sdlc-suite.json` declara `SATELITES`/`DASHBOARDS`/`DEM_TABLERO`/`PER_REGISTRO`/`CFG_*` y `common.jsx:10-16` exige `TABLERO.*`. Coinciden **cero de cinco** | (a) La spec adopta el prefijo `TABLERO.` de `ADR-0155` §2.5 · (b) El cliente web adopta los códigos de la spec · (c) Se cambia la convención del ADR | **(b) para el primer ciclo, con (a) como destino.** Cambiar 5 constantes en `common.jsx` cuesta minutos; renombrar 64 nodos ya extraídos y trazados a 96 rutas de API arriesga romper la trazabilidad `SD-05` del inventario. Pero la convención del ADR está `Aceptada`, así que (b) es una **divergencia que hay que declarar**, no un atajo silencioso |
+| **D3** | **¿Entra el refresco por portador (`G-187`) en el alcance?** | (a) No: la sesión dura 60 min y luego re-login · (b) Sí: se implementa | **(a) para el primer ciclo**, siempre que se acepte que ningún escenario dure más de 60 min. (b) si se quiere probar renovación de sesión, y entonces es trabajo de UMS |
+| **D4** | **¿TLS en el primer ciclo?** | (a) T1: todo por `localhost`, sin TLS · (b) T2: `mkcert` + hosts `.local` | **(a).** Por V-06, (b) sin certificados **rompe el login en silencio**; con certificados añade una clase de fallo que enmascara los defectos de contrato que la prueba busca |
+| **D5** | **¿Cómo se custodia el secreto HS256 compartido entre dos clústeres?** | (a) Generado por el script en cada `up`, efímero · (b) Secret fijo gestionado fuera · (c) Se acelera `G-199` (firma asimétrica) | **(a) para la prueba**, con la advertencia impresa en cada ejecución. (c) es lo correcto a plazo y **exige ADR aceptado antes de tocar código** (`S-06`) |
+| **D6** | **¿El robot cubre también el portal de UMS, o solo el Tablero?** | (a) Solo el Tablero · (b) Ambos | **(a).** El portal ya tiene su carril A en RoboSoft. Duplicarlo es el error que este documento existe para evitar |
+| **D7** | **¿Se acepta que la prueba no ejercite TLS ni multi-perfil por usuario?** | Sí / No | **Sí, declarándolo.** Un perfil por usuario (13 disponibles) cubre la comparación; el cambio de perfil en caliente depende de `G-201` y `G-202`, ambos abiertos |
+
+---
+
+
diff --git a/docs/architecture/ep-06-approvals-detailed-design.es.md b/docs/architecture/ep-06-approvals-detailed-design.es.md
new file mode 100644
index 00000000..8d7bbb6d
--- /dev/null
+++ b/docs/architecture/ep-06-approvals-detailed-design.es.md
@@ -0,0 +1,1074 @@
+# EP-06: Diseño Detallado — Seguridad, Acceso Externo y Delegación **Versión:** 1.0 **Fecha:** 2026-05-14 **Épica:** EP-06 (Post-MVP)
+
+**Historias:** US-017 a US-022 **Functional Stories:** FS-09, FS-10, FS-14
+
+---
+
+## PARTE 1: FS-09 — Adaptive MFA & Passwordless Authentication
+
+### 1.1 Definición **FS-09** implementa autenticación adaptativa donde
+
+* **MFA**: Multi-Factor Authentication (requisito condicional basado en riesgo)
+* **Passwordless**: Métodos sin contraseña (FIDO2, magic links, biometría)
+
+El sistema calcula un **Risk Score**en tiempo real y decide automáticamente si MFA es requerido.
+
+### 1.2 Risk Scoring Model
+
+#### 1.2.1 Factores de Riesgo
+
+| Factor | Rango | Peso | Ejemplo |
+| -------- | ------- | ------ | --------- |
+| **Login Frequency Anomaly** | 0-30 | 0.20 | User nunca ha hecho login a esta hora |
+| **Geographic Anomaly** | 0-30 | 0.25 | User está en país diferente al usual |
+| **Device Reputation** | 0-20 | 0.15 | Device nuevo o no reconocido |
+| **Network Anomaly** | 0-10 | 0.10 | IP sospechosa, VPN, proxy |
+| **Failed Attempts** | 0-10 | 0.10 | N intentos fallidos recientes |
+| **Tenant Risk Level** | 0-30 | 0.20 | Tenant categorizado como "high-risk" |
+
+**Risk Score = Σ(Factor × Weight)** Rango: 0 (bajo riesgo) a 100 (alto riesgo)
+
+#### 1.2.2 Thresholds de Decisión
+
+```csharp
+public class MFADecisionEngine
+{
+ // Risk Score → MFA Requirement
+ public MFARequirement CalculateMFARequirement(decimal riskScore, User user, Tenant tenant)
+ {
+ return (riskScore, user.Category, tenant.RiskLevel) switch
+ {
+ // Bajo riesgo: Sin MFA requerido
+ (< 20, _, _) => MFARequirement.NotRequired,
+
+ // Riesgo medio: MFA recomendado (opcional)
+ (20 to 40, UserCategory.INTERNAL, _) => MFARequirement.Recommended,
+ (20 to 40, _, _) => MFARequirement.Required,
+
+ // Riesgo alto: MFA obligatorio
+ (40 to 70, _, _) => MFARequirement.Required,
+
+ // Riesgo crítico: MFA + intervención de admin
+ (> 70, _, _) => MFARequirement.RequiredWithSecurityReview,
+
+ _ => MFARequirement.Required
+ };
+ }
+}
+
+public enum MFARequirement
+{
+ NotRequired, // User puede skipear MFA
+ Recommended, // Mostrar prompt pero permitir skip
+ Required, // MFA obligatorio
+ RequiredWithSecurityReview // MFA + manual admin review
+}
+```
+
+#### 1.2.3 Cálculo de Riesgos por Factor
+
+```csharp
+public class RiskScoringEngine
+{
+ // Factor 1: Login Frequency Anomaly (0-30 puntos)
+ public int CalculateFrequencyAnomaly(User user, DateTime loginAttemptTime)
+ {
+ var userLoginHistory = _auditRepository.GetLoginsByUser(user.Id, lastDays: 30);
+ var usualLoginHours = userLoginHistory
+ .GroupBy(l => l.Timestamp.Hour)
+ .Select(g => (hour: g.Key, frequency: g.Count()))
+ .OrderByDescending(g => g.frequency)
+ .Take(5) // Top 5 horas
+ .Select(g => g.hour)
+ .ToList();
+
+ if (!usualLoginHours.Contains(loginAttemptTime.Hour))
+ return 30; // Anomalía total
+
+ return 0; // Patrón conocido
+ }
+
+ // Factor 2: Geographic Anomaly (0-30 puntos)
+ public int CalculateGeographicAnomaly(User user, string ipAddress)
+ {
+ var userLocation = _geoIpService.GetLocation(ipAddress);
+ var usualCountries = _auditRepository.GetLoginsByUser(user.Id, lastDays: 90)
+ .Select(l => _geoIpService.GetLocation(l.IpAddress).Country)
+ .Distinct()
+ .ToList();
+
+ if (!usualCountries.Contains(userLocation.Country))
+ {
+ // Check si geográficamente POSIBLE viajar en el tiempo
+ var lastLoginLocation = _auditRepository.GetLastLogin(user.Id);
+ var travelTime = CalculateTravelTime(lastLoginLocation, userLocation);
+
+ if (travelTime.TotalMinutes < 120) // Imposible viajar en 2h
+ return 30; // Muy sospechoso
+
+ return 20; // Viaje posible pero raro
+ }
+
+ return 0;
+ }
+
+ // Factor 3: Device Reputation (0-20 puntos)
+ public int CalculateDeviceReputation(User user, string deviceFingerprint)
+ {
+ var knownDevices = _deviceRepository.GetDevicesByUser(user.Id)
+ .Where(d => d.Status == DeviceStatus.TRUSTED)
+ .Select(d => d.Fingerprint)
+ .ToList();
+
+ if (!knownDevices.Contains(deviceFingerprint))
+ return 20; // Device desconocido
+
+ return 0;
+ }
+
+ // Factor 4: Network Anomaly (0-10 puntos)
+ public int CalculateNetworkAnomaly(string ipAddress)
+ {
+ var threatIntel = _threatIntelService.CheckIP(ipAddress);
+
+ return threatIntel switch
+ {
+ { IsMalicious: true } => 10,
+ { IsVPN: true } => 5, // VPN = algo sospechoso
+ { IsProxy: true } => 5,
+ { IsTor: true } => 10,
+ _ => 0
+ };
+ }
+
+ // Factor 5: Failed Attempts (0-10 puntos)
+ public int CalculateFailedAttempts(User user, string ipAddress)
+ {
+ var recentFailures = _auditRepository
+ .GetFailedLoginAttempts(user.Id, ipAddress, lastMinutes: 60)
+ .Count;
+
+ return recentFailures switch
+ {
+ 0 => 0,
+ 1 to 3 => 3,
+ 4 to 6 => 7,
+>= 7 => 10
+ };
+ }
+
+ // Factor 6: Tenant Risk Level (0-30 puntos)
+ public int CalculateTenantRiskLevel(Tenant tenant)
+ {
+ return tenant.RiskLevel switch
+ {
+ TenantRiskLevel.LOW => 0,
+ TenantRiskLevel.MEDIUM => 10,
+ TenantRiskLevel.HIGH => 25,
+ TenantRiskLevel.CRITICAL => 30,
+ _ => 10
+ };
+ }
+}
+```
+
+---
+
+### 1.3 Acceptance Criteria (FS-09)
+
+#### US-017: Adaptive MFA**Como:** Administrador de Seguridad **Quiero:** Reglas de MFA adaptativo para exigir verificación en accesos de riesgo **Para que:** La postura de seguridad mejore sin fricción uniforme **Criteria:**
+
+```gherkin
+Feature: Adaptive MFA Requirements
+
+ Scenario: Low-risk login (interno, dispositivo conocido, hora usual)
+ Given User "alice@corp.com" (INTERNAL) intenta login a las 9am
+ And desde su dispositivo conocido
+ And desde su país usual
+ When Risk Score calculado = 15
+ Then MFA no es requerido
+ And login completa sin MFA
+
+ Scenario: Medium-risk login (hora inusual)
+ Given User "bob@corp.com" intenta login a las 3am
+ And Risk Score calculado = 35
+ When User category = INTERNAL
+ Then MFA es "Recommended" (optional)
+ And se muestra prompt "Verificación adicional?" con skip button
+
+ Scenario: High-risk login (país diferente)
+ Given User "charlie@corp.com" (EXTERNAL) intenta login desde Brasil
+ And su último login fue desde USA hace 1 hora (viaje imposible)
+ When Risk Score calculado = 75
+ Then MFA es "Required"
+ And login BLOQUEADO hasta completar MFA
+
+ Scenario: Critical risk login (múltiples factores)
+ Given User intenta login con Risk Score = 85
+ And factores: país desconocido + 5 intentos fallidos + IP maliciosa
+ When Risk Score > 70
+ Then MFA es "RequiredWithSecurityReview"
+ And login bloqueado + security team notificado
+ And auditoría registra intent malicioso
+
+ Scenario: Tenant High-Risk Category
+ Given Tenant "HighRiskCorp" categorizado como HIGH_RISK
+ And User es de ese tenant
+ When cualquier login
+ Then Risk Score recibe +25 puntos automáticamente
+ And MFA es más probable (threshold más bajo)
+```
+
+---
+
+### 1.4 Métodos Passwordless Soportados
+
+#### FS-09 Scope: Métodos Disponibles
+
+| Método | Descripción | Seguridad | UX | Requisitos |
+| -------- | ------------- | ----------- | ----- | ------------ |
+| **FIDO2 / WebAuthn** | Biometría o security key | (Alta) | (Excelente) | Device con soporte FIDO2 |
+| **Magic Link** | Link por email con token temporal | (Media) | (Excelente) | Email access |
+| **App Notification** | Push a app móvil (similar a Microsoft/Google Authenticator) | (Alta) | (Excelente) | Authenticator app instalada |
+| **SMS OTP** | Código temporal por SMS | (Baja) | (Buena) | Número teléfono verificado |
+| **TOTP** | Time-based OTP (Google Authenticator, Authy) | (Media) | (Buena) | Authenticator app |
+
+**MVP FS-09 Scope:** FIDO2 + Magic Link + App Notification
+
+```csharp
+public interface IPasswordlessMethod
+{
+ string MethodName { get; } // "fido2", "magic_link", "app_notification"
+ Task InitiateAsync(User user);
+ Task VerifyAsync(PasswordlessChallenge challenge, string response);
+}
+
+public class FIDO2Method : IPasswordlessMethod
+{
+ public string MethodName => "fido2";
+
+ public async Task InitiateAsync(User user)
+ {
+ // 1. Generar challenge (random bytes)
+ var challenge = GenerateSecureChallenge(32);
+
+ // 2. Recuperar credential IDs registrados del usuario
+ var credentials = await _credentialRepository.GetFIDO2CredentialsByUser(user.Id);
+
+ // 3. Construir WebAuthn PublicKeyCredentialRequestOptions
+ var options = new PublicKeyCredentialRequestOptions
+ {
+ Challenge = challenge,
+ Timeout = 60000, // 60 segundos
+ UserVerification = UserVerificationRequirement.Preferred,
+ AllowCredentials = credentials.Select(c => new PublicKeyCredentialDescriptor
+ {
+ Type = PublicKeyCredentialType.PublicKey,
+ Id = Convert.FromBase64String(c.CredentialId)
+ }).ToList()
+ };
+
+ // 4. Guardar challenge en cache temporal (expiración 5 min)
+ await _challengeCache.SetAsync($"fido2:{user.Id}", challenge, TimeSpan.FromMinutes(5));
+
+ return new PasswordlessChallenge
+ {
+ Method = "fido2",
+ Options = JsonSerializer.Serialize(options),
+ ExpiresAt = DateTime.UtcNow.AddMinutes(5)
+ };
+ }
+
+ public async Task VerifyAsync(PasswordlessChallenge challenge, string response)
+ {
+ // 1. Parsear respuesta WebAuthn del cliente
+ var assertion = JsonSerializer.Deserialize(response);
+
+ // 2. Validar signature usando credential público
+ var credential = await _credentialRepository.GetCredential(assertion.Id);
+ var isValid = VerifySignature(assertion, credential.PublicKey);
+
+ // 3. Validar counter (prevenir replay attacks)
+ if (assertion.SignCount <= credential.SignCount)
+ return false; // Posible cloning attack
+
+ credential.SignCount = assertion.SignCount;
+ await _credentialRepository.UpdateAsync(credential);
+
+ return isValid;
+ }
+}
+
+public class MagicLinkMethod : IPasswordlessMethod
+{
+ public string MethodName => "magic_link";
+
+ public async Task InitiateAsync(User user)
+ {
+ // 1. Generar token único (40 caracteres aleatorios)
+ var token = GenerateSecureToken(40);
+
+ // 2. Crear "passwordless session" en BD
+ var session = new PasswordlessSession
+ {
+ Id = Guid.NewGuid(),
+ UserId = user.Id,
+ Method = "magic_link",
+ Token = HashToken(token), // Store hash, no plaintext
+ ExpiresAt = DateTime.UtcNow.AddMinutes(15),
+ Status = PasswordlessSessionStatus.PENDING
+ };
+ await _sessionRepository.AddAsync(session);
+
+ // 3. Enviar email con link
+ var magicLink = $"https://ums.example.com/auth/passwordless/verify?token={token}&session={session.Id}";
+ await _emailService.SendAsync(user.Email, new PasswordlessMagicLinkEmail
+ {
+ UserName = user.Name,
+ MagicLink = magicLink,
+ ExpiresIn = "15 minutos"
+ });
+
+ return new PasswordlessChallenge
+ {
+ Method = "magic_link",
+ SessionId = session.Id.ToString(),
+ ExpiresAt = session.ExpiresAt,
+ Message = $"Link enviado a {MaskEmail(user.Email)}"
+ };
+ }
+
+ public async Task VerifyAsync(PasswordlessChallenge challenge, string response)
+ {
+ // response = token del user
+ var session = await _sessionRepository.GetAsync(Guid.Parse(challenge.SessionId));
+
+ if (session == null || session.ExpiresAt < DateTime.UtcNow)
+ return false; // Session no existe o expiró
+
+ // Timing-safe comparison para evitar timing attacks
+ var isValid = TimingSafeEquals(HashToken(response), session.Token);
+
+ if (isValid)
+ {
+ session.Status = PasswordlessSessionStatus.VERIFIED;
+ session.VerifiedAt = DateTime.UtcNow;
+ await _sessionRepository.UpdateAsync(session);
+ }
+
+ return isValid;
+ }
+}
+```
+
+#### Magic Link Flow Detallado
+
+```mermaid
+sequenceDiagram
+ participant Browser
+ participant API as UMS API
+ participant Email
+ Browser->>API: POST /auth/passwordless { email }
+ Note over API: Generar token Crear session Hash token
+ API->>Email: Enviar magic link
+ Email-->>API: Email sent
+ API-->>Browser: 202 Accepted { sessionId, expiresAt }
+ Note over Browser: Usuario click magic link
+ Browser->>API: GET /auth/passwordless/verify?token=XXX&session=YYY
+ Note over API: Recuperar session Validar token Crear JWT
+ API-->>Browser: 302 Redirect + Set-Cookie session_jwt
+ Note over Browser: Usuario autenticado
+```
+
+---
+
+### 1.5 Configuration (FS-09)
+
+Dónde y cómo se configuran las reglas MFA:
+
+```sql
+-- Nueva tabla en Configuration Context
+CREATE TABLE configuration.mfa_policies (id uuid PRIMARY KEY,
+ root_tenant_id uuid NOT NULL,
+ code varchar(64), -- "default", "high-risk-users", etc.
+ name varchar(255),
+ enabled boolean,
+ scope_type varchar(32), -- 'GLOBAL', 'TENANT', 'ORGANIZATION'
+ applies_to_user_category varchar(32), -- 'INTERNAL', 'EXTERNAL', 'B2B'
+
+-- Risk-based thresholds
+ risk_score_required_threshold integer, -- Ej: 40
+ risk_score_review_threshold integer, -- Ej: 70
+
+-- Enabled methods
+ allow_fido2 boolean,
+ allow_magic_link boolean,
+ allow_app_notification boolean,
+ allow_sms_otp boolean,
+ allow_totp boolean,
+
+-- Passwordless-only mode (no password auth)
+ passwordless_only boolean,
+
+ created_at timestamptz,
+ modified_at timestamptz,
+ root_tenant_id uuid);
+
+-- Tabla de Risk Scoring customization por tenant
+CREATE TABLE configuration.risk_scoring_weights (id uuid PRIMARY KEY,
+ root_tenant_id uuid NOT NULL,
+ frequency_anomaly_weight DECIMAL(3,2), -- Default: 0.20
+ geographic_anomaly_weight DECIMAL(3,2), -- Default: 0.25
+ device_reputation_weight DECIMAL(3,2), -- Default: 0.15
+ network_anomaly_weight DECIMAL(3,2), -- Default: 0.10
+ failed_attempts_weight DECIMAL(3,2), -- Default: 0.10
+ tenant_risk_weight DECIMAL(3,2) -- Default: 0.20);
+```
+
+---
+
+## PARTE 2: FS-14 — Delegated Administration & Scopes
+
+### 2.1 Definición **FS-14** permite que administradores deleguen autoridad de gestión a otros con límites controlados
+
+* **Delegating Admin** (A) → **Delegated Admin** (B): "Puedes gestionar usuarios en mi división"
+* **Scope Limiting**: "Solo en ORGANIZATION X", "Solo acciones CREATE_USER y ASSIGN_PROFILE"
+* **Temporal Constraints**: "Válido hasta 2026-12-31"
+* **Approval Required**: Crear delegación puede requerir aprobación (si es sensitive)
+
+### 2.2 State Machine (Delegación)
+
+#### Ciclo de Vida de la Delegacion
+
+```mermaid
+stateDiagram-v2
+ [*] --> DRAFT: Admin completa configuracion
+ DRAFT --> PENDING_APPROVAL: Si requiere approval
+ DRAFT --> ACTIVE: Si no requiere approval
+ PENDING_APPROVAL --> ACTIVE: Approver aprueba
+ PENDING_APPROVAL --> REJECTED: Approver rechaza
+ ACTIVE --> REVOKED: Revocado
+ ACTIVE --> EXPIRED: Expirado
+ ACTIVE --> COMPLETED: Finaliza
+ REVOKED --> ARCHIVED
+ EXPIRED --> ARCHIVED
+ COMPLETED --> ARCHIVED
+ REJECTED --> ARCHIVED
+ ARCHIVED --> [*]
+```
+
+#### 2.2.1 Estados Detallados
+
+| Estado | Descripción | Transiciones Válidas | Eventos |
+| -------- | ------------- | --------------------- | -------- |
+| **DRAFT** | Delegación en creación, no visible | → PENDING_APPROVAL, → ACTIVE | Created |
+| **PENDING_APPROVAL** | Esperando aprobación (si config lo requiere) | → ACTIVE (approved), → REJECTED | SubmittedForApproval |
+| **ACTIVE** | Delegación operativa | → REVOKED, → EXPIRED | Activated |
+| **REVOKED** | Revocado manualmente por admin | → ARCHIVED | Revoked |
+| **EXPIRED** | Expiró por fecha (valid_until) | → ARCHIVED | Expired |
+| **COMPLETED** | Finalizado naturalmente (fin de período) | → ARCHIVED | Completed |
+| **REJECTED** | Rechazado en aprobación | → ARCHIVED | Rejected |
+| **ARCHIVED** | Histórico (no visible en operaciones) | (ninguna) | Archived |
+
+#### 2.2.2 Transiciones Bloqueadas
+
+```csharp
+public class DelegationStateValidator
+{
+ public bool IsValidTransition(DelegationStatus from, DelegationStatus to)
+ {
+ var validTransitions = new Dictionary>
+ {
+ { DelegationStatus.DRAFT, new() { DelegationStatus.PENDING_APPROVAL, DelegationStatus.ACTIVE } },
+ { DelegationStatus.PENDING_APPROVAL, new() { DelegationStatus.ACTIVE, DelegationStatus.REJECTED } },
+ { DelegationStatus.ACTIVE, new() { DelegationStatus.REVOKED, DelegationStatus.EXPIRED } },
+ { DelegationStatus.REVOKED, new() { DelegationStatus.ARCHIVED } },
+ { DelegationStatus.EXPIRED, new() { DelegationStatus.ARCHIVED } },
+ { DelegationStatus.COMPLETED, new() { DelegationStatus.ARCHIVED } },
+ { DelegationStatus.REJECTED, new() { DelegationStatus.ARCHIVED } },
+ { DelegationStatus.ARCHIVED, new() { } } // Terminal
+ };
+
+ return validTransitions.ContainsKey(from) && validTransitions[from].Contains(to);
+ }
+}
+```
+
+---
+
+### 2.3 Scope Model (Límites de Delegación)
+
+Una delegación define qué acciones puede hacer el delegated admin.
+
+#### 2.3.1 Scope Types
+
+```csharp
+public enum ScopeType
+{
+ TENANT, // Toda la organización (root tenant)
+ ORGANIZATION, // Una organización específica (child tenant)
+ DEPARTMENT, // Un departamento
+ SYSTEM, // Un sistema/aplicación específico
+ TEAM // Un equipo
+}
+
+public record DelegationScope
+{
+ public ScopeType Type { get; init; }
+ public Guid? ScopeId { get; init; } // ID de la organización, sistema, etc.
+ public List AllowedActions { get; init; } // ["CREATE_USER", "ASSIGN_PROFILE"]
+}
+```
+
+#### 2.3.2 Allowed Actions (¿Qué puede hacer el delegated admin?)
+
+```csharp
+public enum DelegatedAction
+{
+ // User Management
+ CREATE_USER,
+ VIEW_USER,
+ UPDATE_USER,
+ DEACTIVATE_USER,
+ DELETE_USER,
+ RESET_PASSWORD,
+
+ // Profile/Role Assignment
+ ASSIGN_PROFILE,
+ REVOKE_PROFILE,
+ APPROVE_PROFILE_REQUEST,
+
+ // Delegation
+ CREATE_DELEGATION,
+ REVOKE_DELEGATION,
+ VIEW_DELEGATION,
+
+ // Approvals
+ APPROVE_EXTERNAL_ACCESS,
+ REJECT_EXTERNAL_ACCESS,
+
+ // Audit/Reporting
+ VIEW_AUDIT_LOG,
+ EXPORT_USERS,
+
+ // Configuration
+ CONFIGURE_ORGANIZATION,
+ MANAGE_ORGANIZATION_POLICIES
+}
+```
+
+#### 2.3.3 Principle of Least Privilege Validation **Regla crítica:** Un admin delegado NO puede otorgar permisos mayores a los que posee
+
+```csharp
+public class DelegationPermissionValidator
+{
+ ///
+ /// Valida que los permisos siendo delegados no excedan los del delegating admin.
+ ///
+ public async Task ValidateDelegationAsync(User delegatingAdmin,
+ User delegatedAdmin,
+ DelegationScope requestedScope)
+ {
+ // 1. Obtener permisos efectivos del delegating admin
+ var delegatingAdminPermissions = await _authorizationService
+ .GetEffectivePermissionsAsync(delegatingAdmin.Id);
+
+ // 2. Validar que requested actions están en delegatingAdminPermissions
+ var unauthorizedActions = requestedScope.AllowedActions
+ .Except(delegatingAdminPermissions.Select(p => p.ActionCode))
+ .ToList();
+
+ if (unauthorizedActions.Any())
+ return ValidationResult.Failure($"Admin no puede delegar acciones: {string.Join(", ", unauthorizedActions)}");
+
+ // 3. Validar scope: delegating admin no puede delegar fuera de su propio scope
+ var delegatingAdminScope = await _delegationRepository
+ .GetDelegationScopeAsync(delegatingAdmin.Id);
+
+ if (!IsWithinScope(requestedScope, delegatingAdminScope))
+ return ValidationResult.Failure("Delegación solicitada excede el scope del admin delegante");
+
+ // 4. Validar que delegated admin no tenga conflictos de interés
+ // (ej: no delegar a admin de un competidor dentro mismo tenant)
+ if (HasConflictOfInterest(delegatedAdmin, requestedScope))
+ return ValidationResult.Failure("Conflicto de interés detectado");
+
+ return ValidationResult.Success();
+ }
+
+ private bool IsWithinScope(DelegationScope requested, DelegationScope delegatingAdmin)
+ {
+ return requested.Type switch
+ {
+ ScopeType.TENANT when delegatingAdmin.Type == ScopeType.TENANT
+ => requested.ScopeId == delegatingAdmin.ScopeId,
+
+ ScopeType.ORGANIZATION when delegatingAdmin.Type == ScopeType.TENANT
+ => true, // Tenant-level admin puede delegar a org-level
+
+ ScopeType.ORGANIZATION when delegatingAdmin.Type == ScopeType.ORGANIZATION
+ => requested.ScopeId == delegatingAdmin.ScopeId,
+
+ _ => false
+ };
+ }
+}
+```
+
+---
+
+### 2.4 Temporal Constraints
+
+Delegaciones pueden tener validez limitada.
+
+```csharp
+public record DelegationTemporalConstraints
+{
+ public DateTime ValidFrom { get; init; }
+ public DateTime ValidUntil { get; init; }
+ public TimeSpan? MaxDuration { get; init; } // Máximo duración permitida (ej: 90 días)
+ public DayOfWeek[]? AllowedDaysOfWeek { get; init; } // Ej: solo business days
+ public TimeSpan? AllowedTimeRange { get; init; } // Ej: 9am-6pm solo
+}
+
+public class DelegationExpirationService : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ // Cada hora, buscar delegaciones que expiraron
+ var expiredDelegations = await _delegationRepository
+ .GetExpiredAsync(DateTime.UtcNow);
+
+ foreach (var delegation in expiredDelegations)
+ {
+ // Transicionar a EXPIRED state
+ delegation.Status = DelegationStatus.EXPIRED;
+ delegation.ModifiedAt = DateTime.UtcNow;
+
+ await _delegationRepository.UpdateAsync(delegation);
+
+ // Registrar en auditoria
+ await _auditService.LogAsync(new AuditEvent
+ {
+ EventType = "DELEGATION_EXPIRED",
+ DelegationId = delegation.Id,
+ RootTenantId = delegation.RootTenantId,
+ Timestamp = DateTime.UtcNow
+ });
+
+ // Notificar al delegating admin
+ await _notificationService.NotifyAsync(delegation.DelegatingAdminId,
+ "Delegación expirada",
+ $"Delegación a {delegation.DelegatedAdmin.Name} expiró");
+ }
+
+ await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
+ }
+ }
+}
+```
+
+---
+
+### 2.5 Acceptance Criteria (FS-14)
+
+```gherkin
+Feature: Delegated Administration with Scope Control
+
+ Scenario: Create delegation within scope
+ Given Admin "alice@corp.com" (TENANT-level)
+ When crea delegación a "bob@corp.com"
+ And scope: ORGANIZATION "Sales Division"
+ And allowed_actions: [CREATE_USER, ASSIGN_PROFILE]
+ And valid_from: 2026-05-15
+ And valid_until: 2026-12-31
+ Then Delegation creada en estado DRAFT
+ And Audit registra: DELEGATION_CREATED
+
+ Scenario: Approve delegation that requires review
+ Given Delegation en estado PENDING_APPROVAL
+ When Approver aprueba
+ Then Delegation transiciona a ACTIVE
+ And Delegated admin puede gestionar usuarios
+ And Audit registra: DELEGATION_APPROVED
+
+ Scenario: Prevent escalation of privilege
+ Given Admin "charlie@corp.com" (ORG-level, permisos limitados)
+ When intenta crear delegación con permisos > sus propios
+ Then Validación falla
+ And Error: "Cannot delegate permissions you don't possess"
+ And Audit registra: DELEGATION_VALIDATION_FAILED
+
+ Scenario: Auto-expire delegation on valid_until
+ Given Delegation con valid_until: 2026-12-31
+ When Sistema alcanza 2027-01-01
+ Then Delegation transiciona automáticamente a EXPIRED
+ And Delegated admin pierde acceso
+ And Audit registra: DELEGATION_EXPIRED
+
+ Scenario: Manual revocation by delegating admin
+ Given Delegation en estado ACTIVE
+ When Delegating admin ejecuta "Revoke delegation"
+ Then Delegation transiciona a REVOKED
+ And Razón de revocación registrada
+ And Delegated admin recibe notificación
+ And Audit registra: DELEGATION_REVOKED
+
+ Scenario: Delegated admin operates within scope
+ Given Delegated admin "bob" con scope: ORG "Sales"
+ And allowed_actions: [CREATE_USER]
+ When intenta crear user en Sales org
+ Then Operación permitida
+ When intenta crear user en Engineering org (fuera scope)
+ Then Operación bloqueada
+ And Error: "Outside delegated scope"
+```
+
+---
+
+## PARTE 3: ER Model Completo (EP-06)
+
+### 3.1 Tablas Nuevas
+
+```sql
+-- ============================================
+-- APPROVALS CONTEXT TABLES
+-- ============================================
+
+CREATE TABLE approval.approval_workflows (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ code varchar(64) NOT NULL,
+ name varchar(255) NOT NULL,
+ description text,
+
+-- Trigger que inicia el workflow
+ trigger_type varchar(32) NOT NULL, -- 'USER_ONBOARDING', 'PROFILE_ASSIGNMENT', 'DELEGATION_CREATION', 'B2B_ACCESS_REQUEST'
+
+-- Tipo de aprobación
+ approval_type varchar(32) NOT NULL, -- 'SERIAL' (uno después de otro), 'PARALLEL' (todos simultáneamente), 'QUORUM' (mayoría)
+ required_approvals integer NOT NULL DEFAULT 1, -- Cuántas aprobaciones se necesitan
+
+-- Timing
+ timeout_days integer DEFAULT 7, -- Cuántos días antes de auto-reject
+ escalate_after_days integer, -- Cuándo escalar a superior si no aprueba
+
+-- Scope
+ scope_type varchar(32), -- 'GLOBAL', 'TENANT', 'ORGANIZATION'
+ applies_to_user_category varchar(32), -- 'INTERNAL', 'EXTERNAL', 'B2B' (NULL = all)
+
+-- Audit
+ enabled boolean NOT NULL DEFAULT true,
+ created_by varchar(255),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ modified_by varchar(255),
+ modified_at timestamptz,
+ is_deleted boolean NOT NULL DEFAULT false,
+
+ CONSTRAINT pk_approval_workflows PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_approval_workflows_tenant FOREIGN KEY (root_tenant_id) REFERENCES identity.tenants(id));
+
+CREATE TABLE approval.approval_rules (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ workflow_id uuid NOT NULL,
+ rule_order integer NOT NULL, -- Orden de evaluación
+
+-- Condición que gatilla esta regla
+ condition_json text, -- JSON: { "riskScore": "> 50", "userCategory": "EXTERNAL" }
+
+-- Quién aprueba si esta regla aplica
+ approver_role varchar(64), -- 'SECURITY_ADMIN', 'DEPARTMENT_HEAD', 'COMPLIANCE_OFFICER'
+ approver_count integer DEFAULT 1,
+
+ created_at timestamptz NOT NULL DEFAULT now(),
+ is_deleted boolean NOT NULL DEFAULT false,
+
+ CONSTRAINT pk_approval_rules PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_approval_rules_workflow FOREIGN KEY (workflow_id, root_tenant_id) REFERENCES approval.approval_workflows(id, root_tenant_id));
+
+CREATE TABLE approval.approval_requests (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ workflow_id uuid NOT NULL,
+
+-- Quién solicita
+ requester_id uuid NOT NULL,
+
+-- Target de la solicitud
+ target_user_id uuid,
+ target_entity_type varchar(32), -- 'USER', 'PROFILE', 'DELEGATION', 'B2B_ACCESS'
+ target_entity_id uuid,
+
+-- Descripción
+ requested_action varchar(255) NOT NULL,
+ request_reason text,
+ business_justification text,
+
+-- Timing
+ created_at timestamptz NOT NULL DEFAULT now(),
+ submitted_at timestamptz,
+ expires_at timestamptz,
+ completed_at timestamptz,
+
+-- Estado
+ status varchar(32) NOT NULL DEFAULT 'DRAFT', -- DRAFT, SUBMITTED, PENDING, APPROVED, REJECTED, ESCALATED
+ final_decision varchar(32), -- APPROVED, REJECTED
+ final_decision_reason text,
+
+-- Metadata
+ priority varchar(32), -- LOW, MEDIUM, HIGH, CRITICAL
+ risk_score DECIMAL(5,2),
+
+ CONSTRAINT pk_approval_requests PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_approval_requests_workflow FOREIGN KEY (workflow_id, root_tenant_id) REFERENCES approval.approval_workflows(id, root_tenant_id),
+ CONSTRAINT fk_approval_requests_requester FOREIGN KEY (requester_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id),
+ CONSTRAINT fk_approval_requests_target FOREIGN KEY (target_user_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id));
+
+CREATE TABLE approval.approval_approvers (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ approval_request_id uuid NOT NULL,
+
+-- Quién aprueba
+ approver_id uuid NOT NULL,
+ approver_role varchar(64),
+
+-- Orden de aprobación (para SERIAL workflows)
+ approval_order integer,
+
+-- Decisión
+ status varchar(32) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, ESCALATED
+ approved_at timestamptz,
+ decision_reason text,
+ decision_notes text,
+
+-- Escalación
+ escalated_to_id uuid, -- Superior si escalado
+ escalated_at timestamptz,
+
+ CONSTRAINT pk_approval_approvers PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_approval_approvers_request FOREIGN KEY (approval_request_id, root_tenant_id) REFERENCES approval.approval_requests(id, root_tenant_id),
+ CONSTRAINT fk_approval_approvers_approver FOREIGN KEY (approver_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id));
+
+CREATE TABLE approval.approval_attachments (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ approval_request_id uuid NOT NULL,
+
+ document_name varchar(255) NOT NULL,
+ document_type varchar(64), -- 'SERVICE_AGREEMENT', 'IDENTITY_PROOF', etc.
+ storage_uri text NOT NULL, -- URL a archivo en Azure Blob Storage, S3, etc.
+ file_size_bytes bigint,
+ uploaded_by uuid,
+ uploaded_at timestamptz NOT NULL DEFAULT now(),
+
+ CONSTRAINT pk_approval_attachments PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_approval_attachments_request FOREIGN KEY (approval_request_id, root_tenant_id) REFERENCES approval.approval_requests(id, root_tenant_id));
+
+-- ============================================
+-- DELEGATION CONTEXT TABLES
+-- ============================================
+
+CREATE TABLE delegation.user_management_delegations (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+
+-- Admin roles
+ delegating_admin_id uuid NOT NULL, -- Quién delega
+ delegated_admin_id uuid NOT NULL, -- A quién se delega
+
+-- Scope
+ scope_type varchar(32) NOT NULL, -- TENANT, ORGANIZATION, DEPARTMENT, SYSTEM, TEAM
+ scope_id uuid, -- ID de org, dept, etc.
+
+-- Acciones permitidas
+ allowed_actions text NOT NULL, -- JSON array: ["CREATE_USER", "ASSIGN_PROFILE", ...]
+
+-- Temporal validity
+ valid_from timestamptz NOT NULL,
+ valid_until timestamptz NOT NULL,
+ max_duration_days integer, -- Máxima duración permitida (para validación)
+
+-- Approval
+ requires_approval boolean NOT NULL DEFAULT false,
+ approval_request_id uuid, -- Link a approval request si fue requerido
+
+-- Estado
+ status varchar(32) NOT NULL DEFAULT 'DRAFT', -- DRAFT, PENDING_APPROVAL, ACTIVE, REVOKED, EXPIRED, REJECTED, COMPLETED, ARCHIVED
+ revoked_at timestamptz,
+ revoked_by uuid,
+ revocation_reason text,
+
+-- Restricciones adicionales
+ restricted_to_user_category varchar(32), -- Ej: solo usuarios EXTERNAL
+ restricted_to_organization_id uuid, -- Ej: solo en esta org
+
+-- Audit
+ created_by uuid NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ modified_by varchar(255),
+ modified_at timestamptz,
+
+ CONSTRAINT pk_user_management_delegations PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_delegation_delegating_admin FOREIGN KEY (delegating_admin_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id),
+ CONSTRAINT fk_delegation_delegated_admin FOREIGN KEY (delegated_admin_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id),
+ CONSTRAINT fk_delegation_approval FOREIGN KEY (approval_request_id, root_tenant_id) REFERENCES approval.approval_requests(id, root_tenant_id));
+
+-- ============================================
+-- INDICES para Performance
+-- ============================================
+
+CREATE INDEX idx_approval_requests_workflow ON approval.approval_requests (workflow_id, root_tenant_id)
+ WHERE status NOT IN ('APPROVED', 'REJECTED');
+
+CREATE INDEX idx_approval_requests_target ON approval.approval_requests (target_user_id, root_tenant_id);
+
+CREATE INDEX idx_approval_approvers_request ON approval.approval_approvers (approval_request_id, root_tenant_id);
+
+CREATE INDEX idx_approval_approvers_approver ON approval.approval_approvers (approver_id, root_tenant_id)
+ WHERE status = 'PENDING';
+
+CREATE INDEX idx_delegations_delegated_admin ON delegation.user_management_delegations (delegated_admin_id, root_tenant_id)
+ WHERE status = 'ACTIVE';
+
+CREATE INDEX idx_delegations_scope ON delegation.user_management_delegations (scope_type, scope_id, root_tenant_id)
+ WHERE status IN ('ACTIVE', 'PENDING_APPROVAL');
+```
+
+---
+
+### 3.2 Modification to Existing Tables
+
+```sql
+-- Agregar columnas a users table para track delegated admin status
+ALTER TABLE identity.users
+ ADD COLUMN is_delegated_admin boolean NOT NULL DEFAULT false,
+ ADD COLUMN delegated_admin_scopes text; -- JSON: cached scopes for performance
+
+-- Agregar columnas a approval_requests para link a MFA/passwordless decisions
+ALTER TABLE approval.approval_requests
+ ADD COLUMN risk_score DECIMAL(5,2),
+ ADD COLUMN mfa_required boolean,
+ ADD COLUMN passwordless_allowed boolean;
+```
+
+---
+
+## PARTE 4: Integration Map (EP-06)
+
+### 4.1 Approvals ↔ Authorization Context
+
+```mermaid
+flowchart TD
+ subgraph AC[APPROVALS CONTEXT]
+ AR[approval_requests]
+ AW[approval_workflows]
+ AA[approval_approvers]
+ AT[approval_attachments]
+ end
+ AC -->|Requires user permission?| AZ
+ subgraph AZ[AUTHORIZATION CONTEXT]
+ PO[policies]
+ PB[policy_bindings]
+ PE[permissions]
+ end
+```
+
+Flujo: el Approver debe tener permiso `APPROVE_PROFILE_ASSIGNMENT` para aprobar un `approval_request` de asignacion de profile.
+
+**Queries de integración:**
+
+```csharp
+public interface IApprovalAuthorizationValidator
+{
+ ///
+ /// Valida que approver tiene permiso para aprobar esta request.
+ ///
+ Task CanApproveAsync(User approver, ApprovalRequest request);
+}
+
+public class ApprovalAuthorizationValidator : IApprovalAuthorizationValidator
+{
+ public async Task CanApproveAsync(User approver, ApprovalRequest request)
+ {
+ // 1. Determinar qué permission se necesita basado en el tipo de request
+ var requiredPermission = request.TargetEntityType switch
+ {
+ "PROFILE" => "APPROVE_PROFILE_ASSIGNMENT",
+ "USER_ONBOARDING" => "APPROVE_USER_ONBOARDING",
+ "B2B_ACCESS" => "APPROVE_B2B_ACCESS",
+ "DELEGATION" => "APPROVE_DELEGATION",
+ _ => throw new InvalidOperationException()
+ };
+
+ // 2. Check si el approver tiene esa permission
+ var permissions = await _authorizationService
+ .GetEffectivePermissionsAsync(approver.Id);
+
+ return permissions.Any(p => p.ActionCode == requiredPermission);
+ }
+}
+```
+
+### 4.2 Approvals ↔ Audit Context
+
+```mermaid
+flowchart TD
+ AC[APPROVALS CONTEXT Generates events]
+ AC -->|APPROVAL_REQUEST_CREATED APPROVAL_SUBMITTED APPROVAL_APPROVED APPROVAL_REJECTED APPROVAL_ESCALATED| AU
+ AU[AUDIT CONTEXT audit_log receives events stores immutable trail]
+```
+
+Cada decision de aprobacion se registra en `audit_log` con: approver, timestamp, decision, reason.
+
+### 4.3 Approvals ↔ Configuration Context
+
+```mermaid
+flowchart TD
+ CC[CONFIGURATION CONTEXT approval_workflows configurable approval_rules configurable mfa_policies configurable risk_scoring_weights tunable]
+ CC -->|Defines approval behavior| AC
+ AC[APPROVALS CONTEXT Uses workflows from config Applies rules from config Evaluates risk scores per config]
+```
+
+---
+
+## Summary EP-06 Deliverables
+
+### Completed in This Document
+
+1. **FS-09 Adaptive MFA**
+
+ * Risk Scoring Model (6 factors, weighted)
+ * Decision Engine (thresholds)
+ * Passwordless Methods (FIDO2, Magic Link, App Notification)
+ * Configuration Model
+ * Acceptance Criteria (5 scenarios)
+
+2. **FS-14 Delegated Admin**
+
+ * State Machine (8 states)
+ * Scope Model (5 scope types, allowed actions)
+ * Principle of Least Privilege Validation
+ * Temporal Constraints & Auto-Expiration
+ * Acceptance Criteria (6 scenarios)
+
+3. **ER Model (Complete)**
+
+ * approval_workflows
+ * approval_rules
+ * approval_requests
+ * approval_approvers
+ * approval_attachments
+ * user_management_delegations
+ * Indices for performance
+
+4. **Integration Map**
+
+ * Approvals ↔ Authorization
+ * Approvals ↔ Audit
+ * Approvals ↔ Configuration
+
+---
+
+### Próximo: EP-07 Compliance (Documento separado)
+
+---
+
+**Aprobado por:** Arquitecto Principal **Fecha:** 2026-05-14
diff --git a/docs/architecture/ep-07-compliance-detailed-design.es.md b/docs/architecture/ep-07-compliance-detailed-design.es.md
new file mode 100644
index 00000000..28ef9ed7
--- /dev/null
+++ b/docs/architecture/ep-07-compliance-detailed-design.es.md
@@ -0,0 +1,959 @@
+# EP-07: Diseño Detallado — Ciclo de Vida de Cumplimiento
+
+**Versión:** 1.0
+**Fecha:** 2026-05-14
+**Épica:** EP-07 (Post-MVP)
+**Historias:** US-023 a US-028
+**Functional Stories:** FS-11, FS-15 (NEW), FS-16 (NEW)
+
+---
+
+## PARTE 1: FS-11 — Upload & Validate User Document
+
+### 1.1 Definición
+
+**FS-11** permite que usuarios y administradores carguen documentos (identidad, certificados, acuerdos) para cumplimiento.
+
+Workflow:
+
+1. **Upload**: Usuario carga documento → storage seguro
+2. **Validation**: Validador revisa → APPROVED / REJECTED
+3. **Lifecycle**: Documento válido hasta fecha de revalidación
+4. **Enforcement**: Si vence, acceso puede ser afectado (integración con FS-16)
+
+### 1.2 Document Type Taxonomy
+
+```csharp
+public enum DocumentType
+{
+ // Identity Verification
+ IDENTITY_PROOF, // Passport, DNI, Driver License
+ ADDRESS_VERIFICATION, // Utility bill, bank statement
+ CORPORATE_REGISTRATION, // Articles of incorporation
+
+ // Authorization
+ SERVICE_AGREEMENT, // B2B contract
+ DATA_PROCESSING_AGREEMENT, // DPA
+ NON_DISCLOSURE_AGREEMENT, // NDA
+
+ // Compliance
+ BACKGROUND_CHECK, // Criminal record clearance
+ INSURANCE_CERTIFICATE, // Liability, D&O
+ SECURITY_CLEARANCE, // Government clearance
+
+ // Role-specific
+ CERTIFICATION, // Professional cert (CPA, CISSP)
+ TRAINING_COMPLETION, // Mandatory training proof
+ MEDICAL_CLEARANCE, // For certain roles
+
+ // Custom (tenant-specific)
+ CUSTOM_DOCUMENT // Tenant-defined
+}
+
+public record DocumentTypeConfiguration
+{
+ public DocumentType Type { get; init; }
+ public string Name { get; init; }
+ public string Description { get; init; }
+ public TimeSpan ValidityPeriod { get; init; } // Cuánto tiempo válido
+ public bool RequiresValidation { get; init; } // Quién aprueba
+ public List ValidatorRoles { get; init; } // COMPLIANCE_OFFICER, HR_ADMIN, etc.
+ public long MaxFileSizeBytes { get; init; }
+ public List AllowedMimeTypes { get; init; } // PDF, JPG, etc.
+}
+```
+
+### 1.3 Acceptance Criteria (FS-11)
+
+```gherkin
+Feature: Document Upload & Validation
+
+ Scenario: Upload identity document
+ Given User "alice@corp.com" is EXTERNAL
+ When uploads document type: IDENTITY_PROOF
+ And document: passport.pdf (500KB, valid PDF)
+ Then document stored in secure location
+ And document status = UPLOADED
+ And audit logs: DOCUMENT_UPLOADED
+ And validator notified for review
+
+ Scenario: Validate document - APPROVED
+ Given Document in UPLOADED status
+ When Compliance Officer reviews
+ And approves with: "Document valid, matches user"
+ Then document status = APPROVED
+ And valid_until = now + 365 days
+ And audit logs: DOCUMENT_APPROVED with notes
+ And user notified: "Document approved"
+
+ Scenario: Validate document - REJECTED
+ Given Document in UPLOADED status
+ When Compliance Officer reviews
+ And rejects with reason: "Document expired"
+ Then document status = REJECTED
+ And audit logs: DOCUMENT_REJECTED with reason
+ And user notified: "Document rejected"
+ And user can re-upload
+
+ Scenario: Document revalidation needed
+ Given APPROVED document with valid_until = 2026-12-31
+ When today > 2026-12-31
+ Then document status = REVALIDATION_REQUIRED
+ And notified: user + admin
+ And user can upload new document
+
+ Scenario: Prevent upload of invalid file type
+ Given User tries to upload: document.exe
+ When file type not in allowed list
+ Then upload rejected
+ And error: "Invalid file type. Allowed: PDF, JPG, PNG"
+```
+
+---
+
+### 1.4 Storage & Security
+
+```csharp
+public class SecureDocumentStorageService : IDocumentStorageService
+{
+ private readonly ISecureStorageProvider _storage; // Azure Blob, S3, etc.
+ private readonly IEncryptionService _encryption;
+ private readonly IDocumentRepository _repository;
+
+ public async Task UploadDocumentAsync(
+ User uploader,
+ DocumentUploadRequest request,
+ Stream fileStream,
+ CancellationToken cancellationToken)
+ {
+ // 1. Validar el archivo
+ if (!IsValidFileType(request.DocumentType, request.FileName))
+ throw new InvalidDocumentException("File type not allowed");
+
+ if (fileStream.Length > GetMaxFileSize(request.DocumentType))
+ throw new DocumentTooLargeException("File exceeds maximum size");
+
+ // 2. Encriptar documento
+ var encryptedStream = await _encryption.EncryptAsync(fileStream);
+
+ // 3. Almacenar en secure storage con path pattern:
+ // /documents/{root_tenant_id}/{user_id}/{document_id}/{filename}
+ var documentId = Guid.NewGuid();
+ var storagePath = $"documents/{uploader.RootTenantId}/{uploader.Id}/{documentId}/{request.FileName}";
+
+ var storageUri = await _storage.UploadAsync(storagePath, encryptedStream);
+
+ // 4. Crear registro de documento
+ var document = new UserDocument
+ {
+ Id = documentId,
+ RootTenantId = uploader.RootTenantId,
+ UserId = uploader.Id,
+ Type = request.DocumentType,
+ FileName = request.FileName,
+ StorageUri = storageUri,
+ FileSizeBytes = fileStream.Length,
+ Status = DocumentStatus.UPLOADED,
+ UploadedBy = uploader.Id,
+ UploadedAt = DateTime.UtcNow,
+ FileHash = ComputeHash(fileStream) // Para virus/tamper detection
+ };
+
+ await _repository.AddAsync(document);
+
+ // 5. Notificar validadores
+ var validators = await _userRepository.GetUsersByRoleAsync(
+ uploader.RootTenantId,
+ "COMPLIANCE_OFFICER");
+
+ foreach (var validator in validators)
+ {
+ await _notificationService.NotifyAsync(
+ validator.Id,
+ $"Document requiring validation: {request.DocumentType}",
+ $"User {uploader.Name} uploaded {request.DocumentType}");
+ }
+
+ // 6. Audit
+ await _auditService.LogAsync(new AuditEvent
+ {
+ EventType = "DOCUMENT_UPLOADED",
+ UserId = uploader.Id,
+ ResourceId = documentId.ToString(),
+ Details = new { DocumentType = request.DocumentType, FileName = request.FileName }
+ });
+
+ return new StorageResult { DocumentId = documentId, Status = "UPLOADED" };
+ }
+
+ public async Task DownloadDocumentAsync(
+ User requester,
+ Guid documentId,
+ CancellationToken cancellationToken)
+ {
+ var document = await _repository.GetAsync(documentId);
+
+ // Validar acceso
+ if (document.UserId != requester.Id &&
+ !await _authorizationService.HasPermissionAsync(requester, "VIEW_DOCUMENTS"))
+ throw new UnauthorizedAccessException();
+
+ // Descargar y desencriptar
+ var encryptedStream = await _storage.DownloadAsync(document.StorageUri);
+ var decryptedStream = await _encryption.DecryptAsync(encryptedStream);
+
+ // Audit
+ await _auditService.LogAsync(new AuditEvent
+ {
+ EventType = "DOCUMENT_DOWNLOADED",
+ UserId = requester.Id,
+ ResourceId = documentId.ToString()
+ });
+
+ return decryptedStream;
+ }
+}
+```
+
+### 1.5 Validation Workflow
+
+```sql
+CREATE TABLE compliance.documents (
+ id uuid PRIMARY KEY,
+ root_tenant_id uuid NOT NULL,
+ user_id uuid NOT NULL,
+ document_type varchar(64) NOT NULL, -- IDENTITY_PROOF, SERVICE_AGREEMENT, etc.
+ document_name varchar(255) NOT NULL,
+ storage_uri text NOT NULL,
+ file_size_bytes bigint,
+ file_hash varchar(256), -- SHA-256 para integrity
+
+ uploaded_at timestamptz NOT NULL DEFAULT now(),
+ uploaded_by uuid,
+ status varchar(32) NOT NULL DEFAULT 'UPLOADED', -- UPLOADED, VALIDATING, APPROVED, REJECTED, REVALIDATION_REQUIRED
+ valid_until timestamptz, -- Cuándo vence
+
+ CONSTRAINT pk_documents PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_documents_user FOREIGN KEY (user_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id)
+);
+
+CREATE TABLE compliance.document_validators (
+ id uuid PRIMARY KEY,
+ root_tenant_id uuid NOT NULL,
+ document_id uuid NOT NULL,
+ validator_id uuid NOT NULL,
+
+ validation_status varchar(32), -- PENDING, APPROVED, REJECTED
+ validation_date timestamptz,
+ validation_notes text,
+ validation_reason text,
+
+ CONSTRAINT pk_document_validators PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_document_validators_doc FOREIGN KEY (document_id, root_tenant_id) REFERENCES compliance.documents(id, root_tenant_id),
+ CONSTRAINT fk_document_validators_user FOREIGN KEY (validator_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id)
+);
+```
+
+---
+
+## PARTE 2: FS-15 — Expiration Notification Rules (NEW)
+
+### 2.1 Definición
+
+**FS-15** define cuándo y cómo notificar a usuarios/admins sobre accesos que vencerán.
+
+**Concepto clave:** Reglas configurables por tenant para alertar ANTES de que el acceso sea revocado.
+
+### 2.2 Notification Rule Model
+
+```csharp
+public record ExpirationNotificationRule
+{
+ public Guid Id { get; init; }
+ public Guid RootTenantId { get; init; }
+ public string Code { get; init; } // "expiry_30d", "expiry_7d", etc.
+ public string Name { get; init; }
+ public string Description { get; init; }
+
+ // Qué tipo de acceso expira
+ public string ScopeType { get; init; } // 'PROFILE', 'PERMISSION', 'DELEGATION', 'DOCUMENT'
+ public string? TargetUserCategory { get; init; } // INTERNAL, EXTERNAL, B2B (null = all)
+
+ // Cuándo notificar ANTES de expiración
+ public int DaysBeforeExpiration { get; init; } // 30, 7, 1
+
+ // Quién se notifica
+ public bool NotifyUser { get; init; }
+ public bool NotifyAdmin { get; init; }
+ public bool NotifyApprover { get; init; }
+
+ // Cómo notificar
+ public List Channels { get; init; } // EMAIL, IN_APP, SMS, WEBHOOK
+
+ // Frecuencia de renotificación
+ public NotificationFrequency Frequency { get; init; } // ONCE, DAILY, WEEKLY
+
+ public bool Enabled { get; init; }
+ public DateTime CreatedAt { get; init; }
+}
+
+public enum NotificationChannel
+{
+ EMAIL,
+ IN_APP,
+ SMS,
+ WEBHOOK,
+ SLACK
+}
+
+public enum NotificationFrequency
+{
+ ONCE, // Una sola notificación
+ DAILY, // Cada día hasta expiración
+ WEEKLY, // Una vez por semana
+ ON_LOGIN // Cada vez que user intenta login
+}
+```
+
+### 2.3 Notification Engine
+
+```csharp
+public class ExpirationNotificationEngine : BackgroundService
+{
+ private readonly IExpirationRepository _expirationRepo;
+ private readonly INotificationService _notificationService;
+ private readonly IExpirationRuleRepository _ruleRepository;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ // Ejecutar cada hora
+ await ProcessExpiringAccessAsync(stoppingToken);
+ await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
+ }
+ }
+
+ private async Task ProcessExpiringAccessAsync(CancellationToken cancellationToken)
+ {
+ // 1. Obtener todas las reglas habilitadas
+ var rules = await _ruleRepository.GetEnabledRulesAsync();
+
+ foreach (var rule in rules)
+ {
+ // 2. Encontrar accesos que expiran en rule.DaysBeforeExpiration días
+ var expiringAccess = await _expirationRepo.GetExpiringAccessAsync(
+ ruleScope: rule.ScopeType,
+ daysUntilExpiration: rule.DaysBeforeExpiration,
+ userCategory: rule.TargetUserCategory);
+
+ foreach (var access in expiringAccess)
+ {
+ // 3. Verificar si ya se notificó (para evitar spam)
+ var lastNotification = await _notificationService.GetLastNotificationAsync(
+ access.UserId,
+ rule.Id);
+
+ if (ShouldSendNotification(lastNotification, rule.Frequency))
+ {
+ // 4. Enviar notificación
+ var notification = new ExpirationNotification
+ {
+ UserId = access.UserId,
+ AccessType = rule.ScopeType,
+ ExpiresAt = access.ExpiresAt,
+ DaysRemaining = rule.DaysBeforeExpiration,
+ RuleId = rule.Id
+ };
+
+ await SendNotificationAsync(notification, rule);
+
+ // 5. Registrar en auditoría
+ await _auditService.LogAsync(new AuditEvent
+ {
+ EventType = "EXPIRATION_NOTIFICATION_SENT",
+ UserId = access.UserId,
+ Details = new { RuleId = rule.Id, DaysRemaining = rule.DaysBeforeExpiration }
+ });
+ }
+ }
+ }
+ }
+
+ private async Task SendNotificationAsync(ExpirationNotification notification, ExpirationNotificationRule rule)
+ {
+ var user = await _userRepository.GetAsync(notification.UserId);
+
+ if (rule.NotifyUser)
+ {
+ foreach (var channel in rule.Channels)
+ {
+ await SendViaChannelAsync(user, notification, channel);
+ }
+ }
+
+ if (rule.NotifyAdmin)
+ {
+ var admins = await _userRepository.GetUsersByRoleAsync(
+ user.RootTenantId, "ADMIN");
+
+ foreach (var admin in admins)
+ {
+ foreach (var channel in rule.Channels)
+ {
+ await SendViaChannelAsync(admin, notification, channel);
+ }
+ }
+ }
+ }
+
+ private async Task SendViaChannelAsync(User recipient, ExpirationNotification notification, NotificationChannel channel)
+ {
+ var message = BuildNotificationMessage(notification);
+
+ switch (channel)
+ {
+ case NotificationChannel.EMAIL:
+ await _emailService.SendAsync(recipient.Email,
+ $"Access Expiring in {notification.DaysRemaining} Days",
+ message);
+ break;
+
+ case NotificationChannel.IN_APP:
+ await _inAppNotificationService.SendAsync(recipient.Id, message);
+ break;
+
+ case NotificationChannel.SMS:
+ if (recipient.PhoneNumber != null)
+ await _smsService.SendAsync(recipient.PhoneNumber, message);
+ break;
+
+ case NotificationChannel.WEBHOOK:
+ await _webhookService.NotifyAsync(notification);
+ break;
+
+ case NotificationChannel.SLACK:
+ await _slackService.NotifyAsync(recipient.SlackUserId, message);
+ break;
+ }
+ }
+
+ private bool ShouldSendNotification(DateTime? lastNotification, NotificationFrequency frequency)
+ {
+ return frequency switch
+ {
+ NotificationFrequency.ONCE => lastNotification == null,
+ NotificationFrequency.DAILY => lastNotification == null ||
+ (DateTime.UtcNow - lastNotification.Value).TotalDays >= 1,
+ NotificationFrequency.WEEKLY => lastNotification == null ||
+ (DateTime.UtcNow - lastNotification.Value).TotalDays >= 7,
+ _ => false
+ };
+ }
+}
+```
+
+### 2.4 Acceptance Criteria (FS-15)
+
+```gherkin
+Feature: Expiration Notification Rules
+
+ Scenario: Configure notification rule
+ Given Admin accesses Configuration > Expiration Notifications
+ When creates rule:
+- Code: "external_30d"
+- Scope: PROFILE
+- Target: EXTERNAL users
+- Days: 30
+- Notify: User + Admin
+- Channels: EMAIL, IN_APP
+- Frequency: ONCE
+ Then rule saved and enabled
+ And audit logs: RULE_CREATED
+
+ Scenario: Auto-notify user before expiry
+ Given Notification rule for 30 days
+ And User "alice" has PROFILE expiring in 30 days
+ When background job executes
+ Then email sent to alice@corp.com: "Access expires in 30 days"
+ And in-app notification created
+ And audit logs: EXPIRATION_NOTIFICATION_SENT
+
+ Scenario: Notify admin daily (repeated notifications)
+ Given Rule with Frequency: DAILY
+ And User access expiring in 5 days
+ When day 1: notification sent to admin
+ And day 2: re-check rule → frequency=DAILY → send again
+ And day 3, 4, 5: repeat
+ Then admin receives 5 notifications (one per day)
+
+ Scenario: Customizable channels per rule
+ Given Rule with Channels: [EMAIL, SLACK, WEBHOOK]
+ When access expiring in 10 days
+ Then notification sent via EMAIL to user
+ And slack message to #compliance channel
+ And webhook POST to https://company.com/compliance/expiry
+```
+
+---
+
+## PARTE 3: FS-16 — Access Behavior on Expiration (NEW)
+
+### 3.1 Definición
+
+**FS-16** define qué ocurre con el acceso cuando se vence.
+
+**Modos:** WARNING (aviso), SUSPEND (suspensión temporal), REVOKE (revocación permanente)
+
+### 3.2 Access Expiration Policy Model
+
+```csharp
+public record AccessExpirationPolicy
+{
+ public Guid Id { get; init; }
+ public Guid RootTenantId { get; init; }
+ public string Code { get; init; } // "contract_expiry", "cert_expiry"
+ public string Name { get; init; }
+
+ // Qué tipo de acceso controla
+ public string ScopeType { get; init; } // 'PROFILE', 'PERMISSION', 'DELEGATION'
+ public string? TargetUserCategory { get; init; }
+
+ // Qué ocurre en expiración
+ public ExpirationAction OnExpirationAction { get; init; } // WARNING, SUSPEND, REVOKE
+
+ // Grace period: días después de expiración antes de enforcement
+ public int GracePeriodDays { get; init; }
+
+ // Permisiones especiales
+ public bool AllowExtension { get; init; } // Puede user solicitar extensión?
+ public int MaxExtensionDays { get; init; } // Máxima extensión permitida
+ public bool RequireReapprovalOnExtend { get; init; } // ¿Necesita nueva aprobación?
+
+ // Excepciones
+ public bool AllowExceptions { get; init; } // Puede admin hacer excepción?
+
+ public bool Enabled { get; init; }
+}
+
+public enum ExpirationAction
+{
+ WARNING, // Solo notificación, acceso permanece
+ SUSPEND, // Acceso suspendido temporalmente
+ REVOKE // Acceso revocado permanentemente
+}
+```
+
+### 3.3 Enforcement Engine
+
+```csharp
+public class AccessExpirationEnforcementEngine : BackgroundService
+{
+ private readonly IAccessRepository _accessRepository;
+ private readonly IExpirationPolicyRepository _policyRepository;
+ private readonly IAuthorizationService _authorizationService;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ // Ejecutar cada 6 horas
+ await EnforceExpiredAccessAsync(stoppingToken);
+ await Task.Delay(TimeSpan.FromHours(6), stoppingToken);
+ }
+ }
+
+ private async Task EnforceExpiredAccessAsync(CancellationToken stoppingToken)
+ {
+ // 1. Obtener todas las políticas habilitadas
+ var policies = await _policyRepository.GetEnabledPoliciesAsync();
+
+ foreach (var policy in policies)
+ {
+ // 2. Encontrar accesos que expired hace más de grace_period
+ var expiredAccess = await _accessRepository.GetExpiredAccessAsync(
+ policyScope: policy.ScopeType,
+ expiredBeforeDays: policy.GracePeriodDays);
+
+ foreach (var access in expiredAccess)
+ {
+ // 3. Aplicar enforcement según policy
+ await EnforceAccessAsync(access, policy);
+ }
+ }
+ }
+
+ private async Task EnforceAccessAsync(UserAccess access, AccessExpirationPolicy policy)
+ {
+ switch (policy.OnExpirationAction)
+ {
+ case ExpirationAction.WARNING:
+ // Solo auditar, no hacer nada
+ await _auditService.LogAsync(new AuditEvent
+ {
+ EventType = "ACCESS_EXPIRED_WARNING",
+ UserId = access.UserId,
+ Details = new { AccessType = policy.ScopeType, ExpiresAt = access.ExpiresAt }
+ });
+ break;
+
+ case ExpirationAction.SUSPEND:
+ // Suspender el acceso
+ access.Status = AccessStatus.SUSPENDED;
+ access.SuspendedAt = DateTime.UtcNow;
+ access.SuspendedReason = $"Expired on {access.ExpiresAt:yyyy-MM-dd}";
+
+ await _accessRepository.UpdateAsync(access);
+
+ // Remover permisos del usuario
+ await _authorizationService.RevokePermissionsAsync(access.UserId, access.Id);
+
+ // Notificar
+ var user = await _userRepository.GetAsync(access.UserId);
+ await _notificationService.NotifyAsync(user.Id,
+ "Access Suspended",
+ $"Your {policy.ScopeType} access has been suspended due to expiration. " +
+ $"Contact admin to request extension.");
+
+ // Audit
+ await _auditService.LogAsync(new AuditEvent
+ {
+ EventType = "ACCESS_SUSPENDED",
+ UserId = access.UserId,
+ ResourceId = access.Id.ToString(),
+ Details = new { Reason = "Expiration", GracePeriod = policy.GracePeriodDays }
+ });
+ break;
+
+ case ExpirationAction.REVOKE:
+ // Revocar el acceso permanentemente
+ access.Status = AccessStatus.REVOKED;
+ access.RevokedAt = DateTime.UtcNow;
+ access.RevokedReason = $"Expired on {access.ExpiresAt:yyyy-MM-dd}";
+
+ await _accessRepository.UpdateAsync(access);
+ await _authorizationService.RevokePermissionsAsync(access.UserId, access.Id);
+
+ var revokedUser = await _userRepository.GetAsync(access.UserId);
+ await _notificationService.NotifyAsync(revokedUser.Id,
+ "Access Revoked",
+ $"Your {policy.ScopeType} access has been revoked due to expiration. " +
+ $"Reapply if needed.");
+
+ await _auditService.LogAsync(new AuditEvent
+ {
+ EventType = "ACCESS_REVOKED",
+ UserId = access.UserId,
+ ResourceId = access.Id.ToString(),
+ Details = new { Reason = "Expiration" }
+ });
+ break;
+ }
+ }
+}
+```
+
+### 3.4 Extension Request Flow
+
+```csharp
+public class AccessExtensionService
+{
+ public async Task RequestExtensionAsync(
+ User requester,
+ Guid accessId,
+ string justification)
+ {
+ var access = await _accessRepository.GetAsync(accessId);
+ var policy = await _policyRepository.GetByAccessTypeAsync(access.Type);
+
+ // 1. Validar que extension es permitida
+ if (!policy.AllowExtension)
+ throw new ExtensionNotAllowedException("Extensions not allowed for this access type");
+
+ if (DateTime.UtcNow > access.ExpiresAt.AddDays(policy.GracePeriodDays))
+ throw new ExtensionTooLateException("Too late to request extension");
+
+ // 2. Crear extension request
+ var request = new AccessExtensionRequest
+ {
+ Id = Guid.NewGuid(),
+ AccessId = accessId,
+ RequestedBy = requester.Id,
+ RequestedAt = DateTime.UtcNow,
+ CurrentExpirationDate = access.ExpiresAt,
+ ProposedNewExpirationDate = access.ExpiresAt.AddDays(30), // Default 30 days
+ Justification = justification,
+ Status = "PENDING"
+ };
+
+ // 3. Si requiere reaprobación, crear approval request
+ if (policy.RequireReapprovalOnExtend)
+ {
+ var approvalRequest = await _approvalService.CreateApprovalRequestAsync(
+ workflow: "ACCESS_EXTENSION_APPROVAL",
+ targetUser: requester.Id,
+ requestedAction: $"Extend {access.Type}",
+ linkedEntity: request.Id);
+
+ request.ApprovalRequestId = approvalRequest.Id;
+ request.Status = "PENDING_APPROVAL";
+ }
+ else
+ {
+ request.Status = "APPROVED";
+ request.ApprovedAt = DateTime.UtcNow;
+ access.ExpiresAt = request.ProposedNewExpirationDate;
+ await _accessRepository.UpdateAsync(access);
+ }
+
+ await _extensionRepository.AddAsync(request);
+
+ // 4. Audit
+ await _auditService.LogAsync(new AuditEvent
+ {
+ EventType = "EXTENSION_REQUESTED",
+ UserId = requester.Id,
+ ResourceId = accessId.ToString()
+ });
+
+ return request;
+ }
+}
+```
+
+### 3.5 Acceptance Criteria (FS-16)
+
+```gherkin
+Feature: Access Behavior on Expiration
+
+ Scenario: WARNING mode (access remains after expiry)
+ Given Policy with OnExpiration: WARNING, GracePeriod: 0
+ When access expires
+ Then notification sent to user
+ And access remains ACTIVE (unchanged)
+ And audit logs: ACCESS_EXPIRED_WARNING
+
+ Scenario: SUSPEND mode (access suspended after grace period)
+ Given Policy with OnExpiration: SUSPEND, GracePeriod: 7
+ And access expired 7 days ago
+ When enforcement job runs
+ Then access status = SUSPENDED
+ And permissions revoked
+ And user notified: "Access suspended"
+ And audit logs: ACCESS_SUSPENDED
+
+ Scenario: REVOKE mode (access permanently removed)
+ Given Policy with OnExpiration: REVOKE, GracePeriod: 3
+ And access expired 3 days ago
+ When enforcement job runs
+ Then access status = REVOKED
+ And permissions permanently removed
+ And user notified: "Access revoked"
+ And cannot be re-enabled (only via new request)
+
+ Scenario: Request extension (if allowed)
+ Given User has suspended access due to expiration
+ And Policy with AllowExtension: true, MaxExtensionDays: 60
+ When user requests extension with justification
+ Then extension request created
+ And if RequireReapprovalOnExtend=true: approval_request created
+ And if RequireReapprovalOnExtend=false: automatically approved
+ Then access ExpiresAt extended
+
+ Scenario: Extension not allowed past grace period
+ Given GracePeriod: 7 days
+ And access expired 8 days ago
+ When user attempts to request extension
+ Then request rejected: "Too late to request extension"
+```
+
+---
+
+## PARTE 4: Compliance Context Definition
+
+### 4.1 Bounded Context
+
+```mermaid
+flowchart TB
+ subgraph CC[COMPLIANCE CONTEXT]
+ direction TB
+ subgraph AG[Aggregates]
+ A1[UserDocument]
+ A2[ExpirationNotificationRule]
+ A3[AccessExpirationPolicy]
+ A4[AccessExtensionRequest]
+ end
+ subgraph PO[Ports]
+ P1[IDocumentStorageService]
+ P2[IExpirationNotificationEngine]
+ P3[IAccessExpirationEnforcement]
+ end
+ subgraph AD[Adapters]
+ AD1[DocumentStorageAdapter Azure/S3]
+ AD2[PostgreSqlComplianceRepository]
+ AD3[EmailNotificationAdapter]
+ end
+ subgraph EV[Events]
+ E1[DocumentUploadedEvent]
+ E2[DocumentApprovedEvent]
+ E3[DocumentRejectedEvent]
+ E4[ExpirationNotificationSentEvent]
+ E5[AccessSuspendedEvent]
+ E6[AccessRevokedEvent]
+ E7[ExtensionRequestedEvent]
+ end
+ end
+```
+
+### 4.2 Integration Points
+
+**Compliance → Approvals:**
+
+* Extension requests que requieren aprobación crean approval_requests
+
+**Compliance → Audit:**
+
+* Todos los eventos (upload, validation, enforcement) registrados immutablemente
+
+**Compliance → Configuration:**
+
+* Notification rules y expiration policies configurables por tenant
+
+**Compliance → Authorization:**
+
+* Cuando acceso es suspendido/revocado, se llama a authorization para remover permisos
+
+---
+
+## PARTE 5: ER Model (EP-07)
+
+```sql
+-- ============================================
+-- COMPLIANCE CONTEXT TABLES
+-- ============================================
+
+CREATE TABLE compliance.documents (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ user_id uuid NOT NULL,
+ document_type varchar(64) NOT NULL,
+ document_name varchar(255) NOT NULL,
+ storage_uri text NOT NULL,
+ file_size_bytes bigint,
+ file_hash varchar(256),
+
+ uploaded_at timestamptz NOT NULL DEFAULT now(),
+ uploaded_by uuid,
+ status varchar(32) NOT NULL DEFAULT 'UPLOADED',
+ valid_until timestamptz,
+
+ CONSTRAINT pk_documents PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_documents_user FOREIGN KEY (user_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id)
+);
+
+CREATE TABLE compliance.document_validators (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ document_id uuid NOT NULL,
+ validator_id uuid NOT NULL,
+
+ validation_status varchar(32),
+ validation_date timestamptz,
+ validation_notes text,
+
+ CONSTRAINT pk_document_validators PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_doc_validators_doc FOREIGN KEY (document_id, root_tenant_id) REFERENCES compliance.documents(id, root_tenant_id)
+);
+
+CREATE TABLE configuration.expiration_notification_rules (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ code varchar(64) NOT NULL,
+ name varchar(255) NOT NULL,
+
+ scope_type varchar(32),
+ target_user_category varchar(32),
+ days_before_expiration integer NOT NULL,
+
+ notify_user boolean,
+ notify_admin boolean,
+ notify_approver boolean,
+ notification_channels jsonb, -- JSON: ["EMAIL", "IN_APP"]
+ notification_frequency varchar(32), -- ONCE, DAILY, WEEKLY
+
+ enabled boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+
+ CONSTRAINT pk_expiration_notification_rules PRIMARY KEY (id, root_tenant_id)
+);
+
+CREATE TABLE configuration.access_expiration_policies (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ code varchar(64) NOT NULL,
+ name varchar(255) NOT NULL,
+
+ scope_type varchar(32) NOT NULL,
+ target_user_category varchar(32),
+ on_expiration_action varchar(32) NOT NULL, -- WARNING, SUSPEND, REVOKE
+ grace_period_days integer DEFAULT 0,
+
+ allow_extension boolean,
+ max_extension_days integer,
+ require_reapproval_on_extend boolean,
+ allow_exceptions boolean,
+
+ enabled boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+
+ CONSTRAINT pk_access_expiration_policies PRIMARY KEY (id, root_tenant_id)
+);
+
+CREATE TABLE compliance.access_extension_requests (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ access_id uuid NOT NULL,
+
+ requested_by uuid NOT NULL,
+ requested_at timestamptz NOT NULL DEFAULT now(),
+ current_expiration_date timestamptz NOT NULL,
+ proposed_new_expiration_date timestamptz NOT NULL,
+ justification text,
+
+ status varchar(32) NOT NULL DEFAULT 'PENDING',
+ approval_request_id uuid,
+ approved_at timestamptz,
+ approved_by uuid,
+ rejection_reason text,
+
+ CONSTRAINT pk_access_extension_requests PRIMARY KEY (id, root_tenant_id)
+);
+
+-- Índices
+CREATE INDEX idx_documents_user ON compliance.documents (user_id, root_tenant_id)
+ WHERE status IN ('UPLOADED', 'VALIDATING');
+
+CREATE INDEX idx_expiration_rules_scope ON configuration.expiration_notification_rules (scope_type, root_tenant_id)
+ WHERE enabled = true;
+
+CREATE INDEX idx_expiration_policies_scope ON configuration.access_expiration_policies (scope_type, root_tenant_id)
+ WHERE enabled = true;
+```
+
+---
+
+## Summary EP-07 Completado
+
+* **FS-11**: Document Upload & Validation workflow
+* **FS-15** (NEW): Expiration Notification Rules engine
+* **FS-16** (NEW): Access Expiration Enforcement (WARNING, SUSPEND, REVOKE)
+* **Compliance Context**: Bounded context defined
+* **ER Model**: 4 nuevas tablas + configuración
+* **Integration**: Compliance integra con Approvals, Audit, Authorization, Configuration
+
+**Próximo:** EP-08 (IGA Advanced)
+
+---
+
+**Aprobado por:** Arquitecto Principal
+**Fecha:** 2026-05-14
diff --git a/docs/architecture/ep-08-iga-detailed-design.es.md b/docs/architecture/ep-08-iga-detailed-design.es.md
new file mode 100644
index 00000000..532ed499
--- /dev/null
+++ b/docs/architecture/ep-08-iga-detailed-design.es.md
@@ -0,0 +1,520 @@
+# EP-08: Diseño Detallado — IGA Avanzada (Identity Governance & Administration)
+
+**Versión:** 1.0 **Fecha:** 2026-05-14 **Épica:** EP-08 (Post-MVP)
+**Historias:** US-031, US-032 (EXPAND a 5-6 historias)
+**Historia Funcional:** FS-12 (Promoción de Rol y Madurez)
+
+---
+
+## PARTE 1: IGA Strategic Domain & Role Maturity
+
+### 1.1 Definición de IGA**Identity Governance & Administration**es la práctica de
+
+* Mapear identidades a roles y responsabilidades
+* Supervisar y evaluar la evolución de roles en el tiempo
+* Autorizar cambios de responsabilidad (promociones)
+* Auditar decisiones de gobernanza **En UMS:** Esto significa definir un modelo donde roles y permisos evolucionan en el tiempo, y las transiciones son gobernadas, auditadas y auditables.
+
+### 1.2 Role Maturity Model
+
+Cada role tiene un nivel de madurez que refleja responsabilidad y seniority:
+
+```csharp
+public enum RoleMaturityLevel
+{
+ JUNIOR = 1, // Aprendiz (0-6 meses)
+ INTERMEDIATE = 2, // Contribuidor (6-18 meses)
+ SENIOR = 3, // Experto (18+ meses)
+ LEAD = 4, // Líder de equipo
+ PRINCIPAL = 5 // Arquitecto/Estratega
+}
+
+public record RoleMaturityStatus
+{
+ public Guid UserId { get; init; }
+ public Guid RoleId { get; init; }
+ public RoleMaturityLevel CurrentLevel { get; init; }
+ public RoleMaturityLevel EligibleNextLevel { get; init; }
+
+ // Timeline
+ public DateTime AssignedAt { get; init; }
+ public DateTime CurrentLevelSince { get; init; } // Cuándo alcanzó este nivel
+ public DateTime? EligibleForPromotionAt { get; init; } // Cuándo es eligible
+
+ // Cumplimiento
+ public int CompletedCertifications { get; init; }
+ public int CompletedTrainings { get; init; }
+ public decimal PerformanceScore { get; init; } // 0.0 a 5.0
+ public bool HasNoComplianceIssues { get; init; }
+
+ public string? BlockingFactor { get; init; } // Ej: "Pending CISSP certification"
+}
+```
+
+---
+
+## PARTE 2: FS-12 — Role Promotion Process (EXPANDIDO)
+
+### 2.1 Definición Expandida **FS-12** gestiona el ciclo de vida completo de una promoción
+
+1. **Eligibility Check** → Verificar que user es eligible
+2. **Impact Analysis** → Calcular qué permisos nuevos, riesgos
+3. **Approval** → Gerente + Security aprueba
+4. **Execution** → Aplicar nueva role
+5. **Verification** → Auditar los cambios
+
+### 2.2 Sub-Historias Expandidas (5-6 historias)
+
+#### US-031: Request Role Promotion (Requestor)
+
+**Como:** Usuario Senior con 2 años en rol **Quiero:** Solicitar promoción a Lead **Para que:** Mi compensación y responsabilidades se alineen **Aceptación:**
+
+* Usuario puede ver cuál es su rol actual y siguiente eligible
+* Puede describir motivos + logros
+* Request guardado en DRAFT status
+* Audit registra: PROMOTION_REQUESTED
+
+---
+
+#### US-032: Review Promotion Impact (Reviewer)
+
+**Como:** Security Administrator **Quiero:** Ver impacto de una promoción (nuevas permisos, sistemas afectados)
+**Para que:** No apruebe cambios que causen riesgos **Aceptación:**
+
+* Impacto muestra: permisos actuales vs nuevos
+* Sistemas afectados listados
+* Risk score calculado (0-100)
+* Conflicting permissions identificados (si es posible)
+* Reviewer puede comentar
+
+---
+
+#### US-033: Approve/Reject Promotion (Manager)
+
+**Como:** Manager directo **Quiero:** Aprobar o rechazar solicitud de promoción **Para que:** Mi equipo esté alineado **Aceptación:**
+
+* Manager ve solicitud con impact analysis
+* Puede aprobar o rechazar
+* Debe escribir motivo
+* Si aprueba → workflow a siguiente approver
+* Si rechaza → solicitud cerrada
+
+---
+
+#### US-034: Execute Promotion (Admin)
+
+**Como:** Admin IGA**Quiero:** Ejecutar una promoción aprobada **Para que:** Los permisos nuevos sean aplicados **Aceptación:**
+
+* Solo disponible si approval chain completa
+* Al ejecutar:
+* Role en usuario actualizado
+* Permisos nuevos asignados
+* Permisos viejos removidos (si aplica)
+* Maturity level actualizado
+* Audit event creado
+* Notificación a usuario
+
+---
+
+#### US-035: Monitor Promotion Metrics (Analytics)
+
+**Como:** HR Analytics **Quiero:** Ver metrics de promociones (tiempo promedio, aprobación rates)
+**Para que:** Identifique bottlenecks **Aceptación:**
+
+* Dashboard mostrando:
+* Promociones pendientes (count, edad)
+* Approval time (avg, median, P95)
+* Rejection rate por approver
+* Blocked reasons (reasons por qué se rechaza)
+* Impact score distribution
+
+---
+
+#### US-036: Promotion Eligibility Engine (Automated)
+
+**Como:** IGA System **Quiero:** Auto-calcular cuándo usuario es eligible **Para que:** Notificaciones automáticas **Aceptación:**
+
+* Cada noche, recalcular eligibility
+* Si usuario es nuevamente eligible:
+* Notificar user + manager
+* Crear "eligible for promotion" badge
+* Considerar:
+* Tiempo en rol actual
+* Certifications completadas
+* Training completadas
+* Performance score
+* Compliance issues
+
+---
+
+### 2.3 Impact Analysis Engine
+
+```csharp
+public class RolePromotionImpactAnalysis
+{
+ public Guid UserId { get; set; }
+ public Guid CurrentRoleId { get; set; }
+ public Guid TargetRoleId { get; set; }
+
+ // Permisos
+ public List CurrentPermissions { get; set; }
+ public List TargetPermissions { get; set; }
+ public List PermissionsAdded { get; set; }
+ public List PermissionsRemoved { get; set; }
+ public List ConflictingPermissions { get; set; }
+
+ // Sistemas afectados
+ public List AffectedSystems { get; set; }
+
+ // Riesgo
+ public decimal RiskScore { get; set; } // 0-100
+ public List RiskFactors { get; set; }
+ public List MitigationsSuggested { get; set; }
+
+ // Auditoría
+ public DateTime AnalyzedAt { get; set; }
+ public string AnalyzedBy { get; set; }
+}
+
+public record SystemImpact
+{
+ public string SystemName { get; init; }
+ public int NewPermissionsCount { get; init; }
+ public string ImpactLevel { get; init; } // LOW, MEDIUM, HIGH, CRITICAL
+ public string? Details { get; init; }
+}
+
+public class PromotionImpactAnalysisService
+{
+ public async Task AnalyzeAsync(User user,
+ Role currentRole,
+ Role targetRole)
+ {
+ // 1. Get permisos actuales
+ var currentPermissions = await _authorizationService
+ .GetEffectivePermissionsAsync(user.Id);
+
+ // 2. Get permisos del target role
+ var targetPermissions = await _authorizationService
+ .GetPermissionsByRoleAsync(targetRole.Id);
+
+ // 3. Calcular diferencias
+ var added = targetPermissions
+ .Except(currentPermissions, new PermissionComparer())
+ .ToList();
+
+ var removed = currentPermissions
+ .Except(targetPermissions, new PermissionComparer())
+ .ToList();
+
+ // 4. Detectar conflicting permissions (ej: create + delete same resource = risky)
+ var conflicting = DetectConflictingPermissions(added);
+
+ // 5. Identificar sistemas afectados
+ var affectedSystems = added
+ .GroupBy(p => p.System)
+ .Select(g => new SystemImpact
+ {
+ SystemName = g.Key,
+ NewPermissionsCount = g.Count(),
+ ImpactLevel = CalculateImpactLevel(g),
+ Details = string.Join(", ", g.Select(p => p.ActionCode))
+ })
+ .ToList();
+
+ // 6. Calcular risk score
+ var riskScore = CalculateRiskScore(added, removed, targetRole, user);
+
+ return new RolePromotionImpactAnalysis
+ {
+ UserId = user.Id,
+ CurrentRoleId = currentRole.Id,
+ TargetRoleId = targetRole.Id,
+ CurrentPermissions = currentPermissions.ToList(),
+ TargetPermissions = targetPermissions.ToList(),
+ PermissionsAdded = added,
+ PermissionsRemoved = removed,
+ ConflictingPermissions = conflicting,
+ AffectedSystems = affectedSystems,
+ RiskScore = riskScore,
+ RiskFactors = IdentifyRiskFactors(riskScore, added, user),
+ AnalyzedAt = DateTime.UtcNow,
+ AnalyzedBy = "PromotionImpactAnalysisEngine"
+ };
+ }
+
+ private decimal CalculateRiskScore(List added,
+ List removed,
+ Role targetRole,
+ User user)
+ {
+ decimal score = 0;
+
+ // Factor 1: Permissions sensitivity (0-40)
+ var sensitivePermissions = added.Count(p => p.RiskLevel == "CRITICAL");
+ score += Math.Min(40, sensitivePermissions * 10);
+
+ // Factor 2: Role seniority jump (0-30)
+ var seniority = targetRole.MaturityLevel - (user.CurrentRole.MaturityLevel ?? 0);
+ if (seniority > 2) score += 30; // Jumping more than 2 levels = risky
+ else if (seniority > 1) score += 15;
+
+ // Factor 3: Time in current role (0-20)
+ var timeInRole = (DateTime.UtcNow - user.RoleAssignedAt).TotalDays;
+ if (timeInRole < 180) score += 20; // Less than 6 months = risky
+ else if (timeInRole < 365) score += 10;
+
+ // Factor 4: User compliance history (0-10)
+ var compliance = await _auditService.GetComplianceScoreAsync(user.Id);
+ if (compliance < 0.9) score += 10;
+
+ return Math.Min(100, score);
+ }
+
+ private List DetectConflictingPermissions(List newPermissions)
+ {
+ var conflicts = new List();
+
+ // Anti-patterns
+ var hasCreate = newPermissions.Any(p => p.ActionCode.Contains("CREATE"));
+ var hasDelete = newPermissions.Any(p => p.ActionCode.Contains("DELETE"));
+ var hasApprove = newPermissions.Any(p => p.ActionCode.Contains("APPROVE"));
+ var hasExecute = newPermissions.Any(p => p.ActionCode.Contains("EXECUTE"));
+
+ if (hasApprove && hasExecute)
+ conflicts.Add("APPROVAL_EXECUTION_CONFLICT: User can approve and execute same action");
+
+ if (newPermissions.Count > 15)
+ conflicts.Add("HIGH_PRIVILEGE_COUNT: More than 15 new permissions");
+
+ return conflicts;
+ }
+}
+```
+
+### 2.4 Promotion Workflow
+
+#### Role Promotion Workflow (State Machine)
+
+```mermaid
+stateDiagram-v2
+ [*] --> DRAFT
+ DRAFT --> PENDING_MANAGER_APPROVAL: User submits request
+ PENDING_MANAGER_APPROVAL --> REJECTED: Manager rejects
+ PENDING_MANAGER_APPROVAL --> PENDING_SECURITY_REVIEW: Manager approves
+ PENDING_SECURITY_REVIEW --> PENDING_SECURITY_APPROVAL: Risky
+ PENDING_SECURITY_REVIEW --> APPROVED_READY_TO_EXECUTE: Safe
+ PENDING_SECURITY_APPROVAL --> APPROVED_READY_TO_EXECUTE: Security approves
+ PENDING_SECURITY_APPROVAL --> REJECTED: Security rejects
+ APPROVED_READY_TO_EXECUTE --> EXECUTED: Admin executes
+ EXECUTED --> VERIFIED: System verifies
+ EXECUTED --> VERIFICATION_FAILED: Verification failed (rollback and notify)
+ REJECTED --> [*]
+ VERIFIED --> [*]
+ VERIFICATION_FAILED --> [*]
+```
+
+---
+
+## PARTE 3: IGA Bounded Context
+
+```mermaid
+flowchart TB
+ subgraph IGA[IGA BOUNDED CONTEXT]
+ direction TB
+ subgraph AG[Aggregates]
+ A1[RoleMaturityStatus]
+ A2[PromotionRequest]
+ A3[PromotionImpactAnalysis]
+ end
+ subgraph PO[Ports]
+ P1[IPromotionApprovalService]
+ P2[IPromotionImpactAnalyzer]
+ P3[IEligibilityCalculator]
+ P4[IPromotionExecutor]
+ end
+ subgraph AD[Adapters]
+ AD1[PostgreSqlIGARepository]
+ AD2[RolePromotionApprovalAdapter]
+ AD3[PromotionImpactAnalysisAdapter]
+ end
+ subgraph EV[Events]
+ E1[PromotionRequestedEvent]
+ E2[PromotionEligibilityCalculatedEvent]
+ E3[PromotionApprovedEvent]
+ E4[PromotionRejectedEvent]
+ E5[PromotionExecutedEvent]
+ E6[PromotionVerifiedEvent]
+ end
+ end
+```
+
+---
+
+## PARTE 4: ER Model (EP-08)
+
+```sql
+-- ============================================
+-- IGA CONTEXT TABLES
+-- ============================================
+
+CREATE TABLE iga.role_maturity_levels (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ user_id uuid NOT NULL,
+ role_id uuid NOT NULL,
+
+ current_maturity_level varchar(32) NOT NULL, -- JUNIOR, INTERMEDIATE, SENIOR, LEAD, PRINCIPAL
+ next_eligible_maturity_level varchar(32),
+
+ assigned_at timestamptz NOT NULL,
+ current_level_since timestamptz NOT NULL,
+ eligible_for_promotion_at timestamptz,
+
+-- Cumplimiento
+ completed_certifications_count integer DEFAULT 0,
+ completed_trainings_count integer DEFAULT 0,
+ performance_score decimal(3,2), -- 0.0 to 5.0
+ has_no_compliance_issues boolean DEFAULT true,
+
+ blocking_factor text,
+ last_reviewed_at timestamptz,
+
+ CONSTRAINT pk_role_maturity_levels PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_role_maturity_user FOREIGN KEY (user_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id));
+
+CREATE TABLE iga.promotion_requests (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ user_id uuid NOT NULL,
+
+ current_role_id uuid NOT NULL,
+ target_role_id uuid NOT NULL,
+
+ requested_at timestamptz NOT NULL DEFAULT now(),
+ requested_by uuid NOT NULL,
+ request_reason text,
+
+-- Approval chain
+ manager_id uuid NOT NULL,
+ manager_approval_status varchar(32), -- PENDING, APPROVED, REJECTED
+ manager_decision_at timestamptz,
+ manager_decision_reason text,
+
+ security_approval_status varchar(32),
+ security_decision_at timestamptz,
+
+-- Overall status
+ status varchar(32) NOT NULL DEFAULT 'DRAFT', -- DRAFT, PENDING_MANAGER_APPROVAL, PENDING_SECURITY_REVIEW, APPROVED, REJECTED, EXECUTED, VERIFIED, FAILED
+ final_status varchar(32), -- PROMOTED, REJECTED, ROLLED_BACK
+
+-- Execution
+ executed_at timestamptz,
+ executed_by uuid,
+ verified_at timestamptz,
+
+ CONSTRAINT pk_promotion_requests PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_promotion_requests_user FOREIGN KEY (user_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id));
+
+CREATE TABLE iga.promotion_impact_analysis (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ promotion_request_id uuid NOT NULL,
+
+ risk_score decimal(5,2),
+ risk_level varchar(32), -- LOW, MEDIUM, HIGH, CRITICAL
+ new_permissions_count integer,
+ removed_permissions_count integer,
+ affected_systems_count integer,
+
+ conflicting_permissions jsonb, -- JSON array
+ risk_factors jsonb, -- JSON array
+ suggested_mitigations jsonb, -- JSON array
+
+ analyzed_at timestamptz NOT NULL DEFAULT now(),
+ analyzed_by varchar(255),
+
+ CONSTRAINT pk_promotion_impact_analysis PRIMARY KEY (id, root_tenant_id),
+ CONSTRAINT fk_promotion_impact_request FOREIGN KEY (promotion_request_id, root_tenant_id) REFERENCES iga.promotion_requests(id, root_tenant_id));
+
+CREATE TABLE iga.promotion_eligible_notifications (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ root_tenant_id uuid NOT NULL,
+ user_id uuid NOT NULL,
+
+ eligible_for_next_level varchar(32),
+ eligible_at timestamptz NOT NULL,
+ notification_sent_at timestamptz,
+ user_acknowledged_at timestamptz,
+
+ CONSTRAINT pk_promotion_eligible_notifications PRIMARY KEY (id, root_tenant_id));
+
+-- Indices
+CREATE INDEX idx_role_maturity_user ON iga.role_maturity_levels (user_id, root_tenant_id);
+CREATE INDEX idx_promotion_requests_user ON iga.promotion_requests (user_id, root_tenant_id)
+ WHERE status IN ('PENDING_MANAGER_APPROVAL', 'PENDING_SECURITY_REVIEW');
+CREATE INDEX idx_promotion_requests_manager ON iga.promotion_requests (manager_id, root_tenant_id)
+ WHERE manager_approval_status = 'PENDING';
+```
+
+---
+
+## PARTE 5: Integration (IGA con otras contexts)
+
+### 5.1 IGA ↔ Approvals
+
+Promotion requests pueden requerir formal approval workflow si target role es sensitive:
+
+```csharp
+// IGA → Approvals: Crear approval request
+if (targetRole.RiskLevel == "CRITICAL")
+{
+ var approvalRequest = await _approvalService.CreateApprovalRequestAsync(workflow: "ROLE_PROMOTION_APPROVAL",
+ requester: user.Id,
+ targetUser: user.Id,
+ requestedAction: $"Promote from {currentRole.Name} to {targetRole.Name}",
+ linkedEntity: promotionRequest.Id);
+
+ promotionRequest.ApprovalRequestId = approvalRequest.Id;
+}
+```
+
+### 5.2 IGA ↔ Authorization
+
+Cuando promotion se ejecuta, permisos del usuario se actualizan:
+
+```csharp
+// IGA → Authorization: Update user permissions
+await _authorizationService.RevokePermissionsAsync(user.Id, currentRole.Id);
+await _authorizationService.AssignPermissionsAsync(user.Id, targetRole.Id);
+```
+
+### 5.3 IGA ↔ Audit
+
+Todos los eventos auditados:
+
+```csharp
+await _auditService.LogAsync(new AuditEvent
+{
+ EventType = "PROMOTION_EXECUTED",
+ UserId = user.Id,
+ ResourceId = promotionRequest.Id.ToString(),
+ Details = new
+ {
+ FromRole = currentRole.Name,
+ ToRole = targetRole.Name,
+ RiskScore = impactAnalysis.RiskScore
+ }
+});
+```
+
+---
+
+## Summary EP-08 Completado
+
+* **FS-12 Expanded**: 6 sub-historias (US-031 a US-036)
+* **IGA Bounded Context**: Definido con agregados, puertos, adaptadores, eventos
+* **Role Maturity Model**: 5 niveles (JUNIOR a PRINCIPAL)
+* **Promotion Impact Analysis**: Risk scoring, permission analysis, affected systems
+* **ER Model**: Tables para maturity, requests, analysis
+* **Integration**: IGA ↔ Approvals, Authorization, Audit
+
+---
+
+**Aprobado por:** Arquitecto Principal **Fecha:** 2026-05-14
diff --git a/docs/architecture/ep-09-onboarding-flow-detailed-design.es.md b/docs/architecture/ep-09-onboarding-flow-detailed-design.es.md
new file mode 100644
index 00000000..041cca60
--- /dev/null
+++ b/docs/architecture/ep-09-onboarding-flow-detailed-design.es.md
@@ -0,0 +1,176 @@
+# EP-09: Diseno Detallado - Bandeja de Aprobacion de Onboarding
+
+**Version:** 1.0
+**Fecha:** 2026-06-01
+**Epica:** EP-09 (Preparacion de Lanzamiento)
+**Historias Funcionales:** FS-21, FS-22, FS-23, FS-24
+**ADR:** ADR-UMS-075
+
+## 1. Objetivo del Diseno
+
+Esta epica introduce un modelo de onboarding en dos fases:
+
+* Fase 1 admite al usuario dentro del tenant.
+* Fase 2 asigna entitlements operativos mediante un flujo de solicitud de perfil.
+
+El diseno mantiene simple la experiencia del operador sin perder el aislamiento por tenant ni la separacion entre admision de identidad y autorizacion.
+
+El diseno tambien exige trazabilidad completa del ciclo de vida de solicitudes de alta de usuario y de perfil por tenant. Los administradores deben cerrar cada requerimiento con un resultado final Aprobado o Denegado, y el solicitante debe ser notificado automaticamente cuando se registre la decision final.
+
+## 2. Superficie de Producto
+
+| Superficie | Visible para | Proposito |
+| --- | --- | --- |
+| Opcion de navegacion Identity | Aprobadores autorizados | Abre la bandeja de aprobacion de onboarding. |
+| Pestaña de onboarding de empresa | System Admin | Revisa solicitudes de alta de empresa. |
+| Gestion de equipo: pestaña Solicitudes de Ingreso | Tenant Admin | Revisa solicitudes pendientes de alta de usuario del tenant activo. |
+| Gestion de equipo: pestaña Solicitudes de Perfiles | Tenant Admin o Gerente de Sucursal delegado | Revisa solicitudes pendientes de perfil y asigna el rol final. |
+| Lobby de usuario | Usuarios activos sin perfil | Muestra bienvenida al tenant y formulario de solicitud de perfil. |
+| Puntos publicos de registro | Visitantes anonimos | Enviar solicitudes de alta de tenant o de usuario. |
+
+## 3. Matriz de Ruteo de Aprobacion
+
+| Tipo de Solicitud | Fuente de Verdad | Estado Inicial | Alcance de Revision | Resultado de Aprobacion |
+| --- | --- | --- | --- | --- |
+| Solicitud de alta de empresa | Agregado `TenantSignupRequest` | Pending | Global | Crea tenant + primer admin + notificacion de contrasena temporal |
+| Solicitud de alta de usuario | Agregado `UserAccount` | Pending | Tenant actual | Aprueba para activar la cuenta o deniega sin acceso al tenant |
+| Solicitud de perfil | `ApprovalRequest` o modelo dedicado de solicitud de perfil | PendingAssignment | Tenant actual o sucursal delegada | Aprueba con asignacion de rol final o deniega sin asignacion de perfil |
+
+## 4. Contrato de Cierre de Ciclo de Vida
+
+| Tipo de Solicitud | Resultados Terminales Requeridos | Responsable de Cierre | Requisito de Notificacion |
+| --- | --- | --- | --- |
+| Solicitud de alta de usuario | Aprobado, Denegado | Tenant Admin | Notificar al solicitante cuando el acceso sea aprobado o denegado. |
+| Solicitud de perfil | Aprobado, Denegado | Tenant Admin o Gerente de Sucursal delegado | Notificar al solicitante cuando la solicitud de perfil sea aprobada o denegada. |
+
+Todo registro de ciclo de vida debe conservar tenant, solicitante, estado actual, resultado final, fecha de decision, aprobador y motivo de decision cuando exista. Los registros pendientes permanecen accionables en la bandeja hasta que se registre una decision final.
+
+## 5. Modelo de Estados
+
+### 5.1 Solicitud de Alta de Empresa
+
+| Estado | Significado | Proxima Accion Permitida |
+| --- | --- | --- |
+| Pending | La solicitud fue enviada y espera revision. | Aprobar o rechazar |
+| Approved | El tenant fue creado y se aprovisiono la primera cuenta admin. | Ninguna |
+| Rejected | La solicitud se cerro sin crear el tenant. | Ninguna |
+
+### 5.2 Solicitud de Alta de Usuario
+
+| Estado | Significado | Proxima Accion Permitida |
+| --- | --- | --- |
+| Pending | La cuenta existe pero aun no puede iniciar sesion. | Aprobar o denegar |
+| ActiveWithoutProfile | El Tenant Admin aprobo la solicitud, pero no existe perfil asignado. | Solicitar perfil |
+| Active | Existe al menos un perfil activo. | Ninguna |
+| Denied | La solicitud se cerro sin activar acceso al tenant. | Ninguna |
+
+### 5.3 Solicitud de Perfil
+
+| Estado | Significado | Proxima Accion Permitida |
+| --- | --- | --- |
+| PendingAssignment | El usuario solicito sistema, sucursal y rol sugerido. | Aprobar, modificar o denegar |
+| Approved | Se otorgo un rol final. El rol otorgado puede coincidir con la solicitud o ser modificado por el aprobador. | Ninguna |
+| Denied | No se asigno perfil para el alcance solicitado. | Ninguna |
+
+## 6. Diagramas de Secuencia
+
+### 6.1 Alta de Empresa
+
+```mermaid
+sequenceDiagram
+ participant Visitante as Contacto de la Empresa
+ participant UI as Pantalla Publica de Login
+ participant Auth as API de Comandos Auth
+ participant Bandeja as Bandeja de Onboarding
+ participant Admin as System Admin
+ participant Identity as Dominio de Identidad
+ participant Notify as Servicio de Notificacion
+
+ Visitante->>UI: Abre el formulario de alta de empresa
+ Visitante->>Auth: Envia los datos de la compania
+ Auth->>Identity: Crear TenantSignupRequest (Pending)
+ Auth->>Notify: Enviar notificacion de solicitud recibida
+ Auth-->>UI: Mostrar confirmacion
+ Bandeja->>Admin: Mostrar solicitud pendiente de empresa
+ Admin->>Auth: Aprobar solicitud de tenant
+ Auth->>Identity: Crear Tenant + primer usuario admin
+ Auth->>Notify: Enviar notificacion de aprobacion de tenant
+```
+
+### 6.2 Alta de Usuario
+
+```mermaid
+sequenceDiagram
+ participant Solicitante as Solicitante
+ participant UI as Pantalla Publica de Login
+ participant Auth as API de Comandos Auth
+ participant Bandeja as Bandeja del Tenant
+ participant Admin as Tenant Admin
+ participant Identity as Dominio de Identidad
+ participant Notify as Servicio de Notificacion
+
+ Solicitante->>UI: Abre el formulario de alta de usuario
+ Solicitante->>UI: Selecciona el tenant y envia la solicitud
+ UI->>Auth: Enviar datos de alta de usuario
+ Auth->>Identity: Crear UserAccount (Pending)
+ Auth->>Notify: Enviar notificacion de solicitud recibida
+ Auth-->>UI: Mostrar confirmacion
+ Bandeja->>Admin: Mostrar solicitud pendiente de usuario
+ Admin->>Auth: Aprobar o denegar cuenta
+ Auth->>Identity: Cerrar solicitud como Aprobada o Denegada
+ Auth->>Notify: Enviar notificacion final de decision de alta de usuario
+ Solicitante->>UI: Inicia sesion despues de la aprobacion
+ UI-->>Solicitante: Mostrar lobby cuando no existe perfil activo
+```
+
+### 6.3 Solicitud de Perfil
+
+```mermaid
+sequenceDiagram
+ participant Usuario as Usuario en Lobby
+ participant UI as Pantalla de Lobby
+ participant Aprobaciones as Bandeja de Solicitudes de Perfil
+ participant Admin as Aprobador de Tenant o Sucursal
+ participant Authz as Dominio de Autorizacion
+ participant Notify as Servicio de Notificacion
+
+ Usuario->>UI: Selecciona sistema, sucursal y rol sugerido
+ UI->>Aprobaciones: Enviar solicitud de perfil
+ Aprobaciones-->>Admin: Mostrar solicitud de perfil pendiente
+ Admin->>Aprobaciones: Aprobar, modificar o denegar
+ Aprobaciones->>Authz: Asignar Profile final si se aprueba
+ Aprobaciones->>Notify: Notificar decision final al usuario
+```
+
+## 7. Ubicacion en UI
+
+| Ubicacion | Componente | Notas |
+| --- | --- | --- |
+| Pantalla de login | Botones de entrada | Enlaza los formularios de alta de usuario y alta de empresa. |
+| Navegacion del modulo Identity | Bandeja de Aprobacion de Onboarding | Nueva opcion visible para aprobadores. |
+| Dashboard de tenants | Panel de solicitudes pendientes | Puede reutilizar el mismo read model por contexto, pero la bandeja sigue siendo la superficie principal de revision. |
+| Gestion de equipo | Pestaña Solicitudes de Ingreso | Muestra solicitudes pendientes con alcance del tenant y acciones de aprobar y denegar. |
+| Gestion de equipo | Pestaña Solicitudes de Perfiles | Muestra solicitudes de perfil con acciones de aprobar, modificar y denegar. |
+| Lobby de usuario | Formulario de solicitud de perfil | Permite solicitar sistema, sucursal y rol sugerido a usuarios sin perfil. |
+
+## 8. Plan de Implementacion
+
+| Fase | Trabajo | Dependencia | Resultado |
+| --- | --- | --- | --- |
+| 1 | Mantener las solicitudes de alta de empresa como `TenantSignupRequest` y exponerlas en la bandeja. | Flujo existente de alta de tenant | Los admins globales pueden revisar solicitudes de empresa en un solo lugar. |
+| 2 | Mantener las solicitudes de alta de usuario como `UserAccount` pendientes y mostrar acciones de aprobacion y denegacion en la bandeja del tenant. | Flujos existentes de alta, activacion y denegacion de usuario | Los Tenant Admins pueden cerrar solicitudes de acceso sin fuga entre tenants. |
+| 3 | Agregar ruteo a lobby para usuarios autenticados sin perfil activo. | Resultado sin perfil del grafo de autorizacion | Los usuarios pueden entrar al tenant sin ver menus operativos. |
+| 4 | Agregar flujo de solicitud de perfil con sistema, sucursal, rol solicitado y justificacion. | Catalogos de perfiles y roles | Los usuarios pueden solicitar entitlements explicitamente. |
+| 5 | Agregar acciones de aprobacion: aprobar, modificar y denegar. | Comandos de aprobacion, denegacion y asignacion de perfil | Los aprobadores pueden asignar el rol final o cerrar la solicitud como denegada conservando auditoria. |
+| 6 | Agregar verificaciones explicitas de capacidad por alcance. | Grafo de autorizacion y asignaciones de rol | Solo los aprobadores autorizados pueden usar las acciones de la bandeja. |
+| 7 | Agregar historial de ciclo de vida y notificaciones de decision final para solicitudes de alta de usuario y de perfil. | Plantillas de notificacion y modelo de auditoria | Toda solicitud permanece trazable hasta quedar Aprobada o Denegada. |
+| 8 | Agregar estados futuros de verificacion de pago si el negocio lo requiere. | Decision de producto | El onboarding de empresa puede pausarse por validacion comercial sin redisenar el punto de entrada. |
+
+## 9. Trazabilidad
+
+| Tipo | Referencias |
+| --- | --- |
+| Historias Funcionales | FS-21, FS-22, FS-23, FS-24 |
+| ADR | ADR-UMS-075 |
+| Entidades de Dominio | `TenantSignupRequest`, `Tenant`, `UserAccount`, `ApprovalRequest`, `Profile`, `Role`, `Branch` |
+| Notificaciones | `TenantSignupRequestReceived`, `TenantSignupApproved`, `UserSignupRequestReceived`, `UserSignupApproved`, `UserSignupDenied`, `ProfileRequestApproved`, `ProfileRequestDenied` |
diff --git a/docs/architecture/index.es.md b/docs/architecture/index.es.md
index 2e4bc654..7c15c5e2 100644
--- a/docs/architecture/index.es.md
+++ b/docs/architecture/index.es.md
@@ -62,4 +62,31 @@ Referencia aplicada React Web para UMS. Esta seccion mapea evidencia actual de c
---
+## Diseño de Solución y Diseños Detallados
+
+Documentos de diseño incorporados en la resincronización con la plataforma de origen. Están en
+español y son la fuente normativa hasta que exista contraparte en inglés.
+
+### Transversales
+- **[Arquitectura de la Solución](./solution-architecture.es.md)**: vista integral de la solución UMS.
+- **[Objetivos de Calidad](./quality-objectives.es.md)**: atributos de calidad y sus escenarios.
+- **[Modelo de Amenazas](./threat-model.es.md)**: superficie de ataque y contramedidas.
+- **[Revisión de Arquitectura — Contexto de Sesión](./session-context-architecture-review.es.md)**.
+
+### Diseños detallados por épica
+- **[EP-06 · Aprobaciones](./ep-06-approvals-detailed-design.es.md)**
+- **[EP-07 · Cumplimiento](./ep-07-compliance-detailed-design.es.md)**
+- **[EP-08 · IGA](./ep-08-iga-detailed-design.es.md)**
+- **[EP-09 · Flujo de Onboarding](./ep-09-onboarding-flow-detailed-design.es.md)**
+
+### Diseños de funcionalidad
+- **[Cambio de Perfil](./profile-switch-design.es.md)**
+- **[Selección de Sistema en Autenticación](./system-selection-at-login-design.es.md)**
+
+### Integración E2E con el tablero SDLC
+- **[Análisis de Integración](./e2e-sdlc-dashboard-integration-analysis.es.md)**
+- **[Plan de Paralelización](./e2e-dashboard-parallelization-plan.es.md)**
+
+---
+
**[Volver al Índice Maestro](../MASTER_INDEX.es.md)** | **[Volver al README Principal](../README.es.md)**
diff --git a/docs/architecture/overview.es.md b/docs/architecture/overview.es.md
index cce8c5e5..86cbfca3 100644
--- a/docs/architecture/overview.es.md
+++ b/docs/architecture/overview.es.md
@@ -146,9 +146,7 @@ Todas las reglas de negocio, invariantes y diagramas arquitectónicos se consoli
├── authorization/
│ ├── system-suite.md - Aplicaciones principales y opciones del sistema
│ ├── module.md - Zonas funcionales dinámicas
-│ ├── menu.md - Estructura de menús navegables
-│ ├── sub-menu.md - Submenús anidados
-│ ├── option.md - Interfaces e interfaces de acceso del usuario
+│ ├── menu-node.md - Árbol de navegación recursivo (ADR-0090)
│ ├── action.md - Tokens granulares de operación (Read/Write/Delete)
│ ├── permission-template.md - Grupos de permisos preestablecidos
│ ├── permission-template-item.md - Mapeos individuales dentro de un template
diff --git a/docs/architecture/parameter-system-redesign.md b/docs/architecture/parameter-system-redesign.md
index a788447b..40b4f4b9 100644
--- a/docs/architecture/parameter-system-redesign.md
+++ b/docs/architecture/parameter-system-redesign.md
@@ -136,7 +136,7 @@ Function GetEffectiveValue(tenantId, parameterCode):
| Parameter | Scope | Tenant | Resolution |
|-----------|-------|--------|------------|
| SESSION_TIMEOUT_MINUTES | GlobalAndTenant | RANSA | RANSA override: 45 |
-| SESSION_TIMEOUT_MINUTES | GlobalAndTenant | UNIMAR | No override → Global: 30 |
+| SESSION_TIMEOUT_MINUTES | GlobalAndTenant | BEYONDNET | No override → Global: 30 |
| ACCESS_TOKEN_DURATION_MS | GlobalOnly | Any | Global: 3600000 |
| UI_CUSTOM_BRANDING_ENABLED | GlobalAndTenant | RANSA | RANSA override: true |
| UI_CUSTOM_BRANDING_ENABLED | GlobalAndTenant | APM | No override → Global: false |
@@ -158,7 +158,7 @@ Function GetEffectiveValue(tenantId, parameterCode):
| UI_CUSTOM_BRANDING_ENABLED | Custom Branding Enabled | Boolean | false | GlobalAndTenant | Enable custom tenant branding |
| UI_THEME | UI Theme | String | light | GlobalAndTenant | UI theme preference |
| MAX_VALIDITY_PERIOD_DAYS | Max Validity Period Days | Number | 365 | GlobalAndTenant | Maximum user account validity period |
-| FRONTEND_CONFIG_TRANSPORT | Frontend Config Transport | String | rest | GlobalOnly | Transport mode: graphql or rest |
+| ~~FRONTEND_CONFIG_TRANSPORT~~ | *Withdrawn (2026-08-09)* | — | — | — | The web app's REST/GraphQL switch and the `query-transport.service` that read this flag were removed with the resync; data access is REST, and GraphQL is consumed directly where it is wanted |
| ENABLE_GRAPHQL_INTROSPECTION | Enable GraphQL Introspection | Boolean | false | GlobalOnly | Allow GraphQL schema introspection |
---
@@ -321,7 +321,7 @@ public interface IConfigurationProvider
- Master catalog seeded from `ParameterCatalogSeeder`
- Global values seeded with defaults
- Tenant overrides seeded for demo tenants
-- SQLite database in `Ums.Presentation/umsdev.db`
+- PostgreSQL database (`UmsDev`); the SQLite dev file `Ums.Presentation/umsdev.db` was withdrawn with SQLite support
### 8.3 Production Mode
diff --git a/docs/architecture/profile-switch-design.es.md b/docs/architecture/profile-switch-design.es.md
new file mode 100644
index 00000000..6637835c
--- /dev/null
+++ b/docs/architecture/profile-switch-design.es.md
@@ -0,0 +1,252 @@
+# Diseño — Lista de perfiles y cambio de perfil sin re-autenticar
+
+> **Estado:** Propuesta · **Fase SDLC:** 2 · Diseño · **Fecha:** 2026-08-01
+> **Depende de:** [Evaluación de arquitectura — Contexto de Sesión](./evaluacion-arquitectura-contexto-sesion.md) §11 (Fase 4)
+> **Gaps relacionados:** [G-177](../../GAPS.md) (contrato mono-perfil), [G-171](../../GAPS.md), [G-172](../../GAPS.md)
+
+---
+
+## 1. Qué problema resuelve
+
+Un perfil ata **un usuario, un rol y —por herencia del rol— un sistema**, más opcionalmente una
+sucursal. El mismo usuario puede tener varios: PMO en el Tablero SDLC, Operador en TMS, o dos roles
+distintos en el mismo sistema. El modelo lo permite a propósito: el índice
+`(TenantId, UserId, RoleId, BranchId)` no es único (`ProfileRecordConfiguration.cs:21`).
+
+Hoy el login **elige uno y descarta el resto sin dejar traza**: `FirstOrDefault` sobre un orden por
+`RoleId`, que es un GUID (`AuthorizationGraphBuilderService.cs:121-123`). El usuario no sabe que
+tiene otros perfiles, no puede elegir, y el criterio de desempate no es explicable ni estable entre
+despliegues.
+
+Este diseño cubre dos piezas:
+
+1. **Devolver la lista de perfiles autorizados** en la respuesta de autenticación.
+2. **Cambiar de perfil sin volver a autenticarse**, con un endpoint dedicado.
+
+Ambas asumen la decisión ya tomada: **un contexto por perfil**, no una matriz compilada que funda
+los permisos de todos los perfiles. El motivo es la trazabilidad — con un contexto por perfil
+siempre se puede responder «puedes aprobar el gate porque entraste como PMO»; fundiendo, un permiso
+denegado se convierte en una investigación.
+
+---
+
+## 2. Qué ya existe y se reutiliza
+
+La mayor parte del trabajo está hecha. Esto es plomería, no un motor nuevo.
+
+| Pieza | Dónde | Estado |
+|---|---|---|
+| Cargar todos los perfiles de un usuario | `IProfileRepository.GetByUserIdAsync` (`Repositories.cs:18`) | **Ya se llama en cada login** y se descarta todo menos uno → coste marginal cero |
+| Construir el grafo de un perfil concreto | `IAuthorizationGraphBuilder.BuildForProfileAsync` | Existe, lo usa la previsualización de administración |
+| Emitir el token de grafo | `IJwtTokenService.GenerateGraphToken` | Existe |
+| Cookie de sesión con claims | `AuthEndpoints.HandleLoginAsync` | Existe |
+| Auditoría de eventos de autenticación | `IAuthAuditService.RecordAuthEventAsync` | Existe (`Auth.Login.Success`, `Auth.Refresh.*`) |
+| Validación de token en endpoint sin `RequireAuthorization` | `HandleSwitchTenantAsync` (`AuthEndpoints.cs:528`) | Existe — es el patrón a imitar |
+
+Lo único que hay que **añadir** es un bloque en el contrato, un comando con su manejador, un
+endpoint y las reglas de selección.
+
+---
+
+## 3. Bloque `profiles` en el contrato
+
+Se añade al grafo, no al envoltorio de la respuesta de login: el endpoint de sistemas satélite
+(`/client/authenticate`) devuelve solo el grafo y también necesita esta información.
+
+```jsonc
+"profiles": [
+ {
+ "id": "…", // opcional, como el resto de ids: solo con metadatos técnicos
+ "system": { "code": "SDLC", "value": "Tablero de Gobierno SDLC" },
+ "role": { "code": "PMO", "value": "Oficina de Gestión (PMO)", "hierarchyLevel": 1 },
+ "branch": null, // { code, value } si el perfil es BranchScoped
+ "scope": "OrgWide",
+ "isCurrent": true // exactamente uno lleva true
+ },
+ {
+ "system": { "code": "TMS", "value": "Transporte" },
+ "role": { "code": "OPERADOR", "value": "Operador de Transporte", "hierarchyLevel": 3 },
+ "branch": { "code": "CALLAO", "value": "Callao" },
+ "scope": "BranchScoped",
+ "isCurrent": false
+ }
+]
+```
+
+Decisiones de forma:
+
+* **`system` viene del rol, no del perfil.** El perfil no guarda el sistema; se resuelve por
+ `Role.SystemSuiteId`. Se proyecta aquí porque el cliente lo necesita para pintar el selector, y
+ obligarle a cruzarlo sería trasladarle un detalle de nuestro modelo.
+* **Solo perfiles activos.** Un perfil inactivo no es una opción que ofrecer.
+* **`isCurrent` en vez de `isDefault`.** Lo que el cliente necesita saber es con cuál está operando
+ ahora; cuál sería el de por defecto es una regla del servidor, no información de la sesión.
+* **Bloque aditivo** → bump MINOR de `schemaVersion`, no MAJOR. Un consumidor que lo ignore sigue
+ funcionando. Ojo: el arnés RoboSoft pinea `schema_version == "1.0.0"` (`configuration.py:419`) y
+ hay que actualizarlo en el mismo cambio.
+
+**Coste:** cero consultas nuevas. Los perfiles ya se cargan; hoy se tiran. Resolver el `system` de
+cada uno sí exige leer sus roles: **una consulta adicional** por login para el conjunto de roles
+referenciados (`WHERE Id IN (…)`), no una por perfil.
+
+---
+
+## 4. Selección de perfil en el login
+
+Dos cambios, independientes entre sí.
+
+### 4.1 Desempate explicable
+
+Cuando el usuario tiene varios perfiles activos y no pide ninguno en concreto, el orden es:
+
+1. `Role.HierarchyLevel` ascendente — el rol más alto primero. Ya existe y ya se proyecta.
+2. `SystemSuite.Code` alfabético.
+3. `Role.Code` alfabético.
+
+Determinista, estable entre despliegues y **explicable a un humano**, que es lo que hoy no es
+ordenar por un GUID.
+
+### 4.2 Filtros opcionales en la petición
+
+```jsonc
+POST /api/v1/auth/login
+{
+ "tenantCode": "BEYONDNET",
+ "username": "ana.torres",
+ "password": "…",
+ "system": "SDLC", // opcional
+ "role": "PMO", // opcional
+ "profileId": null // opcional; si viene, gana sobre los otros dos
+}
+```
+
+Reglas:
+
+* Sin filtros → se aplica el desempate de §4.1 y `profiles` viaja completo.
+* Con `system` y/o `role` → se filtra la lista **y** se elige de entre los filtrados.
+* Si los filtros no casan con ningún perfil del usuario, el login **es correcto** y devuelve un
+ grafo lobby con `profiles` vacío. No se devuelve 401: las credenciales eran válidas, y confundir
+ «no tienes ese perfil» con «tu contraseña es incorrecta» convierte un problema de asignación en
+ un incidente de soporte.
+
+---
+
+## 5. Endpoint de cambio de perfil
+
+```jsonc
+POST /api/v1/auth/switch-profile
+Authorization: Bearer
+
+{ "profileId": "…" }
+```
+
+Respuesta: **la misma forma que el login** (`LoginSuccessResponse`), con el grafo del perfil nuevo,
+un token nuevo y la cookie de sesión reescrita. Que la forma sea idéntica no es cosmética: el
+cliente reutiliza tal cual el código que ya tiene para inicializar la aplicación tras el login.
+
+### Reglas de validación, en orden
+
+| # | Regla | Fallo |
+|---|---|---|
+| 1 | El token de grafo es válido y no ha expirado | `401` |
+| 2 | El perfil existe | `404` |
+| 3 | **El perfil pertenece al usuario del token** | `403` |
+| 4 | **El perfil pertenece al inquilino de la sesión** | `403` |
+| 5 | El perfil está activo | `409` |
+| 6 | El usuario sigue activo | `401` |
+
+Las reglas 3 y 4 son el corazón de la seguridad de este endpoint: el `profileId` lo envía el
+cliente, así que **nunca se confía en él**. Se resuelve el perfil, y se comprueba que su `UserId`
+coincide con el `sub` del token y que su `TenantId` coincide con el `tenant_id`. Sin la regla 3,
+este endpoint sería una escalada de privilegios de una línea.
+
+No se pide contraseña otra vez: la identidad no cambia, solo el sombrero. Si en el futuro se quiere
+elevación de privilegios con re-autenticación (pasar a un rol con `HierarchyLevel` 0, por ejemplo),
+es una regla adicional sobre este mismo endpoint, no un diseño distinto.
+
+### Auditoría
+
+Evento `Auth.Profile.Switch` con el perfil de origen y el de destino. Es el registro que sostiene la
+promesa de trazabilidad de la opción A: sin él, saber con qué sombrero se hizo una operación exige
+correlacionar por tiempo.
+
+---
+
+## 6. El token anterior sigue vivo — y por qué se acepta
+
+La tentación es revocar el token previo al cambiar de perfil. **No se puede con lo que hay, y
+tampoco hace falta.**
+
+`ITokenRevocationStore.RevokeAsync(userId, revokeUntilUtc)` revoca **por usuario y ventana de
+tiempo**, no por token: `IsRevokedAsync` devuelve `true` para _cualquier_ token de ese usuario
+mientras `now < until` (`InMemoryTokenRevocationStore.cs:29-40`). Usarlo aquí dejaría al usuario
+fuera de la aplicación inmediatamente después de cambiarse de perfil, incluido el token recién
+emitido. La revocación por token exigiría un registro de `jti`, que hoy no existe.
+
+Y no hace falta porque **no hay escalada**: el usuario poseía legítimamente ambos perfiles. Mantener
+el token viejo vivo hasta que expire equivale a tener dos sesiones abiertas con dos sombreros, que
+es exactamente lo que el modelo permite. Lo que sí hay que asumir con honestidad, y documentar en el
+contrato, es la consecuencia: **el cambio de perfil no cierra la sesión anterior**.
+
+Si más adelante se quiere «un solo perfil activo a la vez», la pieza que falta es revocación por
+`jti`; queda anotado como trabajo futuro, no como parte de este diseño.
+
+---
+
+## 7. Piezas a implementar
+
+| Capa | Pieza | Nota |
+|---|---|---|
+| Application | `SwitchProfileCommand(Guid ProfileId)` + manejador | Reutiliza `BuildForProfileAsync`; aplica las reglas 2-6 |
+| Application | `ProfileSummaryDto` y su proyección | Alimenta el bloque `profiles` |
+| Application | `AuthGraphPayload` — proyectar `profiles` | Único mapeador del grafo (D-025): tocar solo ahí |
+| Domain | `GraphProfileOption` en `GraphContext` | Record nuevo; el grafo no cambia de forma, gana un bloque |
+| Presentation | `POST /auth/switch-profile` | Imita `HandleSwitchTenantAsync` en validación de token |
+| Presentation | Reescritura de la cookie de sesión | Mismos claims que el login, con el perfil nuevo |
+| Contrato | `profiles` en esquema, SDK TS, SDK .NET, fixtures | Bump MINOR |
+| E2E | Actualizar el pin `schema_version` de RoboSoft | `configuration.py:419` |
+
+---
+
+## 8. Casos borde
+
+* **Un solo perfil** → `profiles` con un elemento y `isCurrent: true`. El cliente no debe pintar
+ selector; que decida él con el dato, en vez de que el servidor se lo oculte.
+* **Ningún perfil** → grafo lobby (G-043), `profiles: []`, `onboardingPending: true`. Ya resuelto.
+* **Perfil desactivado a mitad de sesión** → el token vigente sigue funcionando hasta expirar. Es la
+ misma staleness que el resto del diseño acepta a propósito («los cambios se reflejan al
+ re-autenticar»). El cambio A→B→A no lo esquiva: la regla 5 rechaza volver a un perfil inactivo.
+* **Perfiles de dos inquilinos** → no se mezclan nunca. Cambiar de inquilino es `switch-tenant`, que
+ ya existe y exige ser administrador interno.
+* **Dos perfiles con el mismo rol y distinta sucursal** → dos entradas que solo se distinguen por
+ `branch`. El cliente debe mostrar la sucursal en el selector o serán indistinguibles.
+
+---
+
+## 9. Impacto en el frontend
+
+El selector de perfil vive junto al de inquilino que ya existe. Al cambiar: llamar al endpoint,
+reemplazar el grafo en memoria y **re-renderizar la navegación completa** — no parchear el menú
+actual, porque el árbol entero cambia. El token nuevo sustituye al anterior en el store.
+
+---
+
+## 10. Pruebas
+
+Unitarias del manejador: perfil de otro usuario → 403; perfil de otro inquilino → 403; perfil
+inactivo → 409; perfil válido → grafo del perfil nuevo con sus permisos y no los del anterior.
+Desempate: tres perfiles con distinto `HierarchyLevel` → gana el más alto, de forma reproducible.
+Contrato: `profiles` presente, con exactamente un `isCurrent`, y coherente con el `context.role`
+del propio grafo.
+
+---
+
+## 11. Fuera de alcance
+
+Devolver **varios contextos de sistema a la vez** (Fase 4 del roadmap). Este diseño devuelve la
+lista de perfiles y el contexto **de uno**. Servir N contextos multiplica el término dominante del
+coste por el número de suites distintas, y esa conversación necesita antes los números de la
+instrumentación que se acaba de añadir.
+
+Tampoco entran aquí: revocación por `jti`, elevación con re-autenticación, ni el bloque de branding
+y configuración visual que pide el escenario objetivo ([G-178](../../GAPS.md)).
diff --git a/docs/architecture/quality-objectives.es.md b/docs/architecture/quality-objectives.es.md
new file mode 100644
index 00000000..e841f061
--- /dev/null
+++ b/docs/architecture/quality-objectives.es.md
@@ -0,0 +1,108 @@
+# Objetivos de Rendimiento y Confiabilidad — ums
+
+> **Estado:** Adoptado | **Propietario:** BeyondNet S.A.C. | **Reglas:** S-06, SD-08
+> **Versión:** 1.0.1 · **Fecha:** 2026-07-14 · **Avanza:** [G-002](../../GAPS.md), [G-003](../../GAPS.md)
+
+Objetivos de nivel de servicio (SLO) y estrategia de confiabilidad del satélite
+**ums**. Fijan el _qué se espera_ del sistema en producción; la
+**verificación empírica** (pruebas de carga y de fallo) se ejecuta en el sprint de
+pruebas ([G-014](../../GAPS.md)). Complementan el [PRD](../01-concepcion/PRD-UMS-001.es.md) (NFR) y la
+[arquitectura](./arquitectura-solucion.md).
+
+## 1. Objetivos de Rendimiento (SLO)
+
+Presupuestos de latencia para las operaciones críticas del camino de
+autenticación y autorización, medidos en el percentil indicado bajo carga
+nominal. Son objetivos iniciales, a calibrar con la prueba de carga base.
+
+| Operación | Objetivo (p95) | Objetivo (p99) | Notas |
+| :--- | :--- | :--- | :--- |
+| Emisión del Grafo de Autorización (login local) | ≤ 300 ms | ≤ 600 ms | Sin saltos externos (pipeline interno, ADR-UMS-080) |
+| Validación de credenciales locales (BCrypt) | ≤ 250 ms | ≤ 500 ms | Coste de hashing acotado por _work factor_ |
+| Resolución del método de autenticación | ≤ 20 ms | ≤ 50 ms | Caché en memoria; refresco en el siguiente login |
+| Consulta REST de lectura (`GET`, proyección plana) | ≤ 150 ms | ≤ 400 ms | CQRS con `ReadModels` en la capa de aplicación |
+| Comando REST de escritura (`POST/PUT/PATCH/DELETE`, con outbox) | ≤ 300 ms | ≤ 700 ms | Incluye persistencia + evento de integración |
+
+**Throughput y recursos:** el gateway impone límites de complejidad, timeouts y
+_rate limiting_ por cliente; se define una cuota por inquilino para evitar que un
+tenant degrade a los demás (aislamiento de rendimiento).
+
+**Contra-objetivo:** ninguna optimización de latencia puede introducir una ruta
+que lea u opere datos fuera del inquilino del solicitante.
+
+### 1.1 Línea base medida (G-002)
+
+Medición base con `k6` contra el runtime vivo en `kind` (`evolith-ums-cluster`,
+namespace `ums`), 3 VUs con think-time, 100 % de éxito. **Hardware de desarrollo
+(no producción); los SLO se fijan «en producción».**
+
+| Operación | Objetivo p95 | Medido p95 | Estado |
+| :--- | :--- | :--- | :--- |
+| Login local (emisión del grafo de autorización, `POST /auth/login`) | ≤ 300 ms | ~353 ms | ⚠️ ligeramente por encima — dominado por BCrypt (work factor) en HW de dev |
+| Consulta REST de lectura (`GET /tenants`, proyección plana) | ≤ 150 ms | ~18 ms | ✅ holgado |
+
+**Resiliencia bajo carga:** a mayor concurrencia (10 VUs desde un mismo cliente)
+el _rate limiting_ por cliente rechaza el exceso de forma rápida y controlada
+(no hay degradación en cascada), como fija la estrategia de throughput.
+
+> Fecha de medición: 2026-07-23. Repetible con `k6 run` del script de carga
+> (login + lectura). El desvío de login se re-evalúa sobre hardware de piloto
+> real antes del corte de release ([G-074](../../GAPS.md)).
+
+## 2. Estrategia de Confiabilidad y Disponibilidad
+
+* **Objetivo de disponibilidad:** ≥ 99.5 % mensual para el servicio de
+ autenticación (objetivo inicial, a revisar con datos de operación).
+* **Consistencia sin 2PC:** los cambios y sus eventos se publican con
+ _Transactional Outbox_ en la misma transacción; la auditoría y las proyecciones
+ se actualizan por consistencia eventual confiable.
+* **Idempotencia:** middleware de `Idempotency-Key` (ADR-UMS-063) hace seguros los
+ reintentos de comandos ante fallos transitorios.
+* **Degradación controlada:** ante indisponibilidad de un IdP externo, el sistema
+ aplica _fail-closed_ en autorización (deniega por defecto) y expone un error
+ accionable con id de diagnóstico, en vez de conceder acceso indebido.
+* **Aislamiento de fallos:** circuit breakers y timeouts hacia dependencias
+ externas (IdP, bus) para evitar el agotamiento de hilos/conexiones; _backpressure_
+ en el consumidor de eventos.
+* **Recuperación:** el estado autoritativo vive en PostgreSQL; las proyecciones de
+ lectura son reconstruibles desde los eventos, por lo que una proyección corrupta
+ no es una pérdida de datos.
+
+## 3. Cómo se Verifica
+
+Los objetivos anteriores no se dan por cumplidos hasta medirlos. La verificación
+se compone de:
+
+* **Prueba de carga base** (k6/JMeter) sobre las operaciones de la §1, para
+ establecer la línea base y detectar regresiones. Los proyectos de carga se
+ importaron en `src/tests/load` y `src/tests/performance`.
+* **Pruebas de fallo** (indisponibilidad de IdP, corte del bus, reintentos) que
+ ejerciten la degradación y la idempotencia de la §2.
+* **Observabilidad** (OpenTelemetry) para medir las latencias reales en operación
+ y comparar contra los SLO ([G-004](../../GAPS.md)).
+
+Esta verificación empírica se ejecuta en el sprint de pruebas
+([G-014](../../GAPS.md)); hasta entonces, `G-002` y `G-003` permanecen abiertos en su
+parte de _verificación_.
+
+## 4. Trazabilidad
+
+Los objetivos se apoyan en decisiones ya tomadas: pipeline interno del grafo
+(ADR-UMS-080), idempotencia (ADR-UMS-063), API **REST-only** con CQRS en la capa de
+aplicación ([D-007](../../DECISIONS.md), que revisa ADR-UMS-055/059, antes GraphQL para
+queries), persistencia **PostgreSQL únicamente** ([D-008](../../DECISIONS.md), que
+hace cumplir ADR-UMS-089) y bus con outbox (ADR-UMS-051). Su retrazado a ADRs aceptados
+de `evolith-core` es deuda ([G-012](../../GAPS.md)).
+
+## Historial de Cambios
+
+| Versión | Fecha | Autor | Descripción |
+| :--- | :--- | :--- | :--- |
+| 1.0.1 | 2026-07-14 | BeyondNet S.A.C. | Alineación a API REST-only + PostgreSQL-only (D-007, D-008): SLO de lectura por REST `GET` en vez de GraphQL |
+| 1.0.0 | 2026-07-13 | BeyondNet S.A.C. | Objetivos de rendimiento (SLO) y estrategia de confiabilidad iniciales. Avanza G-002 y G-003 (queda la verificación empírica) |
+
+---
+
+
diff --git a/docs/architecture/session-context-architecture-review.es.md b/docs/architecture/session-context-architecture-review.es.md
new file mode 100644
index 00000000..9b71e9be
--- /dev/null
+++ b/docs/architecture/session-context-architecture-review.es.md
@@ -0,0 +1,605 @@
+# Informe de Evaluación de Arquitectura — UMS: Autenticación, Grafo de Autorización y Contexto de Sistema
+
+**Repositorio:** `ums` (rama `develop`) · **Fecha:** 2026-08-01 · **Autor:** Software Architect Enterprise
+**Alcance:** backend `.NET 10` (`src/apps/ums.api`), SDK (`src/libs/sdk`), frontend (`src/apps/ums.web-app`), infraestructura (`src/infra`)
+**Método:** análisis estático con verificación adversarial de todas las afirmaciones de alto impacto. Las afirmaciones refutadas o corregidas durante la verificación **no** aparecen como hechos en este informe.
+
+---
+
+## 1. Resumen ejecutivo
+
+UMS **ya tiene** un motor de contexto de sesión. No hay que construirlo: `AuthorizationGraph` + `AuthorizationGraphBuilderService` + `AuthGraphPayload` son exactamente el "contexto de sistema" que pide el escenario objetivo, construido en 11 pasos y compartido por `/auth/login` y `/api/v1/client/authenticate` (`AuthorizationGraph.cs:83-112`, `AuthorizationGraphBuilderService.cs:84-237`, `AuthGraphPayload.cs:40-67`). Los patrones enterprise necesarios están presentes y bien usados: CQRS tipado sobre MediatR, Result pattern, agregados DDD, repositorios con UnitOfWork, outbox transaccional, factory de serializadores y dos capas AOP de autorización. **La brecha no es de patrones: es de cardinalidad, de composición del contrato y de coste por login.**
+
+Cuatro conclusiones, en orden de gravedad:
+
+1. **El contrato es mono-perfil y mono-sistema por construcción.** El builder elige UN perfil con `FirstOrDefault` sobre un orden por `RoleId` (un GUID) y descarta el resto sin registrarlo — no hay logger inyectado en el servicio (`AuthorizationGraphBuilderService.cs:121-123`, `PostgreSqlProfileRepository.cs:70`). `GraphContext` lleva un `SystemSuite?`, un `Role?` y un `Profile?` escalares (`GraphContext.cs:9-15`). El escenario multi-perfil es alcanzable en producción: el índice `(TenantId, UserId, RoleId, BranchId)` no es único (`ProfileRecordConfiguration.cs:21`) y el flujo IGA crea perfiles adicionales cuando difieren rol o sucursal (`ApproveRequestCommandHandler.cs:187-191`). Los puntos 3 y 4 del escenario objetivo **no tienen hoy ninguna representación en el contrato**.
+
+2. **El aplanamiento del árbol de navegación pierde datos en silencio.** `MenuNode` es un árbol recursivo de profundidad arbitraria aceptado por ADR-0090, pero `BuildMenuAccess` recorre exactamente tres niveles literales Menu→SubMenu→Option (`AuthorizationGraphBuilderService.cs:307,311,315`). Cualquier opción fuera de ese patrón —creable hoy desde la API (`AddNodeCommand.cs:37-51` no valida forma) y desde el UI de administración (`SuiteNode.tsx:90,358-360`)— desaparece del grafo sin error ni traza. El efecto es _fail-closed_ (el usuario pierde acceso, no lo gana), pero el diagnóstico es caro porque la data semilla nunca lo reproduce (`AuthorizationDevDataSeeder.cs:346-355`) y el read path administrativo **sí** muestra el nodo (`SystemSuiteDto.cs:73`, recursivo).
+
+3. **El coste por login es alto y una parte es desperdicio puro y demostrable.** El grafo emite 16 sentencias SQL estrictamente secuenciales (verificado sumando las implementaciones: Tenant 1 + Profile 2 + Role 1 + SystemSuite 7 + Templates 2 + FeatureFlags 3); el login completo suma ~24 con el handler. De esas, **2 son 100 % desperdicio**: la carga de todas las plantillas del inquilino con sus ítems cuyo resultado se descarta con `#pragma warning disable S1481` (`AuthorizationGraphBuilderService.cs:148-155`, `PostgreSqlPermissionTemplateRepository.cs:52-63`). Se paga en login, en ambos refresh y en el preview. Ninguna lectura del grafo usa `AsNoTracking` — hay exactamente una ocurrencia en todo `Infrastructure/Persistence` (`RefreshTokenStore.cs:53`).
+
+4. **El bloqueador operativo más urgente no es el grafo: es que UMS no puede escalar a más de una réplica.** `backend-deployment.yaml:8` fija `replicas: 1` literal, sin `resources`, sin HPA ni PDB. Y aunque se cambiara, se rompería funcionalidad: el código selecciona Redis con `configuration["Redis:Connection"]` (`DependencyInjection.cs:151`) mientras el chart inyecta `REDIS_CONNECTION` (`backend-deployment.yaml:53-54`) — la clave no enlaza, así que en Kubernetes corren `InMemoryTokenRevocationStore` e `InMemoryConfigurationCache`. Sumado a la ausencia total de Data Protection persistido (0 ocurrencias de `PersistKeysTo`/`SetApplicationName`, `AuthenticationExtensions.cs:49-70`), escalar hoy produce cookies indescifrables entre pods y tokens revocados que siguen valiendo.
+
+**Recomendación global:** no rediseñar. Hay una secuencia de siete cambios acotados —cinco de ellos de riesgo bajo y efecto medible— que resuelven el 80 % del problema antes de tocar el contrato. El contrato solo debe cambiar una vez, y con ADR.
+
+---
+
+## 2. Evaluación de la arquitectura actual
+
+### 2.1 Lo que está bien hecho (y no hay que tocar)
+
+| Área | Evidencia | Valoración |
+|---|---|---|
+| CQRS tipado | `ICommand.cs:6`, `IQueryHandler.cs:6`, MediatR 12.4.1 (`Ums.Application.csproj:19`) | Separación de contrato, no solo de nombres. Correcto. |
+| Result pattern | `Result.cs:7-45` | Alta adopción. Único reparo: el error es `string` y el código HTTP se extrae por parsing (`ClientAuthEndpoints.cs:160-164`). Deuda menor, no bloqueante. |
+| Agregados DDD | `Profile.cs:10`, `SystemSuite.cs:14` sobre `AggregateRoot` | Invariantes en el dominio, no en handlers. Correcto. |
+| Outbox transaccional | `DependencyInjection.cs:250-258`, `UmsPlatformDbContext.cs:234-235` | `UseBusOutbox()` = entrega post-commit. Bien resuelto. |
+| Fail-closed en permisos | `AuthorizationGraphBuilderService.cs:389-394` (G-039/ADR-UMS-088) | El default es `NotGranted`, no `Allow`. Decisión correcta y documentada. |
+| Deny-wins | `AuthorizationGraphBuilderService.cs:260-267`, tests en `AuthorizationGraphBuilderServiceTests.cs:290-352` | Independiente del orden, verificado por test adversarial. Correcto. |
+| Grafo lobby (usuario sin perfil) | `AuthorizationGraphBuilderService.cs:125-130,458-505`, `AuthEndpoints.cs:186-199` | Caso resuelto de forma controlada y null-safe extremo a extremo (G-043, G-122). Bien. |
+| Cadena IdP anti-spraying | `IdpChainAuthenticator.cs:163-166,170` | Solo avanza por `InfraUnavailable`, nunca por credencial. Detección de ciclos y tope de saltos. Diseño de seguridad correcto. |
+| Índices del grafo | `SystemSuiteNodeRecordConfiguration.cs:33`, `ProfilePermissionRecordConfiguration.cs:18-19`, y resto | Todos los compuestos llevan el padre como columna líder: las cargas por FK están cubiertas. |
+| Rehidratación del árbol | `AuthorizationAggregateFactory.cs:225-233` | Agrupa por padre en diccionario, reconstruye en O(N). Sin búsquedas cuadráticas. Bien resuelto. |
+| SLOs formales | `docs/02-diseno/objetivos-calidad.md:18-24` | Presupuestos por operación, específicos, no genéricos. Artefacto real. |
+| Observabilidad de ruta | `ObservabilityExtensions.cs:57-126`, `dashboards/ums-overview.json` | p95 por `http_route` medible contra SLO. Alertas sobre métricas que existen. |
+
+### 2.2 Los tres límites estructurales
+
+**(a) Cardinalidad singular.** El builder resuelve un perfil → un rol → una suite (`AuthorizationGraphBuilderService.cs:121-141`) y el contrato lo congela: `PrincipalContext` del JSON Schema exige `systemSuite`/`role`/`profile` singulares (`auth-graph.schema.json:87-105`). Nota importante para el diseño de la solución: **todo lo aguas abajo del par (suite, profile) ya está parametrizado por ese par** — `BuildPermissionMap` (`:245`), `BuildMenuAccess` (`:295`), `BuildDomainPermissions` (`:376`), `EvaluateFeatureFlagsAsync` (`:421`) y `DeriveScopes` (`:517`) son funciones puras sobre `(suite, permMap)` y serían reutilizables tal cual en un bucle por perfil. El rediseño toca los pasos 2-4 y 10 y el contrato; **no toca el motor de resolución de permisos**.
+
+**(b) Composición del contexto.** `GraphEffectiveConfig` es un record cerrado de 7 campos, todos de seguridad de sesión (`GraphEffectiveConfig.cs:9-16`). Falta la capa visual (logo, colores, temas, iconos, página inicial) y las integraciones. Dato relevante: el repositorio de suites **ya carga** `AppSettings` (clave/valor/ámbito, `AppSetting.cs:3-14`) en cada lectura (`PostgreSqlSystemSuiteRepository.cs:23`) y el builder **nunca los proyecta** — la materia prima se paga y se descarta.
+
+**(c) Coste no amortizado.** Cero caché en el camino del grafo. El servicio es Scoped sin decorador (`DependencyInjection.cs:112`). El catálogo de suite —idéntico para todos los usuarios de esa suite, cambia solo por acción administrativa— se relee y rehidrata entero en cada login de cada usuario (7 sentencias SQL con `AsSplitQuery`, `PostgreSqlSystemSuiteRepository.cs:20-26`).
+
+---
+
+## 3. Comparación arquitectura actual vs requerimientos
+
+### 3.1 Requerimientos funcionales
+
+| # | Requerimiento del escenario | Estado | Evidencia |
+|---|---|---|---|
+| 1 | Detección automática del mecanismo (local/IdP) | **Cubierto** | `AuthMethodResolverService.cs:54-133`: cascada `AUTH_USE_EXTERNAL_IDP` (0 SQL, memoria) + reglas FR-042 por prioridad/suite/dominio de email. Dos fallbacks diferenciados. El cliente no elige nada. |
+| 2 | Resolver el grafo con filtros opcionales (Sistema, Rol) | **Ausente** | `LoginRequest` = 4 campos (`AuthEndpoints.cs:649-653`). `RequestedScopes` está muerto: única ocurrencia en `src/` es su declaración (`ClientAuthEndpoints.cs:210`). `AuthenticateUserCommand.SystemSuiteId` **sí se lee** (`:94`, `:220`) pero **solo** para resolver IdP — es contexto de autenticación por FR-042, no filtro de autorización; y ningún endpoint lo puebla. |
+| 3 | Devolver TODOS los perfiles autorizados | **Ausente** | `FirstOrDefault` y descarte (`AuthorizationGraphBuilderService.cs:121-123`). Matiz verificado: el usuario **sí puede listarlos** vía `GET /profiles?userId=` (`ProfileEndpoints.cs:21-49`), pero **no puede usarlos**: el único camino que honra un `profileId` es `BuildForProfileAsync`, consumido solo por el preview administrativo (`PreviewProfileAuthGraphCommandHandler.cs:79`), que no emite token. Previsualizable, nunca conmutable. |
+| 4 | Contexto de sistema completo por sistema autorizado | **Parcial** | Viajan navegación, permisos efectivos de menú y dominio, flags y scopes (`AuthGraphPayload.cs:50-66`). También idioma y zona horaria, **pero solo en el login web** (`SessionParameters`, `AuthEndpoints.cs:219-229,661-671`) y **fuera del contrato versionado**: `/api/v1/client/authenticate` no los entrega. Ausentes: logo, colores, temas, iconos, layout, página inicial, integraciones, parámetros generales. También ausentes en el payload: icono y **ruta** de cada opción de menú (`MenuNodeProps.cs:9-19` no tiene esos campos) — por eso el front real tiene la navegación hardcodeada (`navigation.config.tsx:1-29`) y el único consumo de `menuAccess` es resolución de acceso (`use-access-resolution.ts`), no pintado de menú. |
+| 5 | Cacheo en cliente toda la sesión; cambios al re-autenticar | **Cubierto** | Es el comportamiento actual: los tres puntos de entrada reconstruyen (`AuthenticateUserCommandHandler.cs:286`, `RefreshAuthenticationCommandHandler.cs:167`, `RefreshSessionCommandHandler.cs:79`) y `ValidUntil = GeneratedAt + SessionTimeout` (`AuthorizationGraph.cs:71-78`). |
+| 6 | Evaluar `include=bloque1,bloque2` | **Costura presente, capacidad ausente** | `AuthGraphPayload` está sobre `Dictionary` **precisamente** para omitir claves según condición (comentario explícito, `:36-38`), y `WithId` ya omite bloques según `GraphSerializationOptions.IncludeTechnicalMetadata` (`:77-84`). Es la costura exacta donde encaja `include=`. Pero hoy `BuildInternalAsync` siempre construye todas las secciones (`:172-177`). |
+
+### 3.2 Requerimientos no funcionales
+
+| RNF | Estado | Números verificados |
+|---|---|---|
+| Múltiples aplicaciones | **Parcial** | El modelo lo soporta (SystemSuite por tenant); el contrato de sesión no (un sistema por grafo). |
+| Multi-tenant | **Parcial, con defecto** | RLS activo en 18 tablas (`20260720152552_EnableRowLevelSecurity.cs:13-33`), pero la política castea `"TenantId"::text` (`:41-42`) → inutiliza los índices btree. Además el lookup de login es por email **global** (ver §6.2). |
+| Miles de usuarios concurrentes | **No preparado** | `replicas: 1` en duro (`backend-deployment.yaml:8`); Redis nunca se activa; sin Data Protection persistido; idempotencia y rate limiter en proceso. |
+| Latencia / throughput | **Medido una vez, no reproducible** | Login p95 ~353 ms vs SLO 300 ms (`objetivos-calidad.md:33-50`, k6 2026-07-23, 3 VUs). No hay artefacto de esa ejecución en el repo; los tres scripts k6 presentes apuntan a otros objetivos (`login-performance.js:17` → `localhost:5293`). El panel de métricas está en `_auto_` (`metrics/index.md:412-421`). Ningún objetivo de throughput verificado. |
+| Memoria / ancho de banda | **Medido, malo** | 43.039–49.997 bytes por login (8 capturas reales, `src/provisioning/sdlc/auth-graph/*.json`). 94,8 % es el grafo; **52 % son `domainPermissions` cartesianas**. Sin compresión en ninguna capa (0 ocurrencias de `ResponseCompression` en `src/`, sin `gzip` en `nginx.conf.template`). |
+| Escalabilidad horizontal | **Bloqueada** | Ver §6.1. |
+
+---
+
+## 4. Requerimientos ya cubiertos
+
+Lo que **no** hay que volver a construir:
+
+1. **Resolución automática del mecanismo de autenticación.** Completa, con motor de reglas FR-042, cascada de configuración en memoria (0 SQL en modo local) y dos fallbacks bien diferenciados: legado `tenant.GetActiveIdentityProvider()` cuando ninguna `IdpConfiguration` gobierna (`AuthMethodResolverService.cs:119`), y caída a Local para `ExternalApi` (G-049) cuando la regla ganó pero el proveedor no está activo (`:95`).
+
+2. **Cadena de fallback IdP resistente a credential-spraying.** Solo avanza por `InfraUnavailable`; `CredentialTerminal` corta (`IdpChainAuthenticator.cs:163-166`). Cadena agotada → `AUTH_018` → HTTP 503, no 401 (`:176`, `AuthEndpoints.cs:311-312`). Correcto.
+
+3. **Motor de contexto de sesión.** El `AuthorizationGraph` es el contexto de sistema: principal, autenticación, catálogo de acciones, navegación con permisos efectivos, permisos de dominio, flags, configuración efectiva, scopes y ventana de validez. Compartido por el login web y el de satélites mediante un mapeador único (`AuthGraphPayload`).
+
+4. **Regeneración al re-autenticar (punto 5 del escenario).** Ya es el comportamiento: nada que hacer.
+
+5. **Precompilación del mapa de permisos.** `BuildPermissionMap` ya es exactamente un índice materializado: colapsa las `ProfilePermission` activas en `Dictionary<(TargetId,ActionId),(Effect,Source)>` con deny-wins estricto y override-wins entre Allow (`AuthorizationGraphBuilderService.cs:245-282`). La discusión no es si el patrón encaja: ya está.
+
+6. **Caso "usuario sin perfil".** Grafo lobby con `onboardingPending:true`, null-safe extremo a extremo (G-043/G-122, cerrado 2026-07-22 con login 200 verificado).
+
+7. **Extensibilidad en persistencia.** `SystemSuiteAppSettings(SuiteId, ConfigKey, ConfigValue, ScopeId)` con UNIQUE por las tres (`SystemSuiteAppSettingRecordConfiguration.cs:17`), igual que `AppConfigurations` y `ParameterTenantValues`: **añadir un tipo de configuración nuevo no requiere migración de esquema**. El requisito de extensibilidad está estructuralmente cubierto en la capa de datos.
+
+8. **Composición agregado→componentes en el modelo.** `DomainResource.ParentResourceId` existe, `SystemSuite.AddDomainResource` valida que el padre exista para `DomainMethod` (`SystemSuite.cs:420-431`), el builder lo propaga (`:414`) y el front ya sabe pintar el árbol (`ProfileDomainResourcesPanel.tsx:109,126,130`).
+
+9. **Versionado de contrato con política declarada.** `SCHEMA_VERSIONING.md:32` clasifica "Add new top-level section" como **MINOR**, los SDK validan la ventana `[1.0.0, 2.0.0)` (`schema-version.ts:38-44`, consumida por sdk-client, sdk-express y sdk-authorization) y existe fixture dedicada (`schema-minor-ahead.json`, versión 1.99.0). El mecanismo de extensión existe.
+
+10. **Costura para `include=`.** `AuthGraphPayload` sobre diccionarios + `GraphSerializationOptions` + parámetro de inquilino `AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA`. Infraestructura lista.
+
+11. **Maquinaria de ETag.** `ETagHelper` (RFC 7232) ya usado en AppConfiguration y Tenant (`AppConfigurationQueryEndpoints.cs:45-68`). Reutilizable.
+
+12. **Outbox + catálogo de eventos de invalidación.** `SystemSuiteModuleAdded/Removed/StatusChanged`, `SystemSuiteActionRegistered/Removed`, `PermissionTemplatePublished/Mutated/Deprecated`, `PermissionOverridden`, `ProfileRoleChanged`, `RoleActionGranted/Revoked` (`DomainEvents.cs:44-77`) — exactamente los disparadores que necesitaría una caché de catálogo. Caveat: el transporte RabbitMQ solo se configura si existe `ConnectionStrings:RabbitMq` (`DependencyInjection.cs:211`), que ni el chart ni compose definen → hoy la rama efectiva es `UsingInMemory` (`:267-275`) y los eventos no cruzan proceso.
+
+---
+
+## 5. Brechas identificadas
+
+### B-1 · Cardinalidad singular del contexto — **Alto**
+
+`FirstOrDefault` sobre orden por `RoleId` (`AuthorizationGraphBuilderService.cs:121-123`, `PostgreSqlProfileRepository.cs:70`). Sin criterio de negocio: `Role.HierarchyLevel` existe y se proyecta al grafo (`:205`) pero **no participa** en la selección. Sin logger: el descarte no deja traza. El orden es además un detalle de implementación del repositorio — `InMemoryProfileRepository.cs:55-60` no ordena en absoluto. Y hay un caso sin desempate posible: dos perfiles activos con el mismo `RoleId` y distinto `BranchId`.
+
+### B-2 · Aplanamiento del árbol de navegación con pérdida silenciosa — **Alto**
+
+Tres bucles literales (`:307,311,315`) frente a un dominio recursivo. El defecto es **mayor que el bucle**: el propio contrato de salida es de tres niveles por tipo (`GraphMenuAccess.cs:11-39`, `auth-graph.schema.json:230-261`) y lo replican los serializadores XML/YAML/CSV y la emisión de claims (`JwtTokenService.cs:159-163`, `AuthEndpoints.cs:232-238`). Corregir solo el builder **no basta**. El resto del código sí es recursivo (`SystemSuiteDto.cs:73`, `MenuNode.Find:191-208`): la regresión está confinada a la proyección del grafo de acceso.
+
+### B-3 · Composición del contexto incompleta — **Alto**
+
+Ausentes: branding (logo, colores, temas, iconos), layout, página inicial, integraciones, parámetros generales. Los códigos `UI_*` existentes son tres y ninguno aporta payload: `UI_CUSTOM_BRANDING_ENABLED` es un interruptor booleano, `UI_LANGUAGE_DEFAULT`, `UI_TIMEZONE_DEFAULT` (`AppConfigurationCodes.cs:15,18,19`). El VO `Logo` y el enum `LogoFormat` existen pero ningún agregado los usa (única referencia fuera de su definición: `LogoTests.cs`). La tabla `TenantBrandings` fue eliminada (`20260715020343_DropTenantBrandingTable.cs:15`).
+
+**Precisión importante:** `TenantParameter` es el metamodelo más rico —tiene `ValueType`, `Category` (con `Ui`=3 y `Localization`=6), `IsSensitive`, `DefaultValue`, `AllowedValues` (`TenantParameterProps.cs:37-48`, `TenantParameterCategory.cs:5-12`)— pero `TenantParameterCodes` no declara **ni un solo** código de UI ni de localización (`:5-25`). Es el candidato natural, está por poblar.
+
+### B-4 · La plantilla no es fuente de verdad en tiempo de build — **Alto**
+
+El builder consulta la plantilla publicada y la descarta (`:148-155`, S1481 suprimido, G-016). El docstring de la clase declara "IsOverride = false → use TemplateItem values" (`:30-36`) pero `BuildPermissionMap` lee siempre `pp.Props.IsAllowed/IsDenied`. La plantilla **sí llega** al mapa, pero por copia previa en `Profile.AssignTemplate` (`Profile.cs:95-118`), invocada al crear el perfil (`CreateProfileCommandHandler.cs:129-138`).
+
+**Corrección relevante frente a la lectura inicial:** una plantilla en uso **no se puede editar** — `AddItem`/`RemoveItem` exigen `Status == Draft` (`PermissionTemplate.cs:161,216,241`) y `AssignTemplate` exige `Published` (`Profile.cs:78`), sin retorno a Draft. La deriva no nace de editar la plantilla. Nace de que **publicar una versión nueva no tiene camino de resincronización**: `AssignTemplate` rechaza re-vincular la misma plantilla (`Profile.cs:85-88`), asignar la nueva **añade** sus permisos sin revocar los de la anterior, y `Deprecate` (`:118-135`) no desactiva los `ProfilePermission` derivados. Revocar exige desactivar permiso a permiso por id (`SetProfilePermissionStatusCommandHandler.cs:59-61`).
+
+Además: `ApproveRequestCommandHandler.cs:208` crea perfiles por aprobación IGA **sin** `AssignTemplate` — quedan con 0 permisos.
+
+### B-5 · Herencia de roles no efectiva sobre permisos — **Medio**
+
+No hay ascenso por `ParentRoleId` en ningún punto: ni en el grafo (`BuildPermissionMap` solo lee `profile.Permissions`) ni en la materialización (`CreateProfileCommandHandler` resuelve por `roleId` exacto). Un test lo fija adversarialmente con `Times.Never` sobre el rol padre ante un ciclo R1→R2→R1 (`AuthorizationGraphBuilderServiceTests.cs:465`).
+
+**Matiz que cambia la severidad en dos direcciones:** el efecto es _fail-closed_ (matriz incompleta, nunca escalada de privilegios). Pero la jerarquía **no es decorativa**: `HeuristicRiskScoreCalculator.cs:57,71` calcula el RiskScore de una promoción a partir del delta de `HierarchyLevel` y lo documenta literalmente como _proxy de permisos nuevos_, y ese score enruta la aprobación. **IGA puntúa riesgo asumiendo una correlación que el motor de autorización ignora por completo.** Ese es el riesgo real, más agudo que "el negocio podría suponer mal".
+
+### B-6 · Contrato: el coste de añadir un bloque no es donde se creía — **Medio**
+
+Verificación adversarial: **ningún SDK valida el payload contra el JSON Schema** — grep de `ajv|jsonschema|check-jsonschema` sobre todo el repo no devuelve nada fuera del `"$schema"` del propio fichero. El SDK .NET deserializa sin `UnmappedMemberHandling.Disallow` (`UmsAuthGraphMiddleware.cs:23-26`) → ignora claves desconocidas; el zod del front no usa `.strict()` y nunca se ejecuta en runtime. Un bloque nuevo **atravesaría los tres SDK sin romper nada**, y la política ya lo clasifica como MINOR.
+
+El coste real es otro: **cinco espejos cableados del contrato** (record, mapeador, JSON Schema, tipos TS/.NET, fixtures) más un test que exige la lista ordenada exacta de 12 claves (`AuthGraphPayloadTests.cs:155-161` — puerta deseada, no ruptura), y sobre todo un arnés E2E que **pinea `schema_version == "1.0.0"`** (`robosoft/contexts/configuration.py:419`): un bump MINOR legítimo hace FAIL INV-CF14. Además `effectiveConfig` es cerrado y sin hueco de extensión (0 ocurrencias de `extensions`/`x-` en el esquema).
+
+### B-7 · Sin caché, sin `AsNoTracking`, sin proyección — **Alto**
+
+Ninguna lectura del grafo usa `AsNoTracking` (única ocurrencia en `Infrastructure/Persistence`: `RefreshTokenStore.cs:53`). El único read model existente no materializa nada: `PermissionTemplateProjectionHandler.cs:35` crea el read model con `Items = []` y **nunca** las puebla; ninguna query lo lee (grep: solo el proyector, el DbContext y el registro de DI). No hay base para servir el contexto sin reconstruirlo.
+
+### B-8 · Sin paginación en el catálogo — **Alto**
+
+`GetAllSystemSuitesQueryHandler.cs:38-73` carga **todas** las suites del inquilino con el grafo completo y aplica `Where`/`OrderBy`/`Count`/`Skip`/`Take` sobre `IEnumerable` en memoria. Los repositorios de suites no tienen `Skip`/`Take` (`PostgreSqlSystemSuiteRepository.cs:57-88`). Ninguna tabla del grafo pagina.
+
+### B-9 · El único stage con usuarios reales no observa — **Medio**
+
+`values-uat.yaml:29-35` pone `observability.enabled: false` por colisión de NodePort con el despliegue dev en el mismo kind. El entorno con personas reales no emite trazas ni métricas: no puede calibrar SLOs (pendiente delegado en G-074).
+
+---
+
+## 6. Riesgos técnicos
+
+### 6.1 · Escalar a más de una réplica rompe funcionalidad, no solo rendimiento — **Severidad: alta**
+
+Cuatro fallos independientes que se manifiestan simultáneamente al poner `replicas > 1`:
+
+| Fallo | Evidencia | Efecto |
+|---|---|---|
+| Cookies indescifrables entre pods | 0 ocurrencias de `DataProtection`/`PersistKeysTo`/`SetApplicationName`; `AuthenticationExtensions.cs:49-70` | Cookie emitida por pod A no se descifra en pod B → 401 aleatorios. Todas las sesiones se invalidan en cada rollout. |
+| Revocación de token no compartida | `DependencyInjection.cs:151` lee `Redis:Connection`; chart inyecta `REDIS_CONNECTION` (`backend-deployment.yaml:53-54`) | Usuario bloqueado sigue autenticándose contra los pods que no procesaron la revocación. |
+| Configuración incoherente | Misma causa → `InMemoryConfigurationCache`; `ReloadTenantAsync` es local (`InMemoryConfigurationCache.cs:104-116`, con `TODO(G-069)` que lo admite) | Sin TTL ni relectura periódica: los demás pods sirven el valor viejo **indefinidamente**. |
+| Idempotencia perdida | `IdempotencyMiddleware.cs:23-32`, cuyo propio XML-doc lo admite | Reintento con la misma `Idempotency-Key` en otro pod re-ejecuta el comando. |
+
+Añadido: el rate limiter es `PartitionedRateLimiter` en proceso (`UmsApiServiceBootstrappers.cs:203-215`) → el límite efectivo se multiplica por N réplicas. Y migración + siembra corren en **cada** réplica al arrancar (`UmsApiServiceBootstrappers.cs:249-281` con ambos flags a `"true"` en `backend-deployment.yaml:49-52`): carrera de migraciones latente.
+
+**G-069 está marcado "Cerrado 2026-07-20" apoyándose en `RedisConfigurationCache.cs`, sobre una vía que el despliegue nunca ejerce.** Hay que reabrirlo.
+
+### 6.2 · Colisión de email entre inquilinos — **Severidad: alta**
+
+`GetByEmailAsync` resuelve `FirstOrDefaultAsync(x => x.Email == ...)` sin acotar por tenant y **sin `ORDER BY`** (`PostgreSqlUserAccountRepository.cs:35-44`). El filtro global de EF no actúa: el login es `AllowAnonymous` (`AuthEndpoints.cs:34,56`), `TenantContextMiddleware` corre después de `UseAuthentication` (`UmsApiServiceBootstrappers.cs:329-332`) y solo lee claims (`TenantContextMiddleware.cs:17-22`) → `OrganizationId` null → el `!HasValue` cortocircuita (`UmsPlatformDbContext.cs:265-269`). El `tenantCode` del body **nunca** alimenta al TenantContext. La unicidad es `(TenantId, Email)` (`UserAccountRecordConfiguration.cs:31`).
+
+**Tres agravantes verificados:**
+
+1. **La colisión es creable por el producto.** El alta administrativa usa el mismo `GetByEmailAsync`, que ahí **sí** está filtrado por tenant (admin autenticado) → solo ve su inquilino y deja pasar el duplicado cruzado (`CreateUserAccountCommandHandler.cs:55`). El mismo método actúa global o acotado según haya sesión: esa es la fragilidad de fondo.
+2. **El flujo IdP es peor que denegación.** `AuthenticateUserCommandHandler.cs:235-244` hace el mismo lookup global pero **no compara `TenantId` en absoluto**; la cuenta hallada pasa a `BuildResultAsync` y el grafo se construye con el `tenantId` solicitado (`:286`). Eso es **potencial cruce de frontera de inquilino en la emisión del grafo**, no solo un 401.
+3. **No es estable.** Sin `ORDER BY`, quién gana depende del plan; puede invertirse tras un `ANALYZE`.
+
+El remedio ya existe sin usar: `GetByTenantAndEmailAsync` (`PostgreSqlUserAccountRepository.cs:46-67`), hoy invocado solo por el seeder.
+
+### 6.3 · El JWT embebe la matriz cartesiana de permisos — **Severidad: alta**
+
+`GenerateGraphToken` añade un claim `perm` por **cada** opción de menú sin filtrar efecto, un `domain_perm` por **cada** par recurso×acción, un `scope` por scope y un `feature` por flag (`JwtTokenService.cs:157-174`). Con los datos capturados: 84 `perm` + 286 `domain_perm` + 370 `scope` (admin) o 29 (directorio) → payload base64url calculado de **~23,3 KB (admin) y ~16,2 KB (directorio) para UN solo sistema**. Se envía en `Authorization: Bearer` en **cada** petición (`auth.store.ts:283`).
+
+`nginx.conf.template` no fija `large_client_header_buffers` (default `4 8k` → HTTP 400 con línea >8 KB) y Kestrel tiene 32 KB por defecto. *Riesgo de despliegue estimado, no ejecutado; el tamaño de claims sí está calculado sobre datos reales.*
+
+### 6.4 · Pérdida silenciosa de nodos de navegación — **Severidad: alta**
+
+Ver B-2. Riesgo agravado: la pérdida es _fail-closed_ pero **invisible** — el admin ve el nodo en el read path recursivo (`SystemSuiteDto.cs:73`) mientras el usuario no lo recibe. `DeriveScopes` deriva scopes solo de `menuAccess` (`:523-528`), así que la opción caída tampoco genera scope. No existe gap registrado que cubra este caso; G-029 está **cerrado** afirmando "grafo de acceso desde module.Nodes", lo que da el trabajo por hecho.
+
+### 6.5 · RLS con cast a texto anula los índices — **Severidad: alta**
+
+`USING (current_setting(...) = '' OR "TenantId"::text = current_setting(...))` (`20260720152552_EnableRowLevelSecurity.cs:41-42`). El cast `uuid::text` impide usar `IX_SystemSuites_TenantId`, `IX_Profiles_TenantId`, etc.; el `OR` con expresión no relacionada bloquea el índice aunque se corrigiera el cast. Aplica a 18 tablas con `FORCE ROW LEVEL SECURITY` → **toda** consulta de la ruta de login. Complementario: el interceptor emite un `SELECT set_config(...)` extra en cada apertura de conexión (`OrganizationDbContextInterceptor.cs:75,83`).
+
+Añadido: los DbSet **hijos** del grafo (`SystemSuiteNodes`, `ProfilePermissions`, `PermissionTemplateItems`, …) no tienen RLS ni `HasQueryFilter`. Mientras se accede por `Include` el JOIN los acota, pero los accesos directos no: `PermissionTemplateItems.CountAsync(...)` (`PostgreSqlPermissionTemplateRepository.cs:154`) y `ProfilePermissions.Where(...)` (`PostgreSqlProfileRepository.cs:133`) recorren filas de todos los inquilinos.
+
+### 6.6 · Tormenta de invalidación al activar Redis — **Severidad: alta (condicional)**
+
+El handler de la suscripción llama `ReloadAsync`/`ReloadTenantAsync`, que a su vez llaman `InvalidateAll`/`InvalidateTenant`, **que vuelven a publicar** (`RedisConfigurationCache.cs:50-76,162-179`; `ConfigurationProvider.cs:78-115`). Con suscripción por patrón, cada pod recibe cada mensaje incluido el propio → ciclo autoamplificado proporcional al número de réplicas. Y `ConfigurationProvider.Dispose()` (`:199`) invoca `InvalidateAll()`: **cada apagado de pod en un rolling dispara una recarga total en todos los demás**. Además `InvalidateSuite`/`InvalidateModule` no publican nada (`:168-170`) → esos ámbitos nunca cruzarían pods: coherencia parcial, más difícil de diagnosticar que la incoherencia total actual.
+
+**Orden crítico: arreglar esto ANTES de corregir la clave de conexión.** El orden inverso convierte un fallo silencioso en una tormenta.
+
+### 6.7 · Formato del grafo declarado ≠ formato serializado — **Severidad: alta**
+
+El handler resuelve el formato por defecto del inquilino y lo devuelve en `GraphFormat` (`AuthenticateUserCommandHandler.cs:295,308-315`), pero serializa **siempre** con el `IAuthorizationGraphSerializer` inyectado, registrado explícitamente como JSON (`DependencyInjection.cs:143-145`). El endpoint solo re-serializa si el llamante pide un formato **distinto** al declarado (`ClientAuthEndpoints.cs:86-112`). Si el inquilino tiene `AUTH_GRAPH_DEFAULT_FORMAT=XML` y el cliente no envía `?format` ni `Accept`: respuesta con `Format=XML`, cabecera `X-Graph-Format: XML`, **cuerpo JSON**.
+
+### 6.8 · Divergencia documentación↔código en `AuthAccessScope` — **Severidad: media**
+
+`AuthAccessScope.PortalManagement` está documentado como "UMS management portal login (/api/v1/auth/login). Uses local BCrypt. IDP is NOT required or consulted" (`AuthAccessScope.cs:14-19`), pero `HandleLoginAsync` construye el comando con `AccessScope: AuthAccessScope.ExternalApi` (`AuthEndpoints.cs:157`). El atajo del resolver (`:54-57`) queda sin llamador en producción. Quien razone sobre superficie de ataque leyendo el dominio concluirá lo contrario de lo que ocurre. Es exactamente el tipo de mentira de estado que SD-05 prohíbe.
+
+### 6.9 · Refresco de perfiles multi-plantilla: `OverrideNeutral` no revoca — **Severidad: media**
+
+Una fila con efecto `NotGranted` se descarta con `continue` (`:256`) antes de la resolución de precedencia. En un perfil con dos plantillas, un "neutral" **no revoca** un Allow de la otra. Corolario del mismo diseño: el override **muta la fila en sitio** (`ProfilePermission.cs:50-75`), destruyendo el valor original de plantilla, así que la rama override-wins solo cambia la etiqueta `Source` del DTO, nunca el `Effect` — el único desempate con consecuencia funcional es deny-wins.
+
+---
+
+## 7. Análisis de rendimiento y escalabilidad
+
+### 7.1 Coste real por login (verificado, ruta local, camino feliz)
+
+| Paso | Llamada | SQL | Nota |
+|---|---|---|---|
+| 1 | `_tenantRepo.GetByCodeAsync` (`handler:69`) | 1 | 2 Includes sin `AsSplitQuery` → cartesiano Branches×IdPs |
+| 2 | `_methodResolver.ResolveAsync` (`:91`) | 0 | Todo en memoria (`ConfigurationProvider.cs:125-131`) |
+| 3 | `_userRepo.GetByEmailAsync` (`:138`) | 3 | `AsSplitQuery` + MfaEnrollments + PasswordCredentials |
+| 4 | `_configProvider.ForTenant` (`:160`) | 0 | Extensión en memoria |
+| 5 | `_localStrategy.Authenticate` (`:175`) | 0 | BCrypt: mayor consumidor de CPU por login |
+| 6 | `_userRepo.UpdateAsync` (`:198`) | 1 | **Relectura** del usuario ya cargado en [3] |
+| 7 | `SaveEntitiesAsync` (`:199`) | ~0-1 | **No escribe outbox**: acumula en memoria para despacho in-process (`UmsPlatformDbContext.cs:64-68,88-89`). En login limpio no emite UPDATE (`UserAccount.cs:446-454`) |
+| 8a | `_tenantRepo.GetByIdAsync` (`builder:94`) | 1 | **Redundante**: el tenant ya está cargado en [1] |
+| 8b | `_profileRepo.GetByUserIdAsync` (`:121`) | 2 | Trae **todos** los perfiles con permisos; se descarta todo menos uno |
+| 8c | `_roleRepo.GetByIdAsync` (`:134`) | 1 | |
+| 8d | `_suiteRepo.GetByIdAsync` (`:139`) | **7** | Consulta dominante: raíz + Modules + Nodes + Node.Actions + AppSettings + Actions + DomainResources |
+| 8e | `_templateRepo.GetByTenantIdAsync` (`:148`) | 2 | **100 % desperdicio**: resultado descartado (S1481, G-016) |
+| 8f | `_featureFlagRepo.GetBySystemSuiteIdAsync` (`:428`) | 3 | Incluye `EvaluationLogs`, que el evaluador jamás consulta |
+| 9 | `_formatProvider.GetDefaultFormatAsync` (`:295`) | 1 | Independiente del grafo |
+| 10 | `_auditService.RecordAuthEventAsync` (`:298`) | 1 tx | `SaveChangesAsync` propio (`AuthAuditService.cs:47-51`) |
+| 11 | `refreshTokenStore.IssueAsync` (`AuthEndpoints.cs:249`) | 1 tx | **Solo si** el inquilino lo activó — el default es `false` (`AppConfigurationDefaults.cs:22`) |
+
+**Totales verificados:** ~24 sentencias SQL en el login completo (16 solo el grafo), más un `SELECT set_config(...)` por cada apertura de conexión. **Transacciones de escritura en el camino feliz por defecto: 1** (el INSERT de auditoría), no 3 — el UPDATE de usuario no se emite con contador limpio y el refresh token está desactivado por defecto.
+
+**Cadena irreductiblemente secuencial: 4 saltos, no 5.** `GetByEmailAsync` toma solo un Email derivado de `command.Username` (`:138`) y **no depende** del resultado de [1]; la comprobación de pertenencia es posterior (`:141`). El tenant es una rama paralela de longitud 1. La cadena forzosa es user→profile→role→suite.
+
+**Caveat sobre paralelización:** el `DbContext` es scoped y no es thread-safe. Paralelizar exige `IServiceScopeFactory` con scopes separados — no es un `Task.WhenAll` gratis.
+
+**Lo que NO está verificado:** "satura el pool de conexiones y las escrituras serializan". No hay `MaxPoolSize` configurado (grep sin resultados; `DependencyInjection.cs:312-320` solo fija reintentos), no hay transacción que abarque el request, y las escrituras son un INSERT append-only y un UPDATE por fila distinta. El impacto demostrable es **latencia por número de round-trips**, lineal. La degradación no lineal del pool es hipótesis a medir, no hallazgo.
+
+En modo IdP hay coste adicional: el resolver recarga el tenant (`AuthMethodResolverService.cs:76`) y lee `IdpConfiguration` (`:112`), y acto seguido `IdpChainAuthenticator` **vuelve a leer** la misma colección y a ejecutar el mismo selector con los mismos argumentos (`:97-98`). 2 SQL y una evaluación de reglas duplicadas por login federado.
+
+### 7.2 Coste algorítmico (CPU)
+
+Para un sistema medio (6 módulos, ~35 opciones, 13 acciones, 22 recursos, P≈375 permisos): permMap ~1.100 ops, BuildActions ~61, BuildMenuAccess ~600-900, BuildDomainPermissions ~1.900, DeriveScopes ~3.200. **Total ≈7.000-8.000 operaciones elementales y ~2.000 objetos por login: orden de 0,3-0,8 ms de CPU pura.** El algoritmo **no es el cuello de botella a este tamaño.**
+
+Tres ineficiencias reales pero secundarias, que sí escalan mal:
+
+* `suite.Actions.FirstOrDefault(a => a.Props.Code == actionCode)` dentro del bucle quíntuple (`:325`). El `actionLookup` construido 160 líneas antes (`:164`) está indexado por Id, no por Code. Con 2.000 opciones y 200 acciones son 400.000 comparaciones de string por login. Corrección: un segundo diccionario, tres líneas.
+* `actionLookup.OrderBy(kv => kv.Value.Code)` **dentro** del foreach de recursos (`:387`): O(R · A log A) en vez de O(A log A).
+* Rehidratación por reflexión sin memoización: `GetConstructor`/`GetField`/`GetProperty` resueltos en **cada** entidad (`AuthorizationAggregateFactory.cs:355-379`). ~500 entidades por login del sistema medio; con 2.000 nodos por suite, ~8.000 búsquedas de metadatos.
+
+### 7.3 Payload (medido sobre 8 capturas reales)
+
+`src/provisioning/sdlc/auth-graph/*.json`, minificado: **43.039–49.997 bytes**. Desglose de `admin_sdlc.json` (49.997 B):
+
+| Bloque | Bytes | % |
+|---|---:|---:|
+| `authorizationGraph` | 47.396 | 94,8 % |
+| → `domainPermissions` | 26.138 | **52,3 %** |
+| → `menuAccess` | 11.823 | 23,6 % |
+| → `scopes` | 7.588 | 15,2 % |
+| → resto (actions, context, flags, config) | 1.847 | 3,7 % |
+| `permissions` (duplica los Allow de menuAccess) | 1.708 | 3,4 % |
+| `sessionParameters` | 280 | 0,6 % |
+
+**El payload no escala con lo que el usuario puede hacer, sino con el tamaño del catálogo.** El perfil más restringido (`directorio.json`) recibe 43.039 B con solo 22 Allow de 286 filas de dominio y 7 de 84 de menú: **se envían 264 filas `NotGranted`**. Causa directa: `BuildDomainPermissions` emite el producto cartesiano completo (`:383-406`).
+
+**Compresión: ausente en todas las capas.** Grep sobre `src/`: 0 ocurrencias de `ResponseCompression`/`UseResponseCompression`/`Brotli`/`Gzip`. El nginx del frontend —único ingreso, hace `proxy_pass` de `/api/`— no activa `gzip` (`nginx.conf.template:1-40`). Ahorro perdido medido: **admin_sdlc 49.997 → 5.308 B (9,4x); directorio 43.039 → 3.702 B (11,6x)**.
+
+Escala: un usuario con 4 sistemas descargaría ~190 KB por login; 1.000 logins/minuto ≈ 3,2 MB/s (~25 Mbps) solo de payload de autenticación.
+
+### 7.4 Trabajo desperdiciado, cuantificado
+
+| Desperdicio | Coste | Evidencia |
+|---|---|---|
+| Serialización del grafo que se descarta | 1 serialización JSON completa (~47 KB de salida) por login | `AuthenticateUserCommandHandler.cs:294-315` produce `SerializedGraph`; el endpoint de login nunca lo consume, construye con `AuthGraphPayload.Build(graph)` (`AuthEndpoints.cs:282`) |
+| Plantillas cargadas y descartadas | 2 SQL + rehidratación de todos los ítems del inquilino | `:148-155` |
+| `Include(EvaluationLogs)` | Parte de 3 SQL, colección sin cota ni poda | `PostgreSqlFeatureFlagRepository.cs:74` — el login usa el evaluador sin estado (`:442`), no `FeatureFlag.Evaluate`; la tabla crece por el endpoint admin (`EvaluateFeatureFlagCommandHandler.cs:52`), no por tráfico de login |
+| Relectura de Tenant | 1 SQL | `builder:94` vs `handler:69` |
+| Relectura de UserAccount | 1 SQL | `PostgreSqlUserAccountRepository.cs:198-204`. **El propio repo demuestra la alternativa**: `PostgreSqlSystemSuiteRepository.cs:101-116` busca en `ChangeTracker` para evitar exactamente eso |
+| Change tracking en lecturas puras | Snapshot de cada entidad del grafo | 0 `AsNoTracking` en el camino |
+
+### 7.5 Línea base y observabilidad
+
+Existe una medición real: k6 contra kind, 3 VUs, 100 % éxito, 2026-07-23 → **login p95 ~353 ms** (sobre el SLO de 300 ms, atribuido a BCrypt en HW de dev) y GET /tenants p95 ~18 ms (`objetivos-calidad.md:33-50`, cierre de G-002). Pero **no es reproducible desde el repositorio**: no hay salida cruda ni JSON de resumen, y ninguno de los tres scripts presentes es el descrito (`login-performance.js:17` apunta a `localhost:5293`; `smoke.js` solo golpea health; `stress.js` golpea GET /tenants con DevAuth). Choca con SD-05.
+
+Observabilidad: OTel con trazas (ASP.NET, HttpClient, EF Core, AOP) y métricas exportadas por OTLP; el dashboard grafica p95 por `http_route`, así que **la latencia de POST /auth/login sí es medible contra su SLO**. Lo que no existe: instrumentos propios. Los meters `UMS.Application` y `UMS.Infrastructure` se registran con el comentario "reserved for future instrumentation" y grep confirma **0 ocurrencias de `new Meter(`** (`ObservabilityExtensions.cs:115-117`). No se mide el desglose del login (BCrypt vs consultas vs construcción vs serialización) ni el tamaño del payload — exactamente lo que hace falta para decidir dónde optimizar.
+
+### 7.6 Estimación de caché de catálogo (no verificada, orden de magnitud)
+
+El grafo proyectado de una suite media ocupa 47 KB de JSON; el árbol equivalente como objetos gestionados (~150-200 entidades) rondaría 100-200 KB por suite. **100 suites ≈ 10-20 MB por réplica; 1.000 suites ≈ 100-200 MB.** Frente a eso, hoy cada login paga 7 consultas SQL + rehidratación + snapshot de tracking del mismo árbol. El orden de magnitud sugiere que una caché de catálogo por proceso es barata; **requiere medición antes de afirmarlo.**
+
+---
+
+## 8. Recomendaciones priorizadas
+
+### 8.1 ALTO impacto
+
+---
+
+#### R-1 · Corregir el lookup de usuario en el login: usar `GetByTenantAndEmailAsync`
+
+* **Qué:** sustituir `GetByEmailAsync` por `GetByTenantAndEmailAsync(tenantId, email, ct)` en las dos ramas del handler (local `:138` e IdP `:235`), y **añadir en la rama IdP la comprobación de `TenantId` que hoy falta**.
+* **Beneficio:** elimina el fallo de colisión cross-tenant (§6.2) y el potencial cruce de frontera en la emisión del grafo por IdP. Mejora además el plan de consulta (usa el índice único `(TenantId, Email)`).
+* **Coste:** 2 líneas + 1 guarda. El método ya existe (`PostgreSqlUserAccountRepository.cs:46-67`).
+* **Precaución:** antes de aplicar, consultar la base de producción por emails duplicados entre inquilinos. Si los hay, el fix cambia el comportamiento observado para esos usuarios (a mejor, pero hay que saberlo).
+* **Medición:** test de integración con dos cuentas del mismo email en tenants distintos, ambas autenticando correctamente. Hoy ese test no existe (`TenantIsolationTests.cs` solo cubre lectura cruzada).
+
+---
+
+#### R-2 · Desbloquear el escalado horizontal (tres cambios, en este orden)
+
+1. **Redis:** que `DependencyInjection.cs:151` lea la misma clave que el chart inyecta, **o** que el chart inyecte `Redis__Connection`. Añadir un log/health-check de arranque que afirme qué implementación quedó activa, y un test que falle si con Redis configurado se registra el store en memoria. **Reabrir G-069.**
+2. **Data Protection:** registrar el anillo de claves persistido en Redis con `SetApplicationName` fijo.
+3. **Idempotencia:** migrar `IdempotencyMiddleware` a `IDistributedCache` reutilizando el `InstanceName = "ums:"` existente, conservando el TTL de 24 h.
+
+* **PRE-REQUISITO OBLIGATORIO:** antes de (1), arreglar la tormenta de invalidación (§6.6): separar `InvalidateLocal*` (sin publicar, para el handler del suscriptor) de `Invalidate*` (con publicación, para la ruta de comando); incluir id de origen en el payload y descartar mensajes propios; **quitar la publicación de `Dispose()`**; y hacer que `InvalidateSuite`/`InvalidateModule` publiquen.
+* **Beneficio:** habilita `replicas > 1` sin romper sesiones, revocación ni idempotencia.
+* **Coste:** bajo por cambio; el orden es lo crítico.
+* **Medición:** desplegar con 2 réplicas en UAT; login en pod A, petición autenticada servida por pod B → 200. Revocar token en A → 401 en B.
+* **Solo después:** parametrizar `replicas` (`backend-deployment.yaml:8`), declarar `resources` (hoy QoS BestEffort, primer candidato a desalojo), añadir plantillas HPA y PDB — el bloque `autoscaling` de `values.yaml:158-163` no lo consume ninguna plantilla, y `values/backend.yaml` es huérfano. Y **antes de escalar**, mover migración/siembra fuera del arranque de cada réplica (`UmsApiServiceBootstrappers.cs:249-281`).
+
+---
+
+#### R-3 · Activar compresión de respuesta
+
+* **Qué:** `AddResponseCompression` + `UseResponseCompression` (Brotli/Gzip) en la API, **o** `gzip on; gzip_proxied any;` en el nginx del frontend.
+* **Beneficio medido:** 9,4x–11,6x sobre capturas reales (49.997 → 5.308 B; 43.039 → 3.702 B). Elimina ~90 % del ancho de banda del login.
+* **Coste:** una llamada de configuración. CPU marginal para JSON de este tamaño. **No toca el contrato.**
+* **Medición:** `Content-Length` de la respuesta de login antes/después.
+* **Es la mejor relación beneficio/complejidad de todo el informe.**
+
+---
+
+#### R-4 · Sacar la matriz de permisos del JWT
+
+* **Qué:** emitir un JWT de identidad y sesión (`sub`, `tenant`, `suite`, `rol`, `jti`, `exp`) y dejar que el grafo viaje solo en el cuerpo, donde el cliente ya lo cachea. Si hay que conservar algo en el token, únicamente los `scope` con efecto **Allow** (29–370 hoy), nunca las filas `NotGranted`.
+* **Beneficio:** hoy el token estimado es 16–24 KB **para un solo sistema** y viaja en cada petición; con varios sistemas es inviable. Es la **única** optimización que actúa sobre todas las peticiones y no sobre una por sesión.
+* **Coste:** medio. Toca `JwtTokenService.cs:157-174` y los consumidores que lean esos claims (el aspecto de autorización del servidor y el SDK).
+* **Medición previa de 5 minutos:** medir el tamaño real del token emitido en UAT. Después: tamaño de token p95.
+
+---
+
+#### R-5 · Eliminar el trabajo desperdiciado del builder (cuatro cortes)
+
+| Corte | Acción | Riesgo |
+|---|---|---|
+| Plantillas | Borrar `:148-155`. Si G-016 debe quedar documentado, dejarlo como comentario **sin ejecutar I/O**. Cuando se cablee, usar `GetByTenantRoleSuiteAsync` (`:65-74`), que ya existe | Nulo: el resultado no se usa |
+| EvaluationLogs | Añadir sobrecarga del repositorio de flags sin `Include(EvaluationLogs)` para la vía del grafo | Nulo: el evaluador no los toca (`FeatureFlagEvaluator.cs:8-28`) |
+| Tenant redundante | Pasar el `Tenant` ya cargado al builder. **Nota:** ninguna sobrecarga de `IAuthorizationGraphBuilder` acepta hoy un `Tenant` (`:21-35`); hay que añadir el parámetro (el patrón ya existe para `UserAccount`) | Bajo |
+| Relectura de usuario | Resolver por `ChangeTracker` como ya hace `PostgreSqlSystemSuiteRepository.cs:101-116` | Bajo |
+
+* **Beneficio:** −5 sentencias SQL por login (de ~24 a ~19) y menos presión de GC; el corte de plantillas además elimina una carga cuyo volumen crece con el tamaño del inquilino, no del usuario. Se paga también en cada refresh y en el preview.
+* **Coste:** bajo, todo interno.
+* **Medición:** repetir la medición k6 de login p95 (hoy ~353 ms). Es la forma de saber si el desvío sobre el SLO es solo BCrypt, como afirma el documento.
+
+---
+
+#### R-6 · Omitir las filas `NotGranted` del cable
+
+* **Qué:** emitir solo entradas con `Allow` o `Deny` explícito, y declarar en el contrato que la ausencia es denegación (semántica que el propio builder ya documenta, `:389-392`).
+* **Beneficio medido:** ~52 % del grafo para perfiles restringidos. Combinado con R-3, el login de un perfil normal baja de ~43 KB a ~2 KB en el cable.
+* **Coste:** cambio de contrato → ADR + bump de `schemaVersion`. **No** rediseño.
+* **Precaución:** requiere actualizar el arnés RoboSoft, que hoy pinea `schema_version == "1.0.0"` (`configuration.py:419`) y haría FAIL con un bump legítimo.
+* **Medición:** bytes del payload por perfil restringido, antes/después.
+
+---
+
+#### R-7 · Índice O(1) en el SDK de autorización
+
+* **Qué:** al hacer `set(graph)` en el accessor, materializar un `Dictionary` para opciones de menú, otro para `(resourceCode, actionCode)` y un `HashSet` case-insensitive para scopes. `AuthorizationValidator` consulta el índice.
+* **Beneficio:** hoy `RequireMenuOption` ejecuta **cuatro foreach anidados** en **cada** comprobación de permiso (`AuthorizationValidator.cs:58-83`), sobre un grafo que es inmutable toda la sesión (`memory.ts:11-24`). Es coste por petición, no por login.
+* **Coste:** ~30 líneas por lenguaje. Cero cambios de contrato. **Aplicar en paralelo en .NET y TypeScript** para no romper la paridad del SDK.
+* **Medición:** benchmark de `RequireMenuOption` con un grafo de 500 opciones.
+* **Es el mayor ratio beneficio/coste del informe después de R-3.**
+
+---
+
+#### R-8 · Corregir el aplanamiento del árbol de navegación
+
+* **Qué:** sustituir los tres records de nivel por un `GraphNavigationNode(Code, Value, Kind, SortOrder, ActionCode?, Effect?, Source?, Children)` y proyectar recursivamente desde `MenuNode`.
+* **Alcance real:** builder + `GraphMenuAccess.cs:11-39` + `AuthGraphPayload` + JSON Schema + serializadores XML/YAML/CSV + emisión de claims + validadores del SDK (.NET y TS). **Exige bump MAJOR a `schemaVersion` 2.0.0.**
+* **Antes de decidir:** ejecutar una consulta sobre los datos sembrados y de UAT contando cuántos nodos se pierden hoy. **Si son cero en producción, planificar junto al bump de esquema en vez de urgirlo.**
+* **Red de seguridad inmediata (coste horas):** añadir un test que siembre una Option raíz y una Option hija directa de un Menu y verifique que aparecen en `MenuAccess`. Hoy fallará — y esa es la prueba de la brecha.
+* **Adicionalmente, ahora:** validar la forma en `AddNodeCommand` (`:37-51`) para que la API deje de aceptar topologías que el grafo no puede representar. Eso convierte un fallo silencioso en un error explícito, que es lo que exige SD-06.
+
+---
+
+### 8.2 MEDIO impacto
+
+#### R-9 · Determinismo y visibilidad de la selección de perfil (aditivo, sin romper contrato)
+
+Tres cambios que se apilan sin tocar la cardinalidad del contrato:
+
+1. Hacer explícito el desempate: `HierarchyLevel` del rol (ya existe y ya se proyecta) + `Code` de la suite como desempate estable, en vez del GUID. Resultado determinista y **explicable**.
+2. Inyectar `ILogger` en el builder y **registrar cuántos perfiles se descartaron**. Hoy la pérdida es invisible.
+3. Devolver la **lista** de perfiles autorizados como bloque aditivo: el builder ya la tiene cargada (`:121`) y hoy la tira → **coste marginal cero SQL**.
+
+* **Coste:** bajo. (3) requiere bump MINOR de esquema.
+* **Medición:** un usuario con 3 perfiles ve los 3 en la respuesta y el elegido es reproducible entre despliegues.
+
+---
+
+#### R-10 · Reparar el `set_config` y la política RLS
+
+* **Qué:** migración que reescriba la política comparando en tipo nativo: `"TenantId" = NULLIF(current_setting('app.current_organization_id', true),'')::uuid`, separando el caso "sin restricción" en política aparte para que el planificador pueda usar `IX_*_TenantId`.
+* **Beneficio:** hoy toda consulta de la ruta de login hace seq scan sobre 18 tablas con `FORCE ROW LEVEL SECURITY`; la latencia degrada con el tamaño **total** de la base, no con el del inquilino.
+* **Coste:** una migración. **Riesgo de seguridad si se equivoca** → revisión obligatoria.
+* **Medición:** `EXPLAIN ANALYZE` antes/después sobre datos representativos.
+
+---
+
+#### R-11 · Caché del catálogo de suite (medir primero)
+
+* **Qué:** caché de solo lectura del catálogo rehidratado con clave `(suiteId, versión)` sobre el `IDistributedCache` ya cableado, invalidada por los eventos `SystemSuite*` que el outbox ya publica.
+* **Por qué esto y no el grafo por usuario:** el catálogo es **idéntico para todos los usuarios de la suite** y solo cambia por acción administrativa; es lo más caro de leer (7 SQL + reflexión). Cachear el grafo completo por usuario es la opción tentadora y la equivocada: cada entrada pesa |recursos|×|acciones|, la invalidación no se puede calcular barata, y **el propio negocio (punto 5) dice que los cambios se reflejan al re-autenticar**, lo que la hace innecesaria.
+* **Pre-requisitos:** R-2 completo (sin Redis efectivo la caché sería incoherente entre pods) y **medición previa** con instrumentación de `BuildInternalAsync` que confirme que la suite es el término dominante.
+* **Coste:** medio. **No abordar sin número.**
+
+---
+
+#### R-12 · Correcciones algorítmicas locales
+
+| Cambio | Ubicación | Coste |
+|---|---|---|
+| `Dictionary` por código junto al `actionLookup` existente | `:164`, elimina el `FirstOrDefault` de `:325` | 3 líneas |
+| Sacar el `OrderBy` fuera del bucle de recursos | `:387` | 2 líneas |
+| Memoizar `ConstructorInfo`/`FieldInfo`/`PropertyInfo` en `ConcurrentDictionary` estáticos, o compilar delegados | `AuthorizationAggregateFactory.cs:355-379` | contenido en la factory, sin tocar dominio |
+| `AsNoTracking()` en las lecturas del builder | los 5 repositorios del camino | bajo; verificar que ninguna ruta compartida escriba |
+
+* **Medición:** benchmark de `BuildAsync` antes/después. Sin benchmark, no aceptar ninguno.
+
+---
+
+#### R-13 · Consolidar la doble resolución de IdP
+
+Pasar la selección ya resuelta del `AuthMethodResolverService` al `IdpChainAuthenticator` en vez de recalcularla (`:112-113` vs `:97-98`). **Cuidado quirúrgico:** el puente configuración→proveedor **no es el mismo** en ambos sitios — el resolver exige `IsActive` (`:128`), la cadena deliberadamente **no** (`IdpChainAuthenticator.cs:206-207`, con su justificación). Cualquier unificación debe preservar esa asimetría o rompe el fallback.
+
+---
+
+#### R-14 · Cerrar las tres divergencias documento↔código
+
+1. `AuthAccessScope` (§6.8): decidir la intención y alinear el otro lado en el **mismo** cambio (patrón P7 del cierre de G-075). Si la intención es que el portal use IdP → corregir el XMLdoc y registrar el gap. Si es que sea Local → el endpoint tiene un defecto de seguridad silencioso.
+2. Formato del grafo (§6.7): resolver el serializador por la factory dentro del handler usando `GraphSerializationCriteria` con el formato ya resuelto, **o** devolver siempre `Format=JSON` cuando se use el serializador por defecto. Añadir prueba de contrato: inquilino con formato no-JSON, petición sin override → el cuerpo parsea en el formato anunciado.
+3. Docstring de `AuthorizationGraphBuilderService:30-36` ("IsOverride=false → use TemplateItem values"): el builder no hace eso. Corregir el texto o cablear el comportamiento; no dejar la mentira.
+
+---
+
+#### R-15 · Política explícita de re-materialización de plantilla
+
+Decidir por ADR y documentar (§B-4). Opciones: (a) re-materializar por evento de publicación con un proceso en background que reconcilie `ProfilePermission` **preservando `IsOverride`**; (b) declarar que la plantilla es un molde de una sola aplicación y exponer una acción administrativa "reaplicar plantilla" que **reemplace** en vez de acumular. Hoy no está decidido ni documentado, y el estado actual acumula sin revocar.
+
+Como corrección puntual del mismo bloque: `ApproveRequestCommandHandler.cs:208` crea perfiles sin plantilla → 0 permisos. Alinear con `CreateProfileCommandHandler`.
+
+---
+
+#### R-16 · `include=` propagado hasta el builder, no solo al serializador
+
+* **Qué:** extender `GraphSerializationOptions` con el conjunto de bloques solicitados y propagarlo **aguas arriba** hasta `BuildInternalAsync`, de modo que `EvaluateFeatureFlagsAsync` y `BuildDomainPermissions` se salten si su bloque no se pidió.
+* **Advertencia central:** si `include=` se aplica solo al proyector, el ahorro es de ancho de banda y **el coste dominante (rehidratar la suite, evaluar flags) se sigue pagando**. Además, sobre el diseño actual —donde una sola lectura del agregado alimenta varios bloques— `include=` no ahorra consultas: para que las ahorre, cada bloque debe resolverse con su propia consulta proyectada.
+* **Si se adopta:** hacerlo como conjunto **cerrado** de perfiles con nombre (`minimal`/`standard`/`full`), no como lista libre, para que la clave de caché siga acotada. Y reutilizar o **retirar** `RequestedScopes` antes de que algún satélite empiece a enviarlo — hoy un integrador que lo envíe recibe el grafo completo sin aviso, falla silenciosa en vez de 400.
+* **Prioridad:** **último**. Las seis intervenciones anteriores dan más retorno.
+
+---
+
+### 8.3 BAJO impacto
+
+* **Instrumentación propia:** dos histogramas en el meter `UMS.Application` ya registrado — duración de `BuildInternalAsync` y tamaño del payload emitido. Convierte el dashboard de "el login tarda X" a "el login tarda X y se va en Y".
+* **Versionar la línea base k6** que produjo la medición del 2026-07-23, apuntando al despliegue (no a `localhost`), con su salida JSON. Requisito de SD-05.
+* **Habilitar observabilidad en UAT** (`values-uat.yaml:29-35`) resolviendo la colisión de NodePort. Es el único entorno con personas reales.
+* **Paginación real en `GetAllSystemSuites`:** método de repositorio que proyecte a DTO plano sin Includes, con `AsNoTracking` y `Skip`/`Take` traducidos a SQL. El agregado completo se reserva para escritura.
+* **Índices faltantes:** `PermissionTemplateItems(TargetId, IsActive)`, `ProfilePermissions(TemplateId)`, índice parcial `Profiles(UserId) WHERE IsActive`, `SystemSuiteDomainResources(ModuleId)`.
+* **`DeriveScopes`:** normalizar códigos a minúsculas en origen para evitar hasta ~1.000 asignaciones de string por login (`:517-536`).
+* **`AuthorizationAspect`:** sustituir `Console.WriteLine` (`:35,66,70,73`) por el logger estructurado.
+* **Auditoría de éxito fuera del camino crítico:** escribirla en la unidad de trabajo que ya se confirma (`handler:199`) o desacoplarla por el outbox existente. **La de fallo debe permanecer síncrona** (su propio comentario lo justifica) y **no tocar el registro de intentos fallidos**: sostiene la política de bloqueo ADR-UMS-095.
+
+---
+
+## 9. Cambios mínimos necesarios
+
+Lo estrictamente necesario para que UMS sea correcto y desplegable en multi-réplica. Sin esto, nada más importa.
+
+| # | Cambio | Corrige | Coste |
+|---|---|---|---|
+| 1 | `GetByTenantAndEmailAsync` + guarda de `TenantId` en la rama IdP | Cruce de frontera de inquilino (§6.2) | 3 líneas |
+| 2 | Separar invalidación local de publicada; quitar publicación de `Dispose()` | Tormenta de invalidación (§6.6) | Bajo |
+| 3 | Alinear la clave de Redis y añadir health-check de arranque | Revocación y configuración incoherentes (§6.1) | Trivial |
+| 4 | Data Protection persistido con `SetApplicationName` | Cookies indescifrables entre pods (§6.1) | Bajo |
+| 5 | `IdempotencyMiddleware` → `IDistributedCache` | Idempotencia perdida (§6.1) | Bajo |
+| 6 | Borrar la carga de plantillas del builder | 2 SQL de desperdicio puro por login/refresh/preview | 8 líneas |
+| 7 | Quitar `Include(EvaluationLogs)` de la vía del grafo | Carga sin cota | 1 línea |
+| 8 | Activar compresión | ~90 % del ancho de banda | 1 línea |
+| 9 | Test que siembre Option fuera del patrón de 3 niveles | Convierte la pérdida silenciosa en fallo visible (§6.4) | Horas |
+| 10 | Validar forma en `AddNodeCommand` | Impide crear topologías irrepresentables | Bajo |
+| 11 | Reabrir G-069 y registrar en `GAPS.md` los hallazgos §6.4, §6.7, §6.8, §B-5 | SD-07 | Documental |
+
+**Orden obligatorio:** 2 → 3 (invertirlo convierte un fallo silencioso en una tormenta). El resto es independiente.
+
+---
+
+## 10. Mejoras opcionales de alto valor
+
+**Contribuidores de bloque en vez de un método de 537 líneas.** Introducir `IGraphSectionContributor { string Key; Task