From fce6505e69c45607fc702bb69eb53f00dba95f25 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Thu, 30 Jul 2026 20:40:49 -0500 Subject: [PATCH] feat(GT-616,GT-617): trazas de gobierno encendibles e inventarios documentales derivados MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GT-616 — VERIFICADO, y peor de lo denunciado. La ficha decía que `TrackerTracing` retorna temprano con `Otlp:Endpoint` vacío, que es el valor por defecto. Cierto (`TrackerTracing.cs:55`), pero ese apagado por defecto lo decidió `T-049` a propósito y NO se revierte: un exportador que no alcanza a nadie reintenta para siempre y ata la disponibilidad del producto a la de un sumidero de telemetría. Lo que no defendía ningún ADR son otras tres cosas que la ficha no vio: 1. No existía UNA SOLA llamada a `StartActivity` en el repositorio. La fuente `Evolith.Tracker` se registraba en el pipeline y no la invocaba nadie, así que encender el exportador sólo habría llenado el backend de fontanería de ASP.NET. 2. El configmap no emitía `Otlp__Endpoint` en absoluto: un operador CON colector no tenía forma soportada de encenderlas. 3. `.env.example:40` declara `OTEL_EXPORTER_OTLP_ENDPOINT` y el comentario del código afirmaba que ésa era «la variable que por fin tiene consumidor». No lo era: el enlace de ASP.NET traduce `Otlp__Endpoint`. El interruptor documentado no encendía nada. Se añaden `GovernanceTraceTags` (tenant/actor/iniciativa/fase/agente/desenlace), `StartGovernanceActivity` + `SetGovernanceOutcome`, y se instrumentan los tres puntos donde se decide gobierno: decidir una compuerta, evaluar contra el Core y un turno de agente. Sin listener el helper devuelve null y no cuesta nada, así que los puntos de llamada no se esconden tras un `if`. De paso, `RepoRoot()` de las guardas de fuente no reconocía un worktree de git (`.git` es un FICHERO, no un directorio): subía hasta el checkout PRINCIPAL y escaneaba otra rama. Se detectó al ver una guarda seguir en rojo con el arreglo ya en disco. GT-617 — VERIFICADO, con cifras propias (las del diagnóstico también fallaban). Medido contra el código: 54 decisiones (no 30), 8 schemas y 49 tablas (no 10/33, y tampoco los 7/45 que el diagnóstico daba por reales — omitía el contexto EF `masterdata`), 12 robots (no 3). Los cinco schemas inexistentes sí son cinco. El arreglo NO es corregir los números: se DERIVAN. `.harness/scripts/doc-inventory.mjs` los saca de los `ModelSnapshot` de EF, de las filas `| T-NNN |` de DECISIONS.md y de importar `robosoft/robots/*.robot.mjs` con el mismo mecanismo del runner, y los escribe en bloques marcados de cinco documentos. `--check` falla el build ante deriva y va cableado a `Validate documentation`. Reescribir cinco bloques no bastaba: la cifra falsa se había copiado a doce sitios más (índices de área, C4, hoja de ruta, spec de CI/CD). Una segunda pasada rastrea cifras a mano en el corpus vivo y las contrasta con lo derivado; se corrigieron las doce. El corpus histórico (DECISIONS, ADRs, docs/audit) queda fuera a propósito: `T-028` DEBE poder decir que declaraba 10 schemas. Verificación (salida real): - GT-616 rojo con el código pristino: 3 pruebas en rojo, `Puntos instrumentados hoy: [], but found 0: {empty}`. Verde con el arreglo: 10/10. - GT-617 rojo antes de generar: los 5 bloques en deriva, exit 1. Verde tras `--write`, exit 0. Dos pruebas de mutación (una decisión nueva en DECISIONS.md, una tabla nueva en el snapshot de EF) vuelven a ponerlo rojo. - `dotnet build -c Release -warnaserror`: 0 avisos, 0 errores. - `dotnet test Tracker.sln -c Release` contra PostgreSQL 16: 1027/1027 + 14/14. - Validadores de documentación de CI, incluida la paridad bilingüe: en verde. Co-Authored-By: Claude Opus 5 --- .github/workflows/docs.yml | 8 + .harness/scripts/doc-inventory.mjs | 409 ++++++++++++++++++ MASTER_INDEX.es.md | 2 +- MASTER_INDEX.md | 4 +- README.es.md | 4 +- README.md | 4 +- .../templates/configmap.yaml | 7 + .../helm/evolith-tracker-api/values.yaml | 17 + reference/specs/architecture/README.es.md | 2 +- reference/specs/architecture/README.md | 2 +- .../c4-macro-topology-phase1.es.md | 2 +- .../architecture/c4-macro-topology-phase1.md | 2 +- .../architecture/scale-out-strategy.es.md | 2 +- .../specs/architecture/scale-out-strategy.md | 2 +- .../tracker-domain-model-overview.es.md | 4 +- reference/specs/design/README.es.md | 4 +- reference/specs/design/README.md | 4 +- .../tracker-implementation-roadmap.es.md | 2 +- .../tracker-platform-implementation.es.md | 2 +- .../design/tracker-platform-implementation.md | 2 +- .../tracker-postgresql-data-design.es.md | 37 +- .../design/tracker-postgresql-data-design.md | 36 +- .../design/tracker-target-architecture.es.md | 2 +- .../infrastructure/tracker-cicd-spec.es.md | 2 +- robosoft/README.md | 19 +- .../Governance/GateDecisionEndpoints.cs | 11 + .../Integration/AssistantEndpoints.cs | 23 +- .../Integration/CoreEvaluationEndpoints.cs | 20 + .../Observability/TrackerTracing.cs | 146 ++++++- .../ObservabilityConventionTests.cs | 204 ++++++++- 30 files changed, 928 insertions(+), 57 deletions(-) create mode 100644 .harness/scripts/doc-inventory.mjs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 67e0b94f..9dae8df1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -33,6 +33,14 @@ jobs: - name: Validate root cleanliness run: node .harness/scripts/validate-root-cleanliness.mjs + # GT-617 — los inventarios documentales se DERIVAN del código, no se mantienen a mano. + # Falla el build a proposito: la insignia de ADRs, el mapa de schemas y el listado de + # robots llevaban meses desfasados (30 vs 54 decisiones, 10 vs 8 schemas, 3 vs 12 + # robots) y nadie lo noto, porque nada los contrastaba con la fuente. Corre en segundos + # y no necesita ni base de datos ni SDK. + - name: Documentation inventory must match the code + run: node .harness/scripts/doc-inventory.mjs --check + - name: Check orphan bilingual files # El escalar plano de `run:` no puede contener ": " — rompia el parseo del workflow y # por eso este fichero nunca llego a ejecutarse (374 ejecuciones, 0 exitosas). Bloque diff --git a/.harness/scripts/doc-inventory.mjs b/.harness/scripts/doc-inventory.mjs new file mode 100644 index 00000000..047a6571 --- /dev/null +++ b/.harness/scripts/doc-inventory.mjs @@ -0,0 +1,409 @@ +#!/usr/bin/env node +// ----------------------------------------------------------------------------- +// GT-617 — inventarios documentales DERIVADOS del código, no mantenidos a mano. +// +// El defecto que cierra no es que unos números estuvieran mal. Es que eran números +// A MANO: la insignia del README decía «30 decisions» contra 54 reales, el diseño +// de datos declaraba 10 schemas y 32 tablas contra 8 y 49 reales —y nombraba cinco +// schemas que no existen—, y el README de RoboSoft listaba 3 robots contra 12. Cada +// uno se escribió correcto el día que se escribió y se quedó atrás en silencio. +// Corregirlos a mano reproduce exactamente el mismo fallo dentro de un mes. +// +// Aquí hay UNA derivación y tres consumidores. Las fuentes de verdad son artefactos +// que el código ya produce y que no se pueden falsear escribiendo prosa: +// +// decisiones → DECISIONS.md, filas `| T-NNN | ... |` del registro canónico +// schemas → los ModelSnapshot de EF Core (`b.ToTable("tabla", "schema")`), +// que EF regenera en cada migración: si alguien añade una tabla, el +// snapshot cambia, y esta guarda se pone roja al instante +// robots → robosoft/robots/*.robot.mjs, IMPORTADOS con el mismo mecanismo con +// el que los carga `robosoft/run.mjs` (no una regex sobre la fuente: +// dos formas de descubrir el mismo conjunto acaban divergiendo) +// +// Uso: +// node .harness/scripts/doc-inventory.mjs imprime el inventario derivado +// node .harness/scripts/doc-inventory.mjs --write reescribe los bloques generados +// node .harness/scripts/doc-inventory.mjs --check falla (exit 1) si hay deriva +// +// `--check` es lo que corre CI. Falla CERRADO por diseño: si una fuente rinde cero +// elementos, o un documento destino ha perdido sus marcas, sale con 2 en vez de pasar. +// Una guarda que sólo puede pasar es el patrón de falso verde que este tablero lleva +// meses cazando; ésta se escribió viéndola primero en ROJO contra los documentos que +// el diagnóstico denunció. +// ----------------------------------------------------------------------------- + +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import process from 'node:process'; + +const AQUI = dirname(fileURLToPath(import.meta.url)); +const RAIZ = join(AQUI, '..', '..'); + +const rutaDe = (...p) => join(RAIZ, ...p); +const leer = (...p) => readFileSync(rutaDe(...p), 'utf8'); + +// ── Fuentes de verdad ──────────────────────────────────────────────────────── + +/** + * Los ModelSnapshot de EF Core. Se leen los DOS contextos: `TrackerDbContext` (el + * dominio) y `TenantProjectionDbContext` (la réplica de MMS que `T-047` declaró como + * excepción CON MOTIVO). Contar sólo el primero daría 7 schemas y 45 tablas y dejaría + * fuera un schema que existe de verdad en la base — que es justo la clase de recorte + * silencioso que produjo el número original. + */ +const CONTEXTOS = [ + { + contexto: 'TrackerDbContext', + ruta: 'src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs', + }, + { + contexto: 'TenantProjectionDbContext', + ruta: 'src/apps/tracker-api/Tracker.Infrastructure/MasterData/Migrations/TenantProjectionDbContextModelSnapshot.cs', + }, +]; + +function derivarSchemas() { + const patron = /\.ToTable\(\s*"([^"]+)"\s*,\s*"([^"]+)"/g; + const porSchema = new Map(); + + for (const { contexto, ruta } of CONTEXTOS) { + const fuente = leer(ruta); + let m; + let encontradas = 0; + while ((m = patron.exec(fuente)) !== null) { + const [, tabla, schema] = m; + encontradas += 1; + if (!porSchema.has(schema)) porSchema.set(schema, { schema, contexto, tablas: [] }); + porSchema.get(schema).tablas.push(tabla); + } + if (encontradas === 0) { + abortar(`${ruta} no produjo ninguna tabla — o el snapshot cambió de forma o la ruta es incorrecta`); + } + } + + const schemas = [...porSchema.values()] + .map((s) => ({ ...s, tablas: s.tablas.sort() })) + .sort((a, b) => a.schema.localeCompare(b.schema)); + + return { + schemas, + totalSchemas: schemas.length, + totalTablas: schemas.reduce((n, s) => n + s.tablas.length, 0), + }; +} + +/** + * Decisiones del satélite. `T-NNN` es el namespace local (distinto de los `ADR-NNNN` + * upstream del Core, que este satélite REFERENCIA pero no posee), y `DECISIONS.md` es + * su registro canónico. Se cuentan los IDS DISTINTOS de la primera columna de la tabla, + * no las menciones: un ADR citado en la columna de notas de otro no es otra decisión. + */ +function derivarDecisiones() { + const fuente = leer('DECISIONS.md'); + const ids = new Set(); + for (const linea of fuente.split('\n')) { + const m = /^\|\s*(T-\d{3})\s*\|/.exec(linea); + if (m) ids.add(m[1]); + } + if (ids.size === 0) abortar('DECISIONS.md no produjo ninguna fila `| T-NNN |`'); + + const ordenados = [...ids].sort(); + return { + total: ids.size, + primera: ordenados[0], + ultima: ordenados[ordenados.length - 1], + }; +} + +/** + * Robots de RoboSoft. Se IMPORTAN, igual que hace `robosoft/run.mjs`, en vez de + * raspar la fuente: `governance-journey.robot.mjs` contiene un `name:` de attrezzo + * en un objeto interior que cualquier regex confundiría con el nombre del robot. + */ +async function derivarRobots() { + const dir = rutaDe('robosoft', 'robots'); + const ficheros = readdirSync(dir).filter((f) => f.endsWith('.robot.mjs')).sort(); + if (ficheros.length === 0) abortar('robosoft/robots/ no contiene ningún *.robot.mjs'); + + const robots = []; + for (const f of ficheros) { + const mod = await import(pathToFileURL(join(dir, f)).href); + const robot = mod.default; + if (!robot?.name || typeof robot.run !== 'function') { + abortar(`robosoft/robots/${f} no exporta un robot válido ({ name, run })`); + } + robots.push({ fichero: f, name: robot.name, description: robot.description ?? '' }); + } + return robots.sort((a, b) => a.name.localeCompare(b.name)); +} + +// ── Bloques generados ──────────────────────────────────────────────────────── + +const marcaInicio = (id) => + ``; +const marcaFin = (id) => ``; + +function insigniaAdrs(inv, idioma) { + const etiqueta = idioma === 'es' ? 'decisiones' : 'decisions'; + const alt = idioma === 'es' ? 'ADRs' : 'ADRs'; + return `[![${alt}](https://img.shields.io/badge/ADRs-${inv.decisiones.total}_${etiqueta}-orange?style=for-the-badge)](./DECISIONS.md)`; +} + +function mapaDeSchemas(inv, idioma) { + const es = idioma === 'es'; + const cabecera = es + ? '| Schema | Contexto EF | Tablas | Tablas (derivadas del snapshot de EF) |' + : '| Schema | EF context | Tables | Tables (derived from the EF snapshot) |'; + const separador = '|--------|------------|--------|---------------------------------------|'; + + const filas = inv.esquemas.schemas.map( + (s) => `| \`${s.schema}\` | \`${s.contexto}\` | ${s.tablas.length} | ${s.tablas.map((t) => `\`${t}\``).join(', ')} |`, + ); + + const resumen = es + ? `**${inv.esquemas.totalSchemas} schemas · ${inv.esquemas.totalTablas} tablas.** Derivado de los \`ModelSnapshot\` de EF Core, que son lo que la base tiene de verdad.` + : `**${inv.esquemas.totalSchemas} schemas · ${inv.esquemas.totalTablas} tables.** Derived from the EF Core \`ModelSnapshot\` files, which are what the database actually has.`; + + return [resumen, '', cabecera, separador, ...filas].join('\n'); +} + +function tablaDeRobots(inv) { + const filas = inv.robots.map((r) => `| \`${r.name}\` | ${r.description} |`); + return [ + `**${inv.robots.length} robots.** Derived from \`robosoft/robots/*.robot.mjs\` — the same discovery \`robosoft/run.mjs\` performs, so this table cannot disagree with what a run executes.`, + '', + '| Robot | Proves |', + '|-------|--------|', + ...filas, + ].join('\n'); +} + +/** + * Los destinos. Cada uno declara el documento, el id del bloque y cómo se renderiza. + * Añadir un consumidor es añadir una fila aquí, no otra copia de los números. + */ +function destinos(inv) { + return [ + { fichero: 'README.md', bloque: 'adr-count', cuerpo: insigniaAdrs(inv, 'en') }, + { fichero: 'README.es.md', bloque: 'adr-count', cuerpo: insigniaAdrs(inv, 'es') }, + { + fichero: 'reference/specs/design/tracker-postgresql-data-design.md', + bloque: 'schema-map', + cuerpo: mapaDeSchemas(inv, 'en'), + }, + { + fichero: 'reference/specs/design/tracker-postgresql-data-design.es.md', + bloque: 'schema-map', + cuerpo: mapaDeSchemas(inv, 'es'), + }, + { fichero: 'robosoft/README.md', bloque: 'robot-roster', cuerpo: tablaDeRobots(inv) }, + ]; +} + +function reemplazarBloque(texto, bloque, cuerpo, fichero) { + const inicio = marcaInicio(bloque); + const fin = marcaFin(bloque); + const i = texto.indexOf(inicio); + const j = texto.indexOf(fin); + + // Fallo CERRADO: un destino sin marcas no se «salta», aborta. Si se saltara, borrar + // las marcas de un documento sería la forma más fácil de poner la guarda en verde — + // exactamente al revés de lo que tiene que premiar. + if (i === -1 || j === -1 || j < i) { + abortar(`${fichero} no contiene el bloque generado '${bloque}' (marcas BEGIN/END ausentes o desordenadas)`); + } + + return texto.slice(0, i) + inicio + '\n' + cuerpo + '\n' + texto.slice(j); +} + +// ── Cifras a mano sueltas por el corpus ────────────────────────────────────── +// +// Reescribir cinco bloques no basta. La cifra falsa del diseño de datos («10 schemas») +// se había copiado a NUEVE documentos más: los índices de área, el C4, la hoja de ruta, +// el spec de CI/CD. Ese es el mecanismo real del defecto — un número escrito a mano se +// propaga por copia y cada copia envejece por su cuenta. Sin esta pasada, arreglar los +// bloques generados dejaría el tablero exactamente igual de mentiroso dentro de un mes. +// +// Se rastrea sólo el corpus VIVO. Quedan fuera a propósito: +// DECISIONS*.md registro de decisiones: `T-028` DEBE poder decir «declaraba 10 +// schemas», porque narra lo que se superó +// docs/adrs/** misma razón: un ADR superado cita su propia cifra +// docs/audit/** el tablero de gaps y su catálogo — narran el hallazgo histórico +// docs/architecture-review/** informe fechado, no documentación viva +// +// Escape para prosa histórica legítima dentro del corpus vivo: `` +// en la misma línea. + +const CORPUS_VIVO = ['README.md', 'README.es.md', 'MASTER_INDEX.md', 'MASTER_INDEX.es.md', 'reference', 'robosoft']; +const ESCAPE = ''; + +function ficherosMarkdown(rutaRelativa) { + const absoluta = rutaDe(rutaRelativa); + let entradas; + try { + entradas = readdirSync(absoluta, { withFileTypes: true }); + } catch { + return absoluta.endsWith('.md') ? [rutaRelativa] : []; + } + + const salida = []; + for (const e of entradas) { + const hijo = `${rutaRelativa}/${e.name}`; + if (e.isDirectory()) { + if (e.name === 'node_modules' || e.name.startsWith('.')) continue; + salida.push(...ficherosMarkdown(hijo)); + } else if (e.name.endsWith('.md')) { + salida.push(hijo); + } + } + return salida; +} + +function cifrasAMano(inv) { + // Sólo se vigilan afirmaciones GLOBALES e inequívocas. La primera versión de esta pasada + // rastreaba además «N tablas» y «N decisiones» en prosa libre, y se puso roja 29 veces + // contra cosas legítimas: los números de epígrafe (`### 1.1 Schema Map` → «1 Schema»), los + // ids de ADR (`ADR-0043 decision`) y los recuentos POR SCHEMA de una tabla de migraciones + // («5 tables» en la fila de un fichero concreto), que son ciertos y no tienen por qué + // sumar el total. Una guarda con ese ruido se desactiva en una semana, y entonces no + // vigila nada — es el mismo final que una guarda que sólo puede pasar. + // + // El `(? ficherosMarkdown(r)); + + if (ficheros.length === 0) abortar('el corpus vivo no rindió ningún .md — revisa CORPUS_VIVO'); + + for (const fichero of ficheros) { + const lineas = leer(fichero).split('\n'); + lineas.forEach((linea, i) => { + if (linea.includes(ESCAPE)) return; + for (const { patron, esperado, que } of afirmaciones) { + patron.lastIndex = 0; + let m; + while ((m = patron.exec(linea)) !== null) { + const declarado = Number(m[1]); + if (declarado !== esperado) { + hallazgos.push({ fichero, linea: i + 1, texto: m[0].trim(), que, declarado, esperado }); + } + } + } + }); + } + + return hallazgos; +} + +// ── Ejecución ──────────────────────────────────────────────────────────────── + +function abortar(mensaje) { + console.error(`FATAL: ${mensaje}`); + process.exit(2); +} + +async function inventario() { + return { + decisiones: derivarDecisiones(), + esquemas: derivarSchemas(), + robots: await derivarRobots(), + }; +} + +async function main() { + const modo = process.argv[2] ?? '--print'; + const inv = await inventario(); + + if (modo === '--print') { + console.log(JSON.stringify( + { + decisiones: inv.decisiones, + schemas: inv.esquemas.totalSchemas, + tablas: inv.esquemas.totalTablas, + porSchema: Object.fromEntries(inv.esquemas.schemas.map((s) => [s.schema, s.tablas.length])), + robots: inv.robots.map((r) => r.name), + }, + null, + 2, + )); + return; + } + + if (modo !== '--write' && modo !== '--check') { + abortar(`modo desconocido '${modo}' (usa --print, --write o --check)`); + } + + const derivas = []; + + for (const destino of destinos(inv)) { + const ruta = rutaDe(destino.fichero); + const actual = readFileSync(ruta, 'utf8'); + const esperado = reemplazarBloque(actual, destino.bloque, destino.cuerpo, destino.fichero); + + if (actual === esperado) continue; + + if (modo === '--write') { + writeFileSync(ruta, esperado, 'utf8'); + console.log(` actualizado ${relative(RAIZ, ruta)} [${destino.bloque}]`); + } else { + derivas.push({ fichero: destino.fichero, bloque: destino.bloque }); + } + } + + if (modo === '--write') { + console.log( + `\n ${inv.decisiones.total} decisiones (${inv.decisiones.primera}..${inv.decisiones.ultima}) · ` + + `${inv.esquemas.totalSchemas} schemas · ${inv.esquemas.totalTablas} tablas · ${inv.robots.length} robots`, + ); + return; + } + + const sueltas = cifrasAMano(inv); + + if (derivas.length > 0 || sueltas.length > 0) { + if (derivas.length > 0) { + console.error('\nDERIVA DOCUMENTAL — los bloques generados no coinciden con el código:\n'); + for (const d of derivas) { + console.error(` ✗ ${d.fichero} [${d.bloque}]`); + } + console.error('\n Arréglalo con: node .harness/scripts/doc-inventory.mjs --write'); + } + + if (sueltas.length > 0) { + console.error('\nCIFRAS A MANO — números escritos a mano que contradicen el código:\n'); + for (const s of sueltas) { + console.error( + ` ✗ ${s.fichero}:${s.linea} "${s.texto}" → ${s.que} reales: ${s.esperado}`, + ); + } + console.error( + '\n Reescribe la frase sin la cifra (o apunta al bloque generado). Si la línea narra\n' + + ` historia a propósito, márcala con ${ESCAPE}`, + ); + } + + console.error( + '\nValores reales derivados ahora mismo:\n' + + ` decisiones : ${inv.decisiones.total} (${inv.decisiones.primera}..${inv.decisiones.ultima})\n` + + ` schemas : ${inv.esquemas.totalSchemas}\n` + + ` tablas : ${inv.esquemas.totalTablas}\n` + + ` robots : ${inv.robots.length}\n`, + ); + process.exit(1); + } + + console.log( + `OK — inventario documental al día: ${inv.decisiones.total} decisiones · ` + + `${inv.esquemas.totalSchemas} schemas · ${inv.esquemas.totalTablas} tablas · ${inv.robots.length} robots`, + ); +} + +await main(); diff --git a/MASTER_INDEX.es.md b/MASTER_INDEX.es.md index 2be5dd16..b89cc7ae 100644 --- a/MASTER_INDEX.es.md +++ b/MASTER_INDEX.es.md @@ -36,7 +36,7 @@ Bienvenido a la documentación del **Evolith Tracker**, repositorio satélite de | [Tracker Target Architecture](./reference/specs/design/tracker-target-architecture.md) | — | TAD maestro: bounded contexts, domain model, layer structure, integrations | | [NestJS Backend Design](./reference/specs/design/tracker-nestjs-backend-design.md) | — | Arquitectura NestJS: módulos, CQRS, repositories, guards, middleware | | [React Frontend Design](./reference/specs/design/tracker-react-frontend-design.md) | — | Arquitectura React: state management, routing, permission-driven UI | -| [PostgreSQL Data Design](./reference/specs/design/tracker-postgresql-data-design.md) | — | Diseño de datos: 10 schemas, DDL, RLS, indexes, concurrency, migrations | +| [PostgreSQL Data Design](./reference/specs/design/tracker-postgresql-data-design.md) | — | Diseño de datos: mapa de schemas (derivado del código), DDL, RLS, indexes, concurrency, migrations | | [UMS Authentication Integration](./reference/specs/design/tracker-ums-authentication-integration.md) | — | Integración AuthN/AuthZ con UMS SaaS: JWT, JWKS, permission guards | | [UMS Authorization Graph Design](./reference/specs/design/tracker-ums-authorization-graph-design.md) | — | ACL transformation: UMS graph → Tracker permissions, role hierarchy | | [Core Integration Design](./reference/specs/design/tracker-core-integration-design.md) | — | Integración con Evolith Core: rulesets, artifact schemas, taxonomy, caching | diff --git a/MASTER_INDEX.md b/MASTER_INDEX.md index 8e24410c..67bc3c84 100644 --- a/MASTER_INDEX.md +++ b/MASTER_INDEX.md @@ -37,7 +37,7 @@ Bienvenido a la documentación del **Evolith Tracker**, repositorio satélite de | [Platform Implementation](./reference/specs/design/tracker-platform-implementation.md) | Reporte de implementación: Ondas 1-4 (Fundación, PostgreSQL, Gobernanza, Integración Core) | | [NestJS Backend Design](./reference/specs/design/tracker-nestjs-backend-design.md) | Arquitectura NestJS: módulos, CQRS, repositories, guards, middleware | | [React Frontend Design](./reference/specs/design/tracker-react-frontend-design.md) | Arquitectura React: state management, routing, permission-driven UI | -| [PostgreSQL Data Design](./reference/specs/design/tracker-postgresql-data-design.md) | Diseño de datos: 10 schemas, DDL, RLS, indexes, concurrency, migrations | +| [PostgreSQL Data Design](./reference/specs/design/tracker-postgresql-data-design.md) | Diseño de datos: mapa de schemas (derivado del código), DDL, RLS, indexes, concurrency, migrations | | [UMS Authentication Integration](./reference/specs/design/tracker-ums-authentication-integration.md) | Integración AuthN/AuthZ con UMS SaaS: JWT, JWKS, permission guards | | [UMS Authorization Graph Design](./reference/specs/design/tracker-ums-authorization-graph-design.md) | ACL transformation: UMS graph → Tracker permissions, role hierarchy | | [Core Integration Design](./reference/specs/design/tracker-core-integration-design.md) | Integración con Evolith Core: rulesets, artifact schemas, taxonomy, caching | @@ -73,7 +73,7 @@ Bienvenido a la documentación del **Evolith Tracker**, repositorio satélite de | Documento | Descripción | | :--- | :--- | | [Bounded Context Map (oficial)](./reference/specs/architecture/bounded-context-map.md) | **Mapa estratégico** de los 9 contexts: relaciones, patrones DDD, flujo de domain events | -| [Domain Model & E/R Overview](./reference/specs/architecture/tracker-domain-model-overview.md) | **Síntesis consolidada**: modelo conceptual, agregados, value objects y E/R de los 10 schemas | +| [Domain Model & E/R Overview](./reference/specs/architecture/tracker-domain-model-overview.md) | **Síntesis consolidada**: modelo conceptual, agregados, value objects y E/R de los schemas reales | | **Fase 0 — Strategic Intake** | | | [Intake Specifications](./reference/specs/intake/README.md) | Intake aggregate structures for Gate 0 | | [Phase 0 Intake Formats](./docs/artifacts/PHASE_0_INTAKE_FORMATS.md) | Manual UI templates and API JSON Schema contracts for Funnel 0 | diff --git a/README.es.md b/README.es.md index f53084e9..95e07662 100644 --- a/README.es.md +++ b/README.es.md @@ -7,7 +7,9 @@ [![Estado](https://img.shields.io/badge/Estado-Activo-brightgreen?style=for-the-badge)]() [![Plataforma](https://img.shields.io/badge/.NET_10_%7C_PostgreSQL_%7C_React_19-informational?style=for-the-badge)]() [![Arquitectura](https://img.shields.io/badge/Evolith-Satélite-blueviolet?style=for-the-badge)](https://github.com/beyondnetcode/evolith_arch32) -[![ADRs](https://img.shields.io/badge/ADRs-30_decisiones-orange?style=for-the-badge)](./DECISIONS.md) + +[![ADRs](https://img.shields.io/badge/ADRs-54_decisiones-orange?style=for-the-badge)](./DECISIONS.md) + [![Licencia](https://img.shields.io/badge/Licencia-Dual_License-informational?style=for-the-badge)](./LICENSE)
diff --git a/README.md b/README.md index 70ccc77b..8ec7169b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ [![Status](https://img.shields.io/badge/Status-Active-brightgreen?style=for-the-badge)]() [![Platform](https://img.shields.io/badge/.NET_10_%7C_PostgreSQL_%7C_React_19-informational?style=for-the-badge)]() [![Architecture](https://img.shields.io/badge/Evolith-Satellite_Product-blueviolet?style=for-the-badge)](https://github.com/beyondnetcode/evolith_arch32) -[![ADRs](https://img.shields.io/badge/ADRs-30_decisions-orange?style=for-the-badge)](./DECISIONS.md) + +[![ADRs](https://img.shields.io/badge/ADRs-54_decisions-orange?style=for-the-badge)](./DECISIONS.md) + [![License](https://img.shields.io/badge/License-Dual_License-informational?style=for-the-badge)](./LICENSE)
diff --git a/product/infra/helm/evolith-tracker-api/templates/configmap.yaml b/product/infra/helm/evolith-tracker-api/templates/configmap.yaml index 3c40bb4d..f097465a 100644 --- a/product/infra/helm/evolith-tracker-api/templates/configmap.yaml +++ b/product/infra/helm/evolith-tracker-api/templates/configmap.yaml @@ -20,3 +20,10 @@ data: Authentication__Ums__Issuer: {{ .Values.auth.ums.issuer | quote }} Authentication__Ums__RequireHttpsMetadata: {{ .Values.auth.ums.requireHttpsMetadata | quote }} Cors__Origins__0: {{ .Values.cors.origins | quote }} + {{- if .Values.otlp.endpoint }} + # T-049 / GT-616 — solo se emite cuando hay colector. Emitirlo vacio seria equivalente a no + # emitirlo (la app hace retorno temprano), pero dejaria una clave en el configmap que sugiere + # que las trazas estan configuradas cuando no lo estan. + Otlp__Endpoint: {{ .Values.otlp.endpoint | quote }} + Otlp__ServiceName: {{ .Values.otlp.serviceName | quote }} + {{- end }} diff --git a/product/infra/helm/evolith-tracker-api/values.yaml b/product/infra/helm/evolith-tracker-api/values.yaml index 01e6badc..5138b637 100644 --- a/product/infra/helm/evolith-tracker-api/values.yaml +++ b/product/infra/helm/evolith-tracker-api/values.yaml @@ -74,6 +74,23 @@ auth: cors: origins: "http://localhost:4200" +# T-049 / GT-616 — trazas distribuidas por push OTLP. +# +# `endpoint` VACIO = trazas apagadas, y ese sigue siendo el valor por defecto: `T-049` decidio no +# entregarlas encendidas porque un exportador que no alcanza a nadie reintenta para siempre y ata +# la disponibilidad del producto a la de un sumidero de telemetria. +# +# Lo que GT-616 arregla no es el valor por defecto sino que NO HABIA INTERRUPTOR: el configmap no +# emitia `Otlp__Endpoint` en absoluto, asi que un operador CON colector no tenia forma soportada de +# encenderlas — solo inyectarla a mano por `extraEnv`, que es exactamente el tipo de configuracion +# no declarada que este chart evita en todo lo demas. +# +# Ponlo al colector del cluster para encenderlas, p. ej.: +# otlp.endpoint=http://opentelemetry-collector.observability:4317 +otlp: + endpoint: "" + serviceName: evolith-tracker-api + # EF Core migrations: run as a pre-install/pre-upgrade Job using the embedded # `dotnet ef migrations bundle` shipped in the image at /app/efbundle/efbundle. migrations: diff --git a/reference/specs/architecture/README.es.md b/reference/specs/architecture/README.es.md index c3419a6a..ac0b2f01 100644 --- a/reference/specs/architecture/README.es.md +++ b/reference/specs/architecture/README.es.md @@ -21,7 +21,7 @@ | Documento | Descripción | Propósito | Tipo | |---|---|---|---| | [Bounded Context Map](./bounded-context-map.es.md) | Mapa estratégico oficial de los 9 contextos: cadena Customer-Supplier, Partnership, Conformist, ACLs y el flujo de eventos de dominio | Saber dónde termina la responsabilidad de cada contexto y quién depende de quién | DDD estratégico | -| [Visión del Modelo de Dominio y E/R](./tracker-domain-model-overview.es.md) | Síntesis consolidada: modelo conceptual, agregados, value objects y diseño E/R de los 10 schemas | Punto de entrada único a todo el diseño de dominio sin leer 9 modelos separados | Síntesis | +| [Visión del Modelo de Dominio y E/R](./tracker-domain-model-overview.es.md) | Síntesis consolidada: modelo conceptual, agregados, value objects y diseño E/R de los schemas reales | Punto de entrada único a todo el diseño de dominio sin leer 9 modelos separados | Síntesis | diff --git a/reference/specs/architecture/README.md b/reference/specs/architecture/README.md index bef74ac4..80adc86e 100644 --- a/reference/specs/architecture/README.md +++ b/reference/specs/architecture/README.md @@ -21,7 +21,7 @@ | Document | Description | Purpose | Type | |---|---|---|---| | [Bounded Context Map](./bounded-context-map.md) | Official strategic map of the 9 contexts: Customer-Supplier chain, Partnership, Conformist, ACLs, and the domain-event flow | Know where each context's responsibility ends and who depends on whom | Strategic DDD | -| [Domain Model & E/R Overview](./tracker-domain-model-overview.md) | Consolidated synthesis: conceptual model, aggregates, value objects, and E/R design across the 10 schemas | Single entry point to the whole domain design without reading 9 separate models | Synthesis | +| [Domain Model & E/R Overview](./tracker-domain-model-overview.md) | Consolidated synthesis: conceptual model, aggregates, value objects, and E/R design across the real schemas | Single entry point to the whole domain design without reading 9 separate models | Synthesis | diff --git a/reference/specs/architecture/c4-macro-topology-phase1.es.md b/reference/specs/architecture/c4-macro-topology-phase1.es.md index 909b2f7b..d7ea4a1f 100644 --- a/reference/specs/architecture/c4-macro-topology-phase1.es.md +++ b/reference/specs/architecture/c4-macro-topology-phase1.es.md @@ -113,7 +113,7 @@ C4Component } Container(api_gateway, "Governance API", "Nginx", "Llamadas entrantes (REST)") - ContainerDb(single_db, "Relational Database", "PostgreSQL", "10 schemas aislados") + ContainerDb(single_db, "Relational Database", "PostgreSQL", "schema-per-context (T-047)") Rel(api_gateway, discovery_module, "Ruta tráfico", "REST") Rel(api_gateway, integration_module, "Ruta tráfico", "REST") diff --git a/reference/specs/architecture/c4-macro-topology-phase1.md b/reference/specs/architecture/c4-macro-topology-phase1.md index cf51a233..6bfdf481 100644 --- a/reference/specs/architecture/c4-macro-topology-phase1.md +++ b/reference/specs/architecture/c4-macro-topology-phase1.md @@ -113,7 +113,7 @@ C4Component } Container(api_gateway, "Governance API", "Nginx", "Llamadas entrantes (REST)") - ContainerDb(single_db, "Relational Database", "PostgreSQL", "10 schemas aislados") + ContainerDb(single_db, "Relational Database", "PostgreSQL", "schema-per-context (T-047)") Rel(api_gateway, discovery_module, "Ruta tráfico", "REST") Rel(api_gateway, integration_module, "Ruta tráfico", "REST") diff --git a/reference/specs/architecture/scale-out-strategy.es.md b/reference/specs/architecture/scale-out-strategy.es.md index cecced3f..fb6324d6 100644 --- a/reference/specs/architecture/scale-out-strategy.es.md +++ b/reference/specs/architecture/scale-out-strategy.es.md @@ -6,7 +6,7 @@ Este documento detalla la ruta de migración desde el **Monolito de Despliegue ## 1. El Estado Inicial (Fase 1) - **Topología:** Un solo servicio Backend corriendo en un solo pod/servidor. -- **Base de Datos:** Un solo cluster físico segregado en 5 esquemas (`discovery_schema`, `qa_schema`, etc.). +- **Base de Datos:** Un solo cluster físico segregado por schema-per-context (el mapa vigente lo deriva `.harness/scripts/doc-inventory.mjs` en `reference/specs/design/tracker-postgresql-data-design.md` §1.1). - **Comunicación Interna:** Llamadas a interfaces a través del *In-Memory Event Bus* (CQRS nativo en memoria). - **Frontend:** Microfrontends ya segregados por dominio (aprobado en T-002). diff --git a/reference/specs/architecture/scale-out-strategy.md b/reference/specs/architecture/scale-out-strategy.md index 0fd83026..c900cc7b 100644 --- a/reference/specs/architecture/scale-out-strategy.md +++ b/reference/specs/architecture/scale-out-strategy.md @@ -6,7 +6,7 @@ Este documento detalla la ruta de migración desde el **Monolito de Despliegue ## 1. El Estado Inicial (Fase 1) - **Topología:** Un solo servicio Backend corriendo en un solo pod/servidor. -- **Base de Datos:** Un solo cluster físico segregado en 5 esquemas (`discovery_schema`, `qa_schema`, etc.). +- **Base de Datos:** Un solo cluster físico segregado por schema-per-context (the live map is derived by `.harness/scripts/doc-inventory.mjs` into `reference/specs/design/tracker-postgresql-data-design.md` §1.1). - **Comunicación Interna:** Llamadas a interfaces a través del *In-Memory Event Bus* (CQRS nativo en memoria). - **Frontend:** Microfrontends ya segregados por dominio (aprobado en T-002). diff --git a/reference/specs/architecture/tracker-domain-model-overview.es.md b/reference/specs/architecture/tracker-domain-model-overview.es.md index 439b20ef..0736e8b8 100644 --- a/reference/specs/architecture/tracker-domain-model-overview.es.md +++ b/reference/specs/architecture/tracker-domain-model-overview.es.md @@ -13,7 +13,7 @@ ## 1. Propósito -Este documento es la **vista única consolidada** del diseño de dominio de Evolith Tracker: el modelo conceptual, los 9 bounded contexts, sus agregados y value objects, y el diseño entidad-relación a través de los 10 schemas PostgreSQL (T-008). +Este documento es la **vista única consolidada** del diseño de dominio de Evolith Tracker: el modelo conceptual, los 9 bounded contexts, sus agregados y value objects, y el diseño entidad-relación a través de los schemas PostgreSQL reales (`T-047`; el mapa vigente lo deriva del código `.harness/scripts/doc-inventory.mjs` y vive en `reference/specs/design/tracker-postgresql-data-design.es.md` §1.1). Sintetiza — nunca reemplaza — las fuentes canónicas: @@ -139,7 +139,7 @@ Toda tabla (según [PostgreSQL Data Design §2](../design/tracker-postgresql-dat - Concurrencia optimista vía `xmin` (columna `version` explícita donde se requiera) - **Sin foreign keys entre schemas.** Las referencias entre contextos son UUIDs validados en la capa de aplicación — preservando la autonomía de cada contexto y habilitando la extracción a servicios en Fase 2. -### 6.2 Cadena Core (5 schemas de compuertas) +### 6.2 Cadena Core (5 schemas de compuertas) Líneas sólidas = FK intra-schema. Líneas punteadas = referencia UUID cross-schema (sin FK). diff --git a/reference/specs/design/README.es.md b/reference/specs/design/README.es.md index cbea41f9..9310dbee 100644 --- a/reference/specs/design/README.es.md +++ b/reference/specs/design/README.es.md @@ -32,7 +32,7 @@ | Documento | Descripción | Propósito | Tipo | |---|---|---|---| -| [Modelo de Dominio Consolidado y E/R](../architecture/tracker-domain-model-overview.es.md) | El diseño DDD único y consolidado: modelo conceptual propuesto, los 9 bounded contexts, agregados, value objects y el diseño E/R de los 10 schemas | Fuente única del diseño de dominio propuesto — el detalle táctico por contexto se navega desde aquí | DDD | +| [Modelo de Dominio Consolidado y E/R](../architecture/tracker-domain-model-overview.es.md) | El diseño DDD único y consolidado: modelo conceptual propuesto, los 9 bounded contexts, agregados, value objects y el diseño E/R de los schemas | Fuente única del diseño de dominio propuesto — el detalle táctico por contexto se navega desde aquí | DDD | @@ -46,7 +46,7 @@ | [Arquitectura Objetivo (TAD)](./tracker-target-architecture.es.md) | Arquitectura técnica maestra: bounded contexts, modelo de dominio, CQRS, capas, despliegue | Referencia única para entender cómo se construye toda la aplicación Tracker | Arquitectura | | [Diseño Backend NestJS](./tracker-nestjs-backend-design.es.md) | Blueprint del backend: módulos, buses CQRS, repositorios, guards, middleware | Guiar a los desarrolladores backend al implementar cualquier bounded context | Backend | | [Diseño Frontend React](./tracker-react-frontend-design.es.md) | Blueprint del frontend: shell Module Federation + 7 remotes, estado Zustand/TanStack, routing | Guiar a los desarrolladores frontend al construir o extender un microfrontend | Frontend | -| [Diseño de Datos PostgreSQL](./tracker-postgresql-data-design.es.md) | Diseño físico de datos: 10 schemas, DDL completo, políticas RLS, índices, control de concurrencia | Implementar o migrar la base de datos exactamente como fue aprobada | Datos | +| [Diseño de Datos PostgreSQL](./tracker-postgresql-data-design.es.md) | Diseño físico de datos: mapa de schemas derivado, DDL completo, políticas RLS, índices, control de concurrencia | Implementar o migrar la base de datos exactamente como fue aprobada | Datos | diff --git a/reference/specs/design/README.md b/reference/specs/design/README.md index 0323af35..c3e978ab 100644 --- a/reference/specs/design/README.md +++ b/reference/specs/design/README.md @@ -32,7 +32,7 @@ | Document | Description | Purpose | Type | |---|---|---|---| -| [Consolidated Domain Model & E/R](../architecture/tracker-domain-model-overview.md) | The single consolidated DDD design: proposed conceptual model, the 9 bounded contexts, aggregates, value objects, and the E/R design across the 10 schemas | Single source for the proposed domain design — per-context tactical detail is navigated from here | DDD | +| [Consolidated Domain Model & E/R](../architecture/tracker-domain-model-overview.md) | The single consolidated DDD design: proposed conceptual model, the 9 bounded contexts, aggregates, value objects, and the E/R design across the schemas | Single source for the proposed domain design — per-context tactical detail is navigated from here | DDD | @@ -46,7 +46,7 @@ | [Target Architecture (TAD)](./tracker-target-architecture.md) | Master technical architecture: bounded contexts, domain model, CQRS, layers, deployment | Single reference to understand how the whole Tracker application is built | Architecture | | [NestJS Backend Design](./tracker-nestjs-backend-design.md) | Backend blueprint: module layout, CQRS buses, repositories, guards, middleware | Guide backend developers implementing any bounded context | Backend | | [React Frontend Design](./tracker-react-frontend-design.md) | Frontend blueprint: Module Federation shell + 7 remotes, Zustand/TanStack state, routing | Guide frontend developers building or extending a microfrontend | Frontend | -| [PostgreSQL Data Design](./tracker-postgresql-data-design.md) | Physical data design: 10 schemas, full DDL, RLS policies, indexes, concurrency control | Implement or migrate the database exactly as approved | Data | +| [PostgreSQL Data Design](./tracker-postgresql-data-design.md) | Physical data design: derived schema map, full DDL, RLS policies, indexes, concurrency control | Implement or migrate the database exactly as approved | Data | diff --git a/reference/specs/design/tracker-implementation-roadmap.es.md b/reference/specs/design/tracker-implementation-roadmap.es.md index ee5570cf..494930c8 100644 --- a/reference/specs/design/tracker-implementation-roadmap.es.md +++ b/reference/specs/design/tracker-implementation-roadmap.es.md @@ -69,7 +69,7 @@ Establecer el monorepo, pipeline CI/CD, primitivas de dominio compartidas e infr - [ ] Bootstrap de app API NestJS con TypeScript strict mode - [ ] App Web React con Vite + TanStack Query + Zustand - [ ] `libs/domain` compartido con clases base (`AggregateRoot`, `BaseDomainEvent`, `Result`) -- [ ] 10 esquemas PostgreSQL creados con migraciones DDL +- [ ] Esquemas PostgreSQL creados con migraciones DDL (el mapa vigente lo deriva `.harness/scripts/doc-inventory.mjs` en `reference/specs/design/tracker-postgresql-data-design.md` §1.1) - [ ] Políticas RLS configuradas para multi-tenancy - [ ] Pipeline CI GitHub Actions (lint, typecheck, test) - [ ] Docker Compose para desarrollo local diff --git a/reference/specs/design/tracker-platform-implementation.es.md b/reference/specs/design/tracker-platform-implementation.es.md index 8a2bd426..7dce9a36 100644 --- a/reference/specs/design/tracker-platform-implementation.es.md +++ b/reference/specs/design/tracker-platform-implementation.es.md @@ -109,7 +109,7 @@ Se crearon 7 archivos de migración en `src/apps/tracker-api/src/migrations/`: | Migración | Schemas/Tablas | Características Clave | |-----------|---------------|----------------------| -| `0001-create-schemas.sql` | 10 schemas | `tracker_discovery`, `tracker_design`, `tracker_construction`, `tracker_qa`, `tracker_release`, `tracker_governance`, `tracker_artifacts`, `tracker_metrics`, `tracker_integration`, `tracker_audit` | +| `0001-create-schemas.sql` | schemas | `tracker_discovery`, `tracker_design`, `tracker_construction`, `tracker_qa`, `tracker_release`, `tracker_governance`, `tracker_artifacts`, `tracker_metrics`, `tracker_integration`, `tracker_audit` | | `0002-create-governance-tables.sql` | 5 tablas | `satellite_products`, `sdlc_executions`, `phase_gate_states`, `exception_requests`, `outbox_messages` | | `0003-create-artifacts-tables.sql` | 3 tablas | `artifact_definitions`, `artifact_instances`, `evidence_records` | | `0004-create-integration-tables.sql` | 4 tablas | `core_evaluation_transactions`, `integration_endpoints`, `sync_records`, `acl_adapters` | diff --git a/reference/specs/design/tracker-platform-implementation.md b/reference/specs/design/tracker-platform-implementation.md index 4bc8bfbe..b9c38e0f 100644 --- a/reference/specs/design/tracker-platform-implementation.md +++ b/reference/specs/design/tracker-platform-implementation.md @@ -109,7 +109,7 @@ Created 7 migration files in `src/apps/tracker-api/src/migrations/`: | Migration | Schemas/Tables | Key Features | |-----------|---------------|--------------| -| `0001-create-schemas.sql` | 10 schemas | `tracker_discovery`, `tracker_design`, `tracker_construction`, `tracker_qa`, `tracker_release`, `tracker_governance`, `tracker_artifacts`, `tracker_metrics`, `tracker_integration`, `tracker_audit` | +| `0001-create-schemas.sql` | schemas | `tracker_discovery`, `tracker_design`, `tracker_construction`, `tracker_qa`, `tracker_release`, `tracker_governance`, `tracker_artifacts`, `tracker_metrics`, `tracker_integration`, `tracker_audit` | | `0002-create-governance-tables.sql` | 5 tables | `satellite_products`, `sdlc_executions`, `phase_gate_states`, `exception_requests`, `outbox_messages` | | `0003-create-artifacts-tables.sql` | 3 tables | `artifact_definitions`, `artifact_instances`, `evidence_records` | | `0004-create-integration-tables.sql` | 4 tables | `core_evaluation_transactions`, `integration_endpoints`, `sync_records`, `acl_adapters` | diff --git a/reference/specs/design/tracker-postgresql-data-design.es.md b/reference/specs/design/tracker-postgresql-data-design.es.md index 9ddb390c..afcf316f 100644 --- a/reference/specs/design/tracker-postgresql-data-design.es.md +++ b/reference/specs/design/tracker-postgresql-data-design.es.md @@ -16,18 +16,31 @@ ### 1.1 Mapa de Schemas -| Schema | Bounded Context | Módulo Propietario | Propósito | -|--------|----------------|--------------|---------| -| `tracker_discovery` | Discovery | `@evolith/tracker-discovery` | Aggregates Initiative, Backlog, Epic, UserStory | -| `tracker_design` | Design | `@evolith/tracker-design` | TechnicalBlueprint, Contract, ADR, DataSchema | -| `tracker_construction` | Construction | `@evolith/tracker-construction` | ImplementationCycle, TechnicalStory, PeerReview | -| `tracker_qa` | QA | `@evolith/tracker-qa` | TestCycle, TestExecution, Defect | -| `tracker_release` | Release | `@evolith/tracker-release` | ReleasePackage, DeploymentRecord, Environment | -| `tracker_governance` | Governance | `@evolith/tracker-governance` | SatelliteProduct, SDLCExecution, PhaseGateState, ExceptionRequest | -| `tracker_artifacts` | Artifacts | `@evolith/tracker-artifacts` | ArtifactDefinition, ArtifactInstance, EvidenceRecord | -| `tracker_metrics` | Metrics | `@evolith/tracker-metrics` | ScorecardDefinition, MetricSnapshot, DriftAlert | -| `tracker_integration` | Integration | `@evolith/tracker-integration` | IntegrationEndpoint, SyncRecord, ACLAdapter | -| `tracker_audit` | Audit | `@evolith/tracker-audit` | AuditEntry (append-only) | + +**8 schemas · 49 tablas.** Derivado de los `ModelSnapshot` de EF Core, que son lo que la base tiene de verdad. + +| Schema | Contexto EF | Tablas | Tablas (derivadas del snapshot de EF) | +|--------|------------|--------|---------------------------------------| +| `masterdata` | `TenantProjectionDbContext` | 4 | `InboxState`, `OutboxMessage`, `OutboxState`, `tenant_projection` | +| `tracker_construction` | `TrackerDbContext` | 1 | `peer_reviews` | +| `tracker_geo` | `TrackerDbContext` | 13 | `catalog_release`, `country`, `currency`, `exchange_rate`, `language`, `locale_profile`, `location`, `location_code`, `location_i18n`, `location_level`, `tax_rate`, `tax_scheme`, `time_zone` | +| `tracker_governance` | `TrackerDbContext` | 21 | `artifact_field_schemas`, `audit_entries`, `core_evaluation_transactions`, `evidence_records`, `exception_requests`, `gate_decisions`, `gate_policies`, `gate_submissions`, `initiatives`, `opportunities`, `phase_artifacts`, `phase_gate_states`, `phase_progressions`, `products`, `provider_connections`, `publication_outbox`, `runtime_approvals`, `sdlc_executions`, `technical_blueprints`, `tenant_intelligence`, `tenants` | +| `tracker_intake` | `TrackerDbContext` | 5 | `intake_dead_letter`, `intake_outbox`, `ppm_endpoints`, `strategic_baselines`, `strategic_initiatives` | +| `tracker_metrics` | `TrackerDbContext` | 1 | `phase_sla_policies` | +| `tracker_qa` | `TrackerDbContext` | 3 | `defects`, `test_cycles`, `test_executions` | +| `tracker_release` | `TrackerDbContext` | 1 | `deployment_records` | + + +> **Qué es el resto de este documento (GT-617).** El mapa de arriba lo DERIVA de los +> `ModelSnapshot` de EF Core el script `.harness/scripts/doc-inventory.mjs` en cada corrida; +> es la topología embarcada, ratificada por +> [`T-047`](../../../docs/adrs/T-047-consolidated-schema-topology.es.md). Las **secciones 3–12 +> son el diseño OBJETIVO original del 2026-06-07** y se conservan sólo por trazabilidad: varios +> de los schemas que detallan (`tracker_discovery`, `tracker_design`, `tracker_artifacts`, +> `tracker_integration`, `tracker_audit`) nunca se crearon, y sus agregados viven en +> `tracker_governance`. Léelas como intención, jamás como descripción de la base. El bloque +> generado es la única parte de este fichero que describe la realidad, y la única que mantiene +> un script. ### 1.2 Estrategia de Referencia Cross-Schema diff --git a/reference/specs/design/tracker-postgresql-data-design.md b/reference/specs/design/tracker-postgresql-data-design.md index 0b6f8209..6164e00c 100644 --- a/reference/specs/design/tracker-postgresql-data-design.md +++ b/reference/specs/design/tracker-postgresql-data-design.md @@ -16,18 +16,30 @@ ### 1.1 Schema Map -| Schema | Bounded Context | Owner Module | Purpose | -|--------|----------------|--------------|---------| -| `tracker_discovery` | Discovery | `@evolith/tracker-discovery` | Initiative, Backlog, Epic, UserStory aggregates | -| `tracker_design` | Design | `@evolith/tracker-design` | TechnicalBlueprint, Contract, ADR, DataSchema | -| `tracker_construction` | Construction | `@evolith/tracker-construction` | ImplementationCycle, TechnicalStory, PeerReview | -| `tracker_qa` | QA | `@evolith/tracker-qa` | TestCycle, TestExecution, Defect | -| `tracker_release` | Release | `@evolith/tracker-release` | ReleasePackage, DeploymentRecord, Environment | -| `tracker_governance` | Governance | `@evolith/tracker-governance` | SatelliteProduct, SDLCExecution, PhaseGateState, ExceptionRequest | -| `tracker_artifacts` | Artifacts | `@evolith/tracker-artifacts` | ArtifactDefinition, ArtifactInstance, EvidenceRecord | -| `tracker_metrics` | Metrics | `@evolith/tracker-metrics` | ScorecardDefinition, MetricSnapshot, DriftAlert | -| `tracker_integration` | Integration | `@evolith/tracker-integration` | IntegrationEndpoint, SyncRecord, ACLAdapter | -| `tracker_audit` | Audit | `@evolith/tracker-audit` | AuditEntry (append-only) | + +**8 schemas · 49 tables.** Derived from the EF Core `ModelSnapshot` files, which are what the database actually has. + +| Schema | EF context | Tables | Tables (derived from the EF snapshot) | +|--------|------------|--------|---------------------------------------| +| `masterdata` | `TenantProjectionDbContext` | 4 | `InboxState`, `OutboxMessage`, `OutboxState`, `tenant_projection` | +| `tracker_construction` | `TrackerDbContext` | 1 | `peer_reviews` | +| `tracker_geo` | `TrackerDbContext` | 13 | `catalog_release`, `country`, `currency`, `exchange_rate`, `language`, `locale_profile`, `location`, `location_code`, `location_i18n`, `location_level`, `tax_rate`, `tax_scheme`, `time_zone` | +| `tracker_governance` | `TrackerDbContext` | 21 | `artifact_field_schemas`, `audit_entries`, `core_evaluation_transactions`, `evidence_records`, `exception_requests`, `gate_decisions`, `gate_policies`, `gate_submissions`, `initiatives`, `opportunities`, `phase_artifacts`, `phase_gate_states`, `phase_progressions`, `products`, `provider_connections`, `publication_outbox`, `runtime_approvals`, `sdlc_executions`, `technical_blueprints`, `tenant_intelligence`, `tenants` | +| `tracker_intake` | `TrackerDbContext` | 5 | `intake_dead_letter`, `intake_outbox`, `ppm_endpoints`, `strategic_baselines`, `strategic_initiatives` | +| `tracker_metrics` | `TrackerDbContext` | 1 | `phase_sla_policies` | +| `tracker_qa` | `TrackerDbContext` | 3 | `defects`, `test_cycles`, `test_executions` | +| `tracker_release` | `TrackerDbContext` | 1 | `deployment_records` | + + +> **What the rest of this document is (GT-617).** The map above is derived from the EF Core +> model snapshots on every run of `.harness/scripts/doc-inventory.mjs`; it is the shipped +> topology, ratified by [`T-047`](../../../docs/adrs/T-047-consolidated-schema-topology.md). +> Sections **3–12 below are the original 2026-06-07 TARGET design** and are kept for +> traceability only — several of the schemas they detail (`tracker_discovery`, +> `tracker_design`, `tracker_artifacts`, `tracker_integration`, `tracker_audit`) were never +> created, and their aggregates live in `tracker_governance` instead. Read them as intent, +> never as a description of the database. The generated block is the only part of this file +> that describes reality, and it is the only part a script maintains. ### 1.2 Cross-Schema Reference Strategy diff --git a/reference/specs/design/tracker-target-architecture.es.md b/reference/specs/design/tracker-target-architecture.es.md index 20d2ccf2..af0b5820 100644 --- a/reference/specs/design/tracker-target-architecture.es.md +++ b/reference/specs/design/tracker-target-architecture.es.md @@ -951,7 +951,7 @@ ingress: | Re-Do Flow | PRD UC-005, Release DDD | Regla de contingencia | `ReDoFlowEngine` | | Generación de evidencia | PRD Estándar de artefactos | Spec de artefacto de evidencia | Aggregate `EvidenceRecord` | | Sincronización con Core | PRD Governance | Versionado de Core | `CoreIntegrationService` | -| PostgreSQL multi-schema | C4 Topology, este documento | Schema-por-dominio | 10 schemas PostgreSQL | +| PostgreSQL multi-schema | C4 Topology, este documento | Schema-por-dominio | Schemas PostgreSQL (el mapa vigente lo deriva `.harness/scripts/doc-inventory.mjs` en `reference/specs/design/tracker-postgresql-data-design.md` §1.1) | | OpenAPI contract-first | PRD EPIC-001 | Estándar de API | Specs OpenAPI 3.0 por módulo | --- diff --git a/reference/specs/infrastructure/tracker-cicd-spec.es.md b/reference/specs/infrastructure/tracker-cicd-spec.es.md index dc0f033e..a86d8bbf 100644 --- a/reference/specs/infrastructure/tracker-cicd-spec.es.md +++ b/reference/specs/infrastructure/tracker-cicd-spec.es.md @@ -127,7 +127,7 @@ Per [TAD §27](../design/tracker-target-architecture.md), el deployment apunta a |------------|------------| | `tracker-api` | Monolito NestJS; ≥2 réplicas; HPA en CPU/latencia ⊕ | | `tracker-web` | Shell Host + remotes (estático, servido vía CDN/ingress) | -| PostgreSQL | Instancia gestionada (cloud) o Helm subchart (non-prod); 10 schemas `tracker_*` | +| PostgreSQL | Instancia gestionada (cloud) o Helm subchart (non-prod); schemas `tracker_*` | | Redis ⊕ | Solo si se confirma el requisito de caching de Core-ruleset (GAP-021) | | OTel Collector | Sidecar/daemonset para traces (TAD §24) | | Ingress | TLS (cert-manager), security headers ⊕ (SEC-D6) | diff --git a/robosoft/README.md b/robosoft/README.md index b49b474b..deaf5b7f 100644 --- a/robosoft/README.md +++ b/robosoft/README.md @@ -59,11 +59,24 @@ fails the run; a failed **soft** check is a warning only. ## Robots + +**12 robots.** Derived from `robosoft/robots/*.robot.mjs` — the same discovery `robosoft/run.mjs` performs, so this table cannot disagree with what a run executes. + | Robot | Proves | |-------|--------| -| `core-integration` | Every Tracker↔Core interface: capabilities manifest, rulesets, MCP tools list + call, a real advisory `evaluate` round-trip, and `/assistant/converse`. | -| _(next)_ `governance-journey` | A tenant drives the full SDLC funnel (opportunity → discovery → design → construction → qa → release), each gate asserted, with a real Core verdict at the Design gate. Will reuse the flow proven in `seed/e2e-governance.mjs`. | -| _(next)_ `provider-connections` | Register / connect / publish a ProviderConnection and assert the outbound custom-check delivery. | +| `audit-trail` | Assert governed actions are recorded, attributed to the authenticated actor, and tenant-scoped (BR-009). | +| `auth-perimeter` | Prove the CLOSED (ums) perimeter enforces: 401 anonymous, 2xx valid, 403 under-privileged, 401 branch/forged. | +| `core-integration` | Assert every Tracker↔Core interface against the live stack (REST + MCP + /converse). | +| `exception-governance` | Drive the exception-request lifecycle (submit→pending→decide→approved) and assert authority + audit (CD-20/21). | +| `gate-enforcement` | Prove the discovery gate REFUSES to approve until evidence AND approval are present. | +| `governance-journey` | Drive one tenant initiative through the full SDLC funnel, asserting every gate. | +| `intake` | Drive the pre-discovery intake funnel (handshake → feasibility → promote) and assert each transition. | +| `provider-connections` | Drive the ProviderConnection lifecycle (register→secret→acl→connect→disconnect) and assert its state machine. | +| `qa-quality-gate` | Prove the QA gate stops authorizing while a blocking defect is open, and clears once it is closed. | +| `runtime-approvals` | Drive the CD-23 HITL flow: machine submits, only an authorized human may approve, rejection is recorded. | +| `scorecard` | Validate the executive scorecard surface: phase-SLA round-trip + a well-formed per-initiative lamina (GT-469). | +| `tenant-isolation` | Two tenant personas: assert tenant B never sees or mutates tenant A's data (BR-006). | + ## Running against the live stack diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GateDecisionEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GateDecisionEndpoints.cs index 76e39ea7..ac58f688 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GateDecisionEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GateDecisionEndpoints.cs @@ -3,6 +3,7 @@ using Tracker.Application.Governance.GateDecision.DTOs; using Tracker.Application.Governance.GateDecision.Queries.GetGateDecision; using Tracker.Domain.Governance.GateDecision; +using Tracker.Presentation.Observability; namespace Tracker.Presentation.Endpoints.Governance; @@ -74,6 +75,14 @@ public static void MapGateDecisionEndpoints(this IEndpointRouteBuilder app) statusCode: StatusCodes.Status403Forbidden); } + // GT-616 — el span de dominio. Decidir una compuerta es EL acto de gobierno de este + // producto: quien lo investiga despues quiere filtrar por inquilino, actor y desenlace, + // no por latencia de ruta. Sin listener esto es `null` y no cuesta nada. + using var traza = TrackerTracing.StartGovernanceActivity( + "governance.gate_decision.decide", + user.TenantId, + user.ActorId); + var result = await mediator.Send(new DecideGateDecisionCommand( user.TenantId, id, @@ -88,6 +97,8 @@ public static void MapGateDecisionEndpoints(this IEndpointRouteBuilder app) // CD-21: las autoridades del actor son lo que la `ExceptionPolicy` del gate // contrasta. Sin esto la politica no tenia contra que decidir. user.Authorities.ToList()), ct); + + traza.SetGovernanceOutcome(result.IsSuccess ? request.Status : "rejected_by_policy"); return result.ToNoContent(); }) .RequireTrackerPermission(TrackerPermissions.GateDecisionDecide) diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/AssistantEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/AssistantEndpoints.cs index bf5f8b33..d9719a15 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/AssistantEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/AssistantEndpoints.cs @@ -1,3 +1,5 @@ +using Tracker.Presentation.Observability; + namespace Tracker.Presentation.Endpoints.Integration; /// @@ -18,12 +20,31 @@ public static void MapAssistantEndpoints(this IEndpointRouteBuilder app) IAgentRuntimeGateway gateway, CancellationToken ct) => { + // GT-616 — el turno de agente es el tercer punto que una traza tiene que poder + // responder: QUE agente actuo, sobre que iniciativa y en que fase. `agent-runtime` es + // el nombre del componente que ejecuta el turno; el motor concreto es deliberadamente + // opaco para el Tracker (la superficie es engine-agnostica, ver el resumen de arriba). + using var traza = TrackerTracing.StartGovernanceActivity( + "governance.assistant.converse", + user.TenantId, + user.ActorId, + phase: request.Context?.Phase, + agent: "agent-runtime"); + + if (!string.IsNullOrWhiteSpace(request.Context?.InitiativeId)) + { + traza?.SetTag(GovernanceTraceTags.InitiativeId, request.Context.InitiativeId); + } + try { - return Results.Ok(await gateway.ConverseAsync(request, user, ct)); + var respuesta = await gateway.ConverseAsync(request, user, ct); + traza.SetGovernanceOutcome("ok"); + return Results.Ok(respuesta); } catch (AgentRuntimeGatewayException ex) { + traza.SetGovernanceOutcome(ex.Code); return Results.Json( new { code = ex.Code, message = ex.Message }, statusCode: ex.StatusCode); diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationEndpoints.cs index ec2d084e..aa67a6fe 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationEndpoints.cs @@ -1,4 +1,5 @@ using Tracker.Domain.Integration.CoreEvaluationTransaction; +using Tracker.Presentation.Observability; namespace Tracker.Presentation.Endpoints.Integration; @@ -17,8 +18,27 @@ public static void MapCoreEvaluationEndpoints(this IEndpointRouteBuilder app) IUnitOfWork unitOfWork, CancellationToken ct) => { + // GT-616 — span de dominio sobre la evaluacion del Core. Es la unica operacion del + // Tracker que cruza la frontera hacia otro producto, asi que es la que mas se investiga + // cuando algo no cuadra; el span de `HttpClient` que ya generaba la instrumentacion + // dice la URL y el codigo, y no dice ni el inquilino ni la fase ni el veredicto. + using var traza = TrackerTracing.StartGovernanceActivity( + "governance.core.evaluate", + user.TenantId, + user.ActorId, + phase: request.Phase); + + // `InitiativeId` viaja como cadena en este contrato (no es un Guid del Tracker sino la + // referencia que el Core recibe), asi que se etiqueta aparte del ayudante tipado. + if (!string.IsNullOrWhiteSpace(request.InitiativeId)) + { + traza?.SetTag(GovernanceTraceTags.InitiativeId, request.InitiativeId); + } + var result = await gateway.EvaluateAsync(request, user, ct); + traza.SetGovernanceOutcome(result.Status == "FAILED" ? "failed" : result.ResultDecision); + // Persist the transaction as the Tracker's own audit trail (the Core is // stateless; the Tracker owns state). var transaction = CoreEvaluationTransaction.Create( diff --git a/src/apps/tracker-api/Tracker.Presentation/Observability/TrackerTracing.cs b/src/apps/tracker-api/Tracker.Presentation/Observability/TrackerTracing.cs index 69c2525a..f7671f20 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Observability/TrackerTracing.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Observability/TrackerTracing.cs @@ -25,9 +25,14 @@ public sealed class TracingOptions /// Endpoint del colector OTLP. **Vacío = trazas desactivadas**, y ése es el valor por defecto. /// /// Entregarlo encendido haría que cada despliegue intentara alcanzar un colector que en - /// la mayoría de entornos no existe — fabricando justo el acoplamiento que `T-049` rechaza. - /// `.env.example` ya declaraba `OTEL_EXPORTER_OTLP_ENDPOINT` sin ningún consumidor; ésta es la - /// variable que por fin lo tiene. + /// la mayoría de entornos no existe — fabricando justo el acoplamiento que `T-049` rechaza. + /// + /// GT-616. El comentario anterior afirmaba que `OTEL_EXPORTER_OTLP_ENDPOINT` —la + /// variable que `.env.example:40` lleva declarando desde siempre— era «la variable que por fin + /// tiene consumidor». No lo era: el enlace de configuración de ASP.NET traduce `Otlp__Endpoint`, + /// no `OTEL_EXPORTER_OTLP_ENDPOINT`, así que ponerla no encendía nada y el retorno temprano + /// seguía disparando. La resuelve ahora , que la + /// acepta como respaldo — es la convención de OpenTelemetry y ya estaba escrita en el `.env`. /// public string? Endpoint { get; set; } @@ -35,6 +40,31 @@ public sealed class TracingOptions public string ServiceName { get; set; } = "evolith-tracker-api"; } +/// +/// GT-616 — los atributos que hacen que una traza responda una pregunta de GOBIERNO. +/// +/// Antes de esta ficha el Tracker registraba una +/// propia y no la usaba NUNCA: no había una sola llamada a StartActivity en todo el +/// repositorio (verificado por búsqueda). Con endpoint configurado el backend habría recibido sólo +/// spans de ASP.NET y de HttpClient — «`POST /api/v1/gate-decisions/{id}/decide` tardó +/// 180 ms». Eso es fontanería. La pregunta que este producto necesita poder hacerle a una traza es +/// «¿qué inquilino decidió qué compuerta, de qué iniciativa, con qué desenlace, y quién —humano o +/// agente— la empujó?». +/// +/// Los nombres viven en constantes y no en literales sueltos porque un atributo mal escrito +/// no falla: se ingiere igual y crea una dimensión huérfana que nadie consulta. La misma razón por +/// la que el prefijo `tracker_` de las métricas tiene guarda. +/// +public static class GovernanceTraceTags +{ + public const string TenantId = "evolith.tenant.id"; + public const string ActorId = "evolith.actor.id"; + public const string InitiativeId = "evolith.initiative.id"; + public const string Phase = "evolith.sdlc.phase"; + public const string Agent = "evolith.agent.name"; + public const string Outcome = "evolith.governance.outcome"; +} + public static class TrackerTracing { /// @@ -43,16 +73,53 @@ public static class TrackerTracing /// public static readonly System.Diagnostics.ActivitySource Source = new("Evolith.Tracker"); + /// + /// GT-616 — nombre de la variable de entorno estandar de OpenTelemetry, aceptada como respaldo. + /// + public const string OtelEndpointEnvVar = "OTEL_EXPORTER_OTLP_ENDPOINT"; + + /// + /// Resuelve el endpoint del colector a partir de las DOS fuentes que un operador puede razonar: + /// la seccion `Otlp:Endpoint` (que el chart rellena) y la variable estandar + /// `OTEL_EXPORTER_OTLP_ENDPOINT` (que `.env.example` ya declaraba). La seccion gana: es la + /// especifica del despliegue. + /// + /// Antes solo existia la primera, de modo que un operador que ponia la variable estandar + /// —la unica documentada en el `.env`— obtenia silencio. Un interruptor que existe, esta + /// documentado y no hace nada es peor que no tenerlo: consume el presupuesto de diagnostico de + /// quien lo prueba. + /// + internal static string? ResolveEndpoint(TracingOptions options, IConfiguration configuration) + { + if (!string.IsNullOrWhiteSpace(options.Endpoint)) + { + return options.Endpoint.Trim(); + } + + // Se lee de la CONFIGURACION, no de `Environment.GetEnvironmentVariable`: el host ya monta + // `AddEnvironmentVariables()`, asi que la variable llega igual, y de este modo la resolucion + // no depende de estado ambiente del proceso — que en una suite de pruebas es una fuente de + // verdes y rojos que no reproducen. + var deEntorno = configuration[OtelEndpointEnvVar]; + + return string.IsNullOrWhiteSpace(deEntorno) ? null : deEntorno.Trim(); + } + public static IServiceCollection AddTrackerTracing( this IServiceCollection services, IConfiguration configuration) { var options = new TracingOptions(); configuration.GetSection(TracingOptions.SectionName).Bind(options); + var endpoint = ResolveEndpoint(options, configuration); + // Sin endpoint no se registra NADA. No es una optimizacion: registrar el pipeline con un // exportador que no puede exportar produce reintentos y ruido de fondo permanente en - // entornos donde nadie pidio trazas. - if (string.IsNullOrWhiteSpace(options.Endpoint)) + // entornos donde nadie pidio trazas. `T-049` lo ratifico y esta ficha NO lo revierte: lo + // que GT-616 arregla es que (a) el chart no ofrecia ninguna forma soportada de encenderlo, + // (b) la variable de entorno documentada no lo encendia, y (c) encendido no habia un solo + // span de dominio que mirar. + if (string.IsNullOrWhiteSpace(endpoint)) { return services; } @@ -75,8 +142,75 @@ public static IServiceCollection AddTrackerTracing( }; }) .AddHttpClientInstrumentation() - .AddOtlpExporter(o => o.Endpoint = new Uri(options.Endpoint))); + .AddOtlpExporter(o => o.Endpoint = new Uri(endpoint))); return services; } + + /// + /// GT-616 — abre un span de DOMINIO alrededor de una operación de gobierno, etiquetado con lo + /// que hace falta para responder una pregunta de gobierno y no una de fontanería. + /// + /// Devuelve null cuando no hay nadie escuchando (trazas apagadas), que es el caso + /// por defecto y no cuesta nada: + /// no asigna nada sin listeners. Por eso los puntos de llamada pueden instrumentarse + /// incondicionalmente en vez de esconderse tras un if que alguien olvidaría de mantener. + /// + /// El identificador de inquilino se etiqueta SIEMPRE: sin él una traza no se puede atribuir + /// y, en un producto multi-inquilino, una traza no atribuible es a la vez inútil y un riesgo — + /// mezcla en el mismo panel actividad de clientes distintos. + /// + public static System.Diagnostics.Activity? StartGovernanceActivity( + string nombre, + Guid tenantId, + Guid? actorId = null, + Guid? initiativeId = null, + string? phase = null, + string? agent = null) + { + var actividad = Source.StartActivity(nombre, System.Diagnostics.ActivityKind.Internal); + if (actividad is null) + { + return null; + } + + actividad.SetTag(GovernanceTraceTags.TenantId, tenantId.ToString()); + + if (actorId is { } actor && actor != Guid.Empty) + { + actividad.SetTag(GovernanceTraceTags.ActorId, actor.ToString()); + } + + if (initiativeId is { } iniciativa && iniciativa != Guid.Empty) + { + actividad.SetTag(GovernanceTraceTags.InitiativeId, iniciativa.ToString()); + } + + if (!string.IsNullOrWhiteSpace(phase)) + { + actividad.SetTag(GovernanceTraceTags.Phase, phase); + } + + if (!string.IsNullOrWhiteSpace(agent)) + { + actividad.SetTag(GovernanceTraceTags.Agent, agent); + } + + return actividad; + } + + /// + /// Sella el DESENLACE de la operación gobernada (aprobado, rechazado, eximido, el veredicto que + /// devolvió el Core...). Va aparte de porque el desenlace + /// no se conoce al abrir el span, y es justo el atributo por el que se filtra al investigar. + /// + public static void SetGovernanceOutcome(this System.Diagnostics.Activity? actividad, string? desenlace) + { + if (actividad is null || string.IsNullOrWhiteSpace(desenlace)) + { + return; + } + + actividad.SetTag(GovernanceTraceTags.Outcome, desenlace); + } } diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Observability/ObservabilityConventionTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Observability/ObservabilityConventionTests.cs index a2a271d6..c165e7f8 100644 --- a/src/apps/tracker-api/Tracker.Tests/Presentation/Observability/ObservabilityConventionTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Observability/ObservabilityConventionTests.cs @@ -97,6 +97,190 @@ public void LaFuenteDeSpansPropiaTieneNombreEstable() TrackerTracing.Source.Name.Should().Be("Evolith.Tracker"); } + // ── GT-616 — que las trazas se puedan encender, y que digan algo cuando se encienden ──────── + + /// + /// GT-616 — `OTEL_EXPORTER_OTLP_ENDPOINT` ENCIENDE las trazas. + /// + /// `.env.example:40` lleva declarando esa variable desde antes de que existiera el + /// pipeline, y el comentario de TracingOptions.Endpoint afirmaba que era «la variable + /// que por fin tiene consumidor». No lo era: el enlace de configuración de ASP.NET traduce + /// `Otlp__Endpoint`, de modo que ponerla no registraba nada y el retorno temprano seguía + /// disparando. Un interruptor documentado que no hace nada consume el presupuesto de + /// diagnóstico de quien lo prueba, y encima hace creer que la telemetría está encendida. + /// + [Fact] + public void ConLaVariableEstandarDeOtel_LasTrazasSIseRegistran() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTrackerTracing(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://colector.local:4317" + }) + .Build()); + + services.Should().Contain(d => d.ServiceType.FullName!.Contains("OpenTelemetry"), + "la variable estandar de OpenTelemetry es la unica documentada en `.env.example`; " + + "si no enciende nada, el interruptor documentado es una mentira"); + } + + /// + /// `Otlp:Endpoint` sigue GANANDO sobre la variable estándar: es la específica del despliegue, + /// y el chart la rellena. Sin esta comprobación, un respaldo mal ordenado dejaría que una + /// variable heredada del entorno del nodo sobrescribiera lo que el operador puso en el chart. + /// + [Fact] + public void LaSeccionDeConfiguracionGanaSobreLaVariableDeEntorno() + { + var opciones = new TracingOptions { Endpoint = "http://del-chart:4317" }; + var configuracion = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://del-entorno:4317" + }) + .Build(); + + TrackerTracing.ResolveEndpoint(opciones, configuracion).Should().Be("http://del-chart:4317"); + } + + /// + /// GT-616 — el chart tiene interruptor. + /// + /// El defecto reportado («no se ejecuta ningún `StartActivity` en el estado desplegado») + /// es cierto, pero el estado apagado por defecto lo decidió `T-049` a propósito y no se + /// revierte. Lo que no defendía ningún ADR es que el configmap NO EMITIERA `Otlp__Endpoint` en + /// absoluto: un operador con colector no tenía manera soportada de encenderlas, sólo inyectar + /// la clave a mano por `extraEnv` — justo el tipo de configuración no declarada que este chart + /// evita en todo lo demás. + /// + /// Se comprueba el chart como TEXTO y no el clúster: la guarda tiene que correr en un + /// runner sin Helm ni Kubernetes, y lo que se protege es que la clave siga declarada, no que + /// un despliegue concreto la tenga puesta. + /// + [Fact] + public void ElChartOfreceUnInterruptorDeclaradoParaLasTrazas() + { + var raiz = RepoRoot(); + var chart = Path.Combine(raiz, "product/infra/helm/evolith-tracker-api"); + + var configmap = File.ReadAllText(Path.Combine(chart, "templates/configmap.yaml")); + var valores = File.ReadAllText(Path.Combine(chart, "values.yaml")); + + configmap.Should().Contain("Otlp__Endpoint", + "sin esta clave el configmap no puede encender las trazas ni aunque el operador tenga colector"); + valores.Should().Contain("otlp:", + "el valor tiene que estar DECLARADO en values.yaml; un `--set` sobre una clave inexistente " + + "no se documenta ni se revisa"); + valores.Should().MatchRegex(@"otlp:\s*\r?\n\s*endpoint:\s*""""", + "el defecto por defecto sigue siendo APAGADO (`T-049`): esta guarda existe para que " + + "encenderlo sea posible, no para encenderlo"); + } + + /// + /// GT-616 — la guarda que cierra el defecto reportado. Existe al menos un `StartActivity` + /// de DOMINIO en la superficie, y está en los puntos donde se decide gobierno. + /// + /// Antes de esta ficha, `TrackerTracing.Source` se registraba en el pipeline y no la + /// usaba NADIE: cero llamadas a `StartActivity` en todo el repositorio. Encender el exportador + /// habría llenado el backend de spans de ASP.NET y `HttpClient` — «esta ruta tardó 180 ms»—, + /// que es una pregunta de fontanería. La de este producto es «qué inquilino decidió qué + /// compuerta, con qué desenlace, y quién la empujó». + /// + /// Es una comprobación de FUENTE y se declara como tal, igual que la del prefijo de + /// métricas: no prueba que los spans sean correctos —eso lo hace + /// —, prueba que nadie ha desmantelado + /// la instrumentación de los puntos gobernados sin darse cuenta. + /// + [Fact] + public void LosPuntosDeGobiernoAbrenUnSpanDeDominio() + { + var raiz = RepoRoot(); + var presentacion = Path.Combine(raiz, "src/apps/tracker-api/Tracker.Presentation"); + + var ficheros = Directory.GetFiles(presentacion, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Contains("/obj/") && !f.Contains("/bin/")) + .ToArray(); + + ficheros.Should().NotBeEmpty("una guarda que recorre cero ficheros pasa siempre"); + + var conSpanDeGobierno = ficheros + .Where(f => File.ReadAllText(f).Contains("StartGovernanceActivity(", StringComparison.Ordinal)) + .Select(f => Path.GetFileName(f)) + .Where(n => n != "TrackerTracing.cs") // la definicion no cuenta como punto de uso + .OrderBy(n => n) + .ToArray(); + + conSpanDeGobierno.Should().HaveCountGreaterThanOrEqualTo(3, + "la fuente `Evolith.Tracker` se registraba en el pipeline y no la invocaba NADIE: " + + "con el exportador encendido el backend solo habria recibido fontaneria de ASP.NET. " + + $"Puntos instrumentados hoy: [{string.Join(", ", conSpanDeGobierno)}]"); + + conSpanDeGobierno.Should().Contain("GateDecisionEndpoints.cs", + "decidir una compuerta es EL acto de gobierno de este producto"); + } + + /// + /// GT-616 — comprobación de COMPORTAMIENTO (no de fuente): el span de gobierno lleva de verdad + /// inquilino, actor, iniciativa, fase, agente y desenlace, con los nombres de atributo estables. + /// + /// Un atributo mal escrito no falla: se ingiere igual y crea una dimensión huérfana que + /// nadie consulta. Por eso los nombres viven en y por eso + /// esta prueba los contrasta contra un ActivityListener real en vez de contra el + /// literal que acaba de escribir el mismo autor. + /// + [Fact] + public void ElSpanDeGobiernoLlevaLosAtributosDeDominio() + { + var tenant = Guid.NewGuid(); + var actor = Guid.NewGuid(); + var iniciativa = Guid.NewGuid(); + + using var oyente = new System.Diagnostics.ActivityListener + { + ShouldListenTo = fuente => fuente.Name == TrackerTracing.Source.Name, + Sample = (ref System.Diagnostics.ActivityCreationOptions _) + => System.Diagnostics.ActivitySamplingResult.AllDataAndRecorded + }; + System.Diagnostics.ActivitySource.AddActivityListener(oyente); + + System.Diagnostics.Activity? capturada; + using (var actividad = TrackerTracing.StartGovernanceActivity( + "governance.gate_decision.decide", tenant, actor, iniciativa, + phase: "design", agent: "agent-runtime")) + { + actividad.SetGovernanceOutcome("approved_with_exception"); + capturada = actividad; + } + + capturada.Should().NotBeNull("con un listener suscrito la fuente tiene que producir el span"); + capturada!.OperationName.Should().Be("governance.gate_decision.decide"); + + capturada.GetTagItem(GovernanceTraceTags.TenantId).Should().Be(tenant.ToString()); + capturada.GetTagItem(GovernanceTraceTags.ActorId).Should().Be(actor.ToString()); + capturada.GetTagItem(GovernanceTraceTags.InitiativeId).Should().Be(iniciativa.ToString()); + capturada.GetTagItem(GovernanceTraceTags.Phase).Should().Be("design"); + capturada.GetTagItem(GovernanceTraceTags.Agent).Should().Be("agent-runtime"); + capturada.GetTagItem(GovernanceTraceTags.Outcome).Should().Be("approved_with_exception"); + } + + /// + /// Sin listener —el estado por defecto, trazas apagadas— abrir un span de gobierno devuelve + /// null y no cuesta nada. Por eso los puntos de llamada se instrumentan + /// incondicionalmente en vez de esconderse tras un `if` que alguien olvidaría de mantener. + /// + [Fact] + public void SinOyente_ElSpanDeGobiernoNoSeMaterializa() + { + var actividad = TrackerTracing.StartGovernanceActivity("governance.probe", Guid.NewGuid()); + + actividad.Should().BeNull(); + + // Y sellarlo tampoco explota: los puntos de llamada no comprueban null. + actividad.SetGovernanceOutcome("approved"); + } + private static IConfiguration Config(string? endpoint) { var valores = new Dictionary(); @@ -104,14 +288,32 @@ private static IConfiguration Config(string? endpoint) return new ConfigurationBuilder().AddInMemoryCollection(valores).Build(); } + /// + /// GT-616 — en un worktree de git, `.git` es un FICHERO, no un directorio. + /// + /// La versión anterior sólo miraba Directory.Exists(".git"), así que dentro de un + /// worktree no reconocía su propia raíz: seguía subiendo hasta el checkout PRINCIPAL y escaneaba + /// los ficheros de OTRA rama. Las guardas de fuente pasaban o fallaban según lo que hubiera en + /// un árbol de trabajo distinto del que se estaba probando — un verde que no es evidencia de + /// nada, y el modo de fallo más caro de todos porque no se anuncia. + /// + /// Se detectó al ver esta misma prueba seguir en rojo con el arreglo ya aplicado en el + /// disco: leía el fichero equivocado. + /// private static string RepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) + while (dir is not null && !EsRaizDeRepositorio(dir)) { dir = dir.Parent; } dir.Should().NotBeNull(); return dir!.FullName; + + static bool EsRaizDeRepositorio(DirectoryInfo candidato) + { + var git = Path.Combine(candidato.FullName, ".git"); + return Directory.Exists(git) || File.Exists(git); + } } }