diff --git a/.bmad-core/deliverables/prd.es.md b/.bmad-core/deliverables/prd.es.md index 6c74fef0..df525ed6 100644 --- a/.bmad-core/deliverables/prd.es.md +++ b/.bmad-core/deliverables/prd.es.md @@ -174,30 +174,48 @@ La CLI es una interfaz de primera clase — cada capacidad disponible en la Apli ### 5.3 Servidor MCP — Integración Directa de Agentes IA -El Servidor MCP de Evolith Tracker es la superficie de integración que hace posible el SDLC AI-Native. Expone el Tracker como un conjunto de herramientas MCP que cualquier agente IA compatible con MCP (Claude Code, agentes BMAD personalizados) puede llamar directamente desde dentro de su contexto de ejecución. +> [!IMPORTANT] +> **Superado por [`T-052`](../../docs/adrs/T-052-gateway-consumes-core-single-mcp.es.md) (COH-006, COH-007).** +> Esta sección describía un servidor MCP hospedado por el Tracker con seis tools `tracker_*`. Se +> construyeron (`c5f3a50`), se recablearon al BFF (`293e3de`, `T-051`) y después se **eliminaron** +> (`614ddff`) cuando `T-052` decidió que el `evolith-mcp` del Core es la única superficie MCP de +> cara a agentes. La visión sigue siendo válida; la topología no. Se conserva el texto porque el +> requisito que enuncia es real y el lector necesita saber dónde se sirve ahora. + +La superficie de integración que hace posible un SDLC AI-Native es un servidor MCP que cualquier +agente compatible (Claude Code, agentes BMAD personalizados) pueda llamar desde su contexto de +ejecución. **Ese servidor es el `evolith-mcp` del Core, no uno del Tracker.** El `tracker-gateway` +lo reexpone como REST en `/api/core-mcp/tools` y `/api/core-mcp/call` para que un consumidor no +tenga que hablar el protocolo MCP. **La Visión de Integración MCP:** -Un Agente Arquitecto BMAD ejecutándose en Claude Code no necesita abrir un navegador ni cambiar de contexto para interactuar con Evolith Tracker. Se conecta al Servidor MCP del Tracker y puede: +Un Agente Arquitecto BMAD ejecutándose en Claude Code no necesita abrir un navegador ni cambiar de +contexto para interactuar con Evolith Tracker. Se conecta a la superficie MCP y puede: 1. **Verificar su asignación actual** — ¿en qué iniciativa y fase está trabajando? 2. **Leer la spec aprobada** — obtener la spec de Diseño actual y las restricciones de ADR upstream 3. **Enviar un entregable** — subir un contrato OpenAPI o ADR al Tracker 4. **Consultar el estado de compuerta** — ¿el entregable actual está aprobado o necesita revisión? -5. **Recibir feedback** — obtener respuestas estructuradas de compuerta (aprobado/rechazado + razones) sin salir del contexto +5. **Recibir feedback** — obtener respuestas estructuradas de compuerta sin salir del contexto -**Categorías de Herramientas MCP:** +**Dónde viven ahora esas cinco capacidades.** Cada una la sirve el BFF `tracker-api`, que posee el +estado de Plano 1 que el Core no conoce —estado de compuerta, asignaciones, evidencia—, y el MCP del +Core para todo lo upstream. Los seis nombres `tracker_*` de abajo son **históricos** y ya no +resuelven: una petición a `POST /api/mcp/tracker_get_assignment` devuelve `404`. -| Categoría de Herramienta | Propósito | +| Tool retirada (`T-052`) | Capacidad, y dónde se sirve ahora | | :--- | :--- | -| `tracker_get_assignment` | Devuelve la tarea asignada actual para un rol de agente dado | -| `tracker_read_spec` | Devuelve los artefactos de spec relevantes para la fase actual | -| `tracker_submit_deliverable` | Envía un artefacto entregable para evaluación de compuerta | -| `tracker_get_gate_status` | Devuelve el estado de compuerta actual para una iniciativa y fase | -| `tracker_log_activity` | Registra la actividad del agente para la pista de auditoría | -| `tracker_get_upstream_constraints` | Devuelve los ADRs de Evolith Core activos aplicables a la fase actual | - -**Principio de Integración MCP:** El Tracker es la autoridad. El agente recibe instrucciones del Tracker, no al revés. Un agente no puede auto-asignarse, saltarse una compuerta ni anular una restricción upstream vía MCP. +| `tracker_get_assignment` | Modelos de lectura de asignación en el BFF `tracker-api` | +| `tracker_read_spec` | `/design/blueprints/*` y los modelos de lectura de evidencia | +| `tracker_submit_deliverable` | Caminos de escritura de artefacto y evidencia en el BFF | +| `tracker_get_gate_status` | `GET /gates/{sdlcExecutionId}/phase/{phase}` | +| `tracker_log_activity` | El camino de escritura de la entrada de auditoría | +| `tracker_get_upstream_constraints` | `GET /architecture/topologies`, fachada sobre el Core | + +**Principio de Integración MCP:** El Tracker es la autoridad sobre su propio plano. El agente recibe +instrucciones del Tracker, no al revés. Un agente no puede auto-asignarse, saltarse una compuerta ni +anular una restricción upstream. --- diff --git a/.bmad-core/deliverables/prd.md b/.bmad-core/deliverables/prd.md index 49bc4fdb..19e27e9a 100644 --- a/.bmad-core/deliverables/prd.md +++ b/.bmad-core/deliverables/prd.md @@ -224,11 +224,24 @@ The CLI is a first-class interface — every capability available in the Web App ### 5.3 MCP Server — AI Agent Direct Integration -The Evolith Tracker MCP Server is the integration surface that makes AI-native SDLC possible. It exposes the Tracker as a set of MCP tools that any MCP-compatible AI agent (Claude Code, custom BMAD agents) can call directly from within their execution context. +> [!IMPORTANT] +> **Superseded by [`T-052`](../../docs/adrs/T-052-gateway-consumes-core-single-mcp.md) (COH-006, COH-007).** +> This section described a Tracker-hosted MCP server exposing six `tracker_*` tools. They were +> built (`c5f3a50`), rebound to the BFF (`293e3de`, `T-051`) and then **deleted** (`614ddff`) +> when `T-052` decided the Core's `evolith-mcp` is the single agent-facing MCP surface. The +> vision below still holds; the topology does not. Left in place rather than removed because the +> requirement it states is real and the reader needs to know where it is now served. + +The integration surface that makes an AI-native SDLC possible is an MCP server that any +MCP-compatible agent (Claude Code, custom BMAD agents) can call from inside its execution context. +**That server is the Core's `evolith-mcp`, not a Tracker-hosted one.** The `tracker-gateway` +re-exposes it over REST at `/api/core-mcp/tools` and `/api/core-mcp/call` so a consumer need not +speak the MCP wire protocol. **The MCP Integration Vision:** -A BMAD Architect Agent running in Claude Code does not need to open a browser or switch context to interact with Evolith Tracker. It connects to the Tracker's MCP Server and can: +A BMAD Architect Agent running in Claude Code does not need to open a browser or switch context to +interact with Evolith Tracker. It connects to the MCP surface and can: 1. **Check its current assignment** — what initiative and phase is it working on? 2. **Read the approved spec** — get the current Design spec and upstream ADR constraints @@ -236,18 +249,23 @@ A BMAD Architect Agent running in Claude Code does not need to open a browser or 4. **Query gate status** — is the current deliverable approved or does it need revision? 5. **Receive feedback** — get structured gate feedback (pass/fail + reasons) without leaving context -**MCP Tool Categories:** +**Where those five capabilities live now.** Each is served by the `tracker-api` BFF, which owns the +Plane-1 state the Core has no concept of — gate status, assignments, evidence — and by the Core's +MCP for everything upstream. The six `tracker_*` tool names below are **historical** and no longer +resolve; a request to `POST /api/mcp/tracker_get_assignment` returns `404`. -| Tool Category | Purpose | +| Retired tool (`T-052`) | Capability, and where it is served now | | :--- | :--- | -| `tracker_get_assignment` | Returns the current assigned task for a given agent role | -| `tracker_read_spec` | Returns the relevant spec artifacts for the current phase | -| `tracker_submit_deliverable` | Submits a deliverable artifact for gate evaluation | -| `tracker_get_gate_status` | Returns the current gate status for an initiative and phase | -| `tracker_log_activity` | Logs agent activity for audit trail | -| `tracker_get_upstream_constraints` | Returns the active Evolith Core ADRs applicable to the current phase | - -**MCP Integration Principle:** The Tracker is authoritative. The agent receives instructions from the Tracker, not the reverse. An agent cannot self-assign, skip a gate, or override an upstream constraint via MCP. +| `tracker_get_assignment` | Assignment read models on the `tracker-api` BFF | +| `tracker_read_spec` | `/design/blueprints/*` and the evidence read models | +| `tracker_submit_deliverable` | Artifact and evidence write paths on the BFF | +| `tracker_get_gate_status` | `GET /gates/{sdlcExecutionId}/phase/{phase}` | +| `tracker_log_activity` | The audit-entry write path | +| `tracker_get_upstream_constraints` | `GET /architecture/topologies`, itself a facade over the Core | + +**MCP Integration Principle:** The Tracker is authoritative over its own plane. The agent receives +instructions from the Tracker, not the reverse. An agent cannot self-assign, skip a gate, or +override an upstream constraint. --- diff --git a/.bmad-core/deliverables/ux-concept.es.md b/.bmad-core/deliverables/ux-concept.es.md index 53adff2b..0814640e 100644 --- a/.bmad-core/deliverables/ux-concept.es.md +++ b/.bmad-core/deliverables/ux-concept.es.md @@ -2,6 +2,15 @@ > **Bilingual Navigation:** [English Version](./ux-concept.md) +> [!NOTE] +> **Los nombres de tools de abajo son históricos (`T-052`, COH-006/COH-007).** Las seis tools MCP +> `tracker_*` se eliminaron cuando [`T-052`](../../docs/adrs/T-052-gateway-consumes-core-single-mcp.es.md) +> convirtió el `evolith-mcp` del Core en la única superficie MCP de cara a agentes; el +> `tracker-gateway` la reexpone en `/api/core-mcp/*`. Las capacidades descritas son reales y las +> sirve el BFF `tracker-api`: lo que desapareció es el transporte. Se conserva el texto para que el +> requisito no se pierda. + + > **Documento BMAD:** Visión UX Conceptual · Entregable del PM/PO > **Estado:** Aprobado para Revisión de Arquitectura > **Entrada:** [PRD](./prd.es.md) diff --git a/.bmad-core/deliverables/ux-concept.md b/.bmad-core/deliverables/ux-concept.md index 6d5348dd..5d90c867 100644 --- a/.bmad-core/deliverables/ux-concept.md +++ b/.bmad-core/deliverables/ux-concept.md @@ -2,6 +2,14 @@ > **Bilingual Navigation:** [Versión en Español](./ux-concept.es.md) +> [!NOTE] +> **Tool names below are historical (`T-052`, COH-006/COH-007).** The six `tracker_*` MCP tools +> were deleted when [`T-052`](../../docs/adrs/T-052-gateway-consumes-core-single-mcp.md) made the +> Core's `evolith-mcp` the single agent-facing MCP surface; the `tracker-gateway` re-exposes it at +> `/api/core-mcp/*`. The capabilities described here are real and served by the `tracker-api` BFF — +> only the transport named below is gone. Kept rather than deleted so the requirement survives. + + > **BMAD Document:** UX Conceptual Vision · PM/PO Deliverable > **Status:** Approved for Architecture Review > **Input:** [PRD](./prd.md) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd2089da..ddfd06cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,3 +235,35 @@ jobs: - name: Board y catalogo deben contar lo mismo run: python3 .harness/scripts/check-gap-registry.py + + # GT-435 — hasta el 2026-08-02 este repositorio publicaba TRES Dockerfiles y CERO + # imagenes: ningun workflow los construia, mientras los charts referenciaban + # `evolith-tracker-api:0.0.1`, `...-web:0.0.1` y `evolith-tracker-gateway:local`, este + # ultimo un tag que ningun registro puede servir. El camino de despliegue no estaba sin + # probar: estaba sin cablear. Nada habia tirado nunca de esas imagenes, asi que ningun + # CI en verde podia revelarlo. + - name: Los charts piden imagenes que este repositorio produce + run: node .harness/scripts/check-deployable-images.mjs + + # El paso anterior contrasta ademas el REGISTRO, la tercera superficie, anadida el + # 2026-08-01: board y catalogo llevaban sincronizados desde que existe el guard y + # nadie miraba el registro, que comparte el mismo espacio de ids. De los 34 ids + # comunes, CINCO se contradecian. Estas pruebas son negativas: comprueban que el + # contraste se pone rojo cuando debe, no que exista. + - name: Self-tests for the board/registro contrast + run: python3 .harness/scripts/check-gap-registry.test.py + + # Las fichas del board recogen hallazgos de auditoria y nada las vuelve a + # contrastar con el codigo, asi que envejecen en silencio mientras siguen + # dirigiendo prioridades. El 2026-08-01 se encontraron CINCO reclamando trabajo ya + # hecho. Este guard re-comprueba las afirmaciones que una maquina puede falsar. + # + # No lee la prosa de las filas, a proposito: el `50-validate-gap-claim` del Core ya + # probo esa via, produjo nueve atribuciones falsas en un dia y enseno a los autores + # a borrar frases verdaderas. Aqui solo se comprueba lo que alguien escribio como + # DATO en `falsifiable-claims.json`. + - name: Board claims still hold against the code + run: node .harness/scripts/check-falsifiable-claims.mjs + + - name: Self-tests for the falsifiable-claim guard + run: node --test .harness/scripts/check-falsifiable-claims.test.mjs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9dae8df1..555340b7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -30,6 +30,18 @@ jobs: - name: Check bilingual parity run: node .harness/scripts/check-bilingual-parity.mjs + # `check-bilingual-parity` compara que el par EXISTA y que las cabeceras cuadren. + # Ninguna de las dos cosas ve el IDIOMA, asi que un fichero copiado, reetiquetado + # «English (this document)» y dejado en castellano lo pasa — que es exactamente el + # estado que describen GAP-011 y GAP-017, y por eso ambas filas seguian siendo + # ciertas con la paridad en verde. Medido el 2026-08-01: 9 de 151, incluidos + # DECISIONS.md y MASTER_INDEX.md. + # + # Va como TRINQUETE: los nueve estan declarados con su motivo en + # `untranslated-allowlist.json`, asi que hoy pasa; el decimo falla. + - name: English-labelled documents actually read as English + run: node .harness/scripts/check-translation-language.mjs + - name: Validate root cleanliness run: node .harness/scripts/validate-root-cleanliness.mjs diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml new file mode 100644 index 00000000..bda39f4e --- /dev/null +++ b/.github/workflows/images.yml @@ -0,0 +1,175 @@ +# Build and publish the Tracker's service images to GHCR. +# +# WHY THIS FILE DID NOT EXIST UNTIL NOW, WHICH IS THE POINT. The Tracker shipped three +# Dockerfiles and ZERO workflows that build or push them, while its Helm charts referenced +# `ghcr.io/beyondnetcode/evolith-tracker-api:0.0.1`, `…-web:0.0.1` and — worse — +# `evolith-tracker-gateway:local`, a tag no registry can ever serve. So the deployment path was +# not merely untested, it was not wired: every chart pointed at an image nothing produced. +# +# Found on 2026-08-02 while preflighting `GT-435` (road to production) from the Core repository, +# without a cluster and without a server. Nothing had ever pulled these images, so no green CI +# could have revealed it. +# +# WHAT IT PUBLISHES. `:latest` and `:` on every merge to `main` and `develop`, mirroring the +# Core's `ci-cd.yml` so both repositories deploy the same way. `latest` makes a chart deployable +# out of the box; `` is what a production deploy must pin, because `latest` cannot be rolled +# back and `GT-448` requires a rollback that has actually been exercised. +# +# AUTHENTICATION. The built-in `GITHUB_TOKEN` with `packages: write`. No extra secret, so this is +# safe to merge and works from the first run. + +name: Images (build & push to GHCR) + +on: + push: + branches: [main, develop] + # Manual runs are for rebuilding a tag without a code change (a base-image CVE, say). + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: images-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-push: + name: ${{ matrix.image }} + runs-on: ubuntu-latest + timeout-minutes: 45 + + strategy: + # One failing image must not hide the state of the other two: a partial publish is a + # deployment where some services are new and some are not, and that is worth seeing whole. + fail-fast: false + matrix: + include: + # Every context is `src/` — the Nx workspace root that holds `apps/`. Each Dockerfile + # documents this in its own header, and the COPY paths are relative to it. + - image: evolith-tracker-api + dockerfile: src/apps/tracker-api/Dockerfile + - image: evolith-tracker-gateway + dockerfile: src/apps/tracker-gateway/Dockerfile + - image: evolith-tracker-web + dockerfile: src/apps/tracker-web/Dockerfile + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push ${{ matrix.image }} + uses: docker/build-push-action@v6 + with: + context: src + file: ${{ matrix.dockerfile }} + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}:latest + ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}:${{ github.sha }} + cache-from: type=gha,scope=${{ matrix.image }} + cache-to: type=gha,mode=max,scope=${{ matrix.image }} + + # A push that exits zero is not proof the image is pullable: the tag has to exist in the + # registry afterwards. This is the difference between "the job was green" and "the artifact + # is there", and it is exactly the confusion that let three charts point at nothing. + - name: The pushed tag exists in the registry + run: | + image="ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}:${{ github.sha }}" + docker buildx imagetools inspect "$image" > /dev/null + echo "verified pullable: $image" + + # ── Deploy ────────────────────────────────────────────────────────────────── + # + # Triggers the Coolify deployment of the freshly published images. Gated on + # `vars.TRACKER_DEPLOY_ENABLED` so it stays silent until somebody decides to turn it on, and it + # runs only from `main`. + # + # DELIBERATE DIFFERENCE FROM THE CORE'S EQUIVALENT JOB, which this otherwise mirrors: there, an + # unset hook warns and exits 0 for every service independently. That is right for "not configured + # yet" and wrong for "half configured" — set the token, typo one hook, and CD reports success + # while that service never deploys. Here the two states are separated: nothing configured is a + # skip, PARTIALLY configured is a FAILURE. A deploy everybody believes is wired and silently is + # not is the exact shape of the defects this repository has spent the week finding. + deploy: + name: Deploy to Coolify + needs: build-push + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && vars.TRACKER_DEPLOY_ENABLED == 'true' + steps: + - name: Classify the deploy configuration + id: cfg + env: + TOKEN: ${{ secrets.COOLIFY_API_TOKEN }} + HOOK_API: ${{ secrets.COOLIFY_TRACKER_API_DEPLOY_HOOK }} + HOOK_GATEWAY: ${{ secrets.COOLIFY_TRACKER_GATEWAY_DEPLOY_HOOK }} + HOOK_WEB: ${{ secrets.COOLIFY_TRACKER_WEB_DEPLOY_HOOK }} + run: | + set=0; unset=0 + for v in "$TOKEN" "$HOOK_API" "$HOOK_GATEWAY" "$HOOK_WEB"; do + if [ -n "$v" ]; then set=$((set+1)); else unset=$((unset+1)); fi + done + + if [ "$set" -eq 0 ]; then + echo "::warning::Coolify is not configured (no token, no hooks). Skipping the deploy. Set COOLIFY_API_TOKEN and the three hooks to enable CD." + echo "state=absent" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$unset" -gt 0 ]; then + echo "::error::Coolify is PARTIALLY configured: $set of 4 values present. A half-wired deploy reports success while some services never ship, so this fails instead of warning. Set all of COOLIFY_API_TOKEN, COOLIFY_TRACKER_API_DEPLOY_HOOK, COOLIFY_TRACKER_GATEWAY_DEPLOY_HOOK and COOLIFY_TRACKER_WEB_DEPLOY_HOOK, or unset them all." + exit 1 + fi + echo "state=complete" >> "$GITHUB_OUTPUT" + + - name: Trigger the deploys + if: steps.cfg.outputs.state == 'complete' + env: + TOKEN: ${{ secrets.COOLIFY_API_TOKEN }} + HOOK_API: ${{ secrets.COOLIFY_TRACKER_API_DEPLOY_HOOK }} + HOOK_GATEWAY: ${{ secrets.COOLIFY_TRACKER_GATEWAY_DEPLOY_HOOK }} + HOOK_WEB: ${{ secrets.COOLIFY_TRACKER_WEB_DEPLOY_HOOK }} + run: | + failed=0 + trigger() { + # `--fail` matters: without it curl exits 0 on a 4xx and the deploy would report + # success for a hook that was rejected. + if curl --fail --silent --show-error -X GET "$2" -H "Authorization: Bearer $TOKEN" -o /dev/null; then + echo "$1 deploy triggered" + else + echo "::error::$1 deploy hook did not accept the request" + failed=1 + fi + } + # All three are attempted before failing, so one bad hook does not hide the state of the + # other two — the same reason the build matrix does not fail fast. + trigger tracker-api "$HOOK_API" + trigger tracker-gateway "$HOOK_GATEWAY" + trigger tracker-web "$HOOK_WEB" + exit $failed + + # Triggering is not deploying. Coolify accepts the webhook and works asynchronously, so a + # green step here means "the request was accepted", never "the new image is serving". Said + # out loud because GT-448 requires a deploy evidenced by a recorded run rather than by a job + # exiting zero, and this job cannot provide that evidence. + - name: What this job did and did not prove + if: steps.cfg.outputs.state == 'complete' + run: | + { + echo '### Deploy triggered' + echo '' + echo 'Coolify accepted the webhook for all three services at `${{ github.sha }}`.' + echo '' + echo '> This proves the REQUEST was accepted. It does not prove the new image is' + echo '> serving traffic: Coolify deploys asynchronously. GT-448 asks for a recorded' + echo '> run against the live service, which has to be taken there, not here.' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index d63c798c..1bfc83ba 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,8 @@ vite.config.ts.timestamp-* # Informe generado por .harness/scripts/coverage-dashboard.mjs; el workflow lo sube # como artifact. No se versiona ni se valida como documentacion redactada. COVERAGE_REPORT.md + +# Local editor/tool config: machine-specific absolute paths, never shared. +# Committed by accident in a5f719c (a `git add -A`) and only caught when a PR +# targeted main, where the root-cleanliness guard rejects unknown root folders. +.codex/ diff --git a/.harness/scripts/check-deployable-images.mjs b/.harness/scripts/check-deployable-images.mjs new file mode 100644 index 00000000..f3c8c869 --- /dev/null +++ b/.harness/scripts/check-deployable-images.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node +/** + * GT-435 — every image a Helm chart asks for must be one this repository actually publishes. + * + * PORTED FROM THE CORE, where the same check found the same class of defect on the same day. Here + * it is sharper: the Tracker had three Dockerfiles and NO workflow that built or pushed them, so + * `evolith-tracker-api:0.0.1`, `…-web:0.0.1` and `evolith-tracker-gateway:local` all named images + * that nothing produced — the last one a tag no registry can ever serve. + * + * WHY, AND IT IS NOT HYPOTHETICAL. Nothing has ever run in production, so nothing has ever pulled + * these images. Measured on 2026-08-02, all three Core charts named a tag that **no workflow in + * this repository produces**: `evolith-core-api:0.0.2`, `evolith-mcp:1.1.0` and + * `evolith-agent-runtime:0.1.0`, while `ci-cd.yml` publishes only `:latest` and `:` and + * `docker-images.yml`, which would publish semver, has never run. A `helm install` with default + * values would therefore have met `ImagePullBackOff` on every service — on day one, at the worst + * possible moment, and for a reason nobody could have diagnosed from a green CI. + * + * The whole point is that this is checkable WITHOUT a cluster, a registry credential or a server. + * The day the VPS exists is not the day to discover that the charts point at nothing. + * + * WHAT IT COMPARES. The image `repository` + `tag` each chart declares, against the set of + * `:` pairs the workflows in `.github/workflows` build and push. Third-party images + * (`postgres`, `openpolicyagent/opa`) are not ours to publish and are allowlisted BY NAME with a + * reason, never by a wildcard — a pattern that swallowed our own images would defeat the check. + * + * WHAT IT DOES NOT DO. It does not contact a registry. A guard that needs credentials runs in one + * job and rots everywhere else, and the failure it exists to catch is a mismatch between two files + * that are both right here. + */ + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import process from 'node:process'; + +const ROOT = process.cwd(); +const HELM_ROOT = 'product/infra/helm'; +const WORKFLOWS = '.github/workflows'; + +/** + * Images this repository does not build. Named individually: a prefix rule such as "anything + * without our org" would also excuse a typo in one of our own repositories. + */ +const THIRD_PARTY = new Map([ + ['postgres', 'Upstream database image, pulled from Docker Hub.'], + ['openpolicyagent/opa', 'Upstream OPA sidecar; the policy bundle we build is mounted into it.'], + ['busybox', 'Upstream init/utility image.'], +]); + +/** Minimal `key: value` reader — enough for `image:` blocks, and no YAML dependency. */ +function readImageBlocks(text) { + const found = []; + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + const m = /^(\s*)(\w[\w-]*)?:?\s*$/.exec(lines[i]); + if (!m) continue; + const indent = m[1].length; + let repository; + let tag; + for (let j = i + 1; j < lines.length; j += 1) { + // Comments and blank lines are SKIPPED, not treated as the end of the block. Stopping at + // the first `#` made the parser report "declares no tag" for a block that documents its + // own tag choice — a guard defeated by the explanation of the thing it checks. + if (/^\s*(#|$)/.test(lines[j])) continue; + const child = /^(\s*)([\w-]+):\s*(.*)$/.exec(lines[j]); + if (!child) break; + if (child[1].length <= indent) break; + const value = child[3].trim().replace(/^["']|["']$/g, ''); + if (child[2] === 'repository') repository = value; + if (child[2] === 'tag') tag = value; + } + if (repository) found.push({ repository, tag: tag ?? '' }); + } + return found; +} + +/** + * Every `:` any workflow pushes. + * + * The image name is usually `${{ matrix.image }}` rather than a literal, so the matrix has to be + * resolved or this function finds nothing — and a guard that discovers zero published images would + * report every chart as broken, which is right today by accident and wrong the moment somebody + * fixes the tags. Its denominator is printed and asserted for exactly that reason. + */ +function publishedImages() { + const published = new Set(); + if (!existsSync(join(ROOT, WORKFLOWS))) return published; + + for (const file of readdirSync(join(ROOT, WORKFLOWS)).filter((f) => /\.ya?ml$/.test(f))) { + // COMMENTS ARE STRIPPED FIRST, and this is not cosmetic: a workflow that DOCUMENTS a broken + // image reference — `# the chart asked for evolith-tracker-api:0.0.1` — would otherwise be + // read as a declaration that it publishes it, and the guard would bless the very tag the + // comment exists to warn about. Found by a negative test, not by the happy path. + const raw = readFileSync(join(ROOT, WORKFLOWS, file), 'utf8') + .split('\n') + .map((line) => line.replace(/(^|\s)#.*$/, '$1')) + .join('\n'); + + // Expressions are collapsed to space-free tokens FIRST. `${{ github.repository_owner }}` + // contains spaces, so any `\S`-based pattern silently matches nothing — which is how a guard + // ends up discovering zero published images and blaming every chart. + const text = raw + .replace(/\$\{\{\s*matrix\.(?:image|name)\s*\}\}/g, '@IMAGE@') + .replace(/\$\{\{\s*github\.sha\s*\}\}/g, '@SHA@') + .replace(/\$\{\{\s*github\.repository_owner\s*\}\}/g, '@OWNER@') + .replace(/\$\{\{[^}]*\}\}/g, '@EXPR@'); + + // Literal `image:`/`name:` entries of the build matrix, which the tag lines interpolate. + const matrixImages = [ + ...text.matchAll(/^\s*-?\s*(?:image|name):\s*([a-z0-9][\w.-]*)\s*$/gm), + ] + .map((m) => m[1]) + .filter((n) => n.includes('-')); + + const expand = (name) => (name === '@IMAGE@' ? matrixImages : name.includes('@') ? [] : [name]); + + for (const m of text.matchAll(/ghcr\.io\/[\w.@-]+\/([\w.@-]+):([\w.@-]+)/g)) { + const tag = m[2] === '@SHA@' ? '' : m[2] === '@EXPR@' ? '' : m[2]; + for (const name of expand(m[1])) published.add(`${name}:${tag}`); + } + + // docker/metadata-action drives tags from `images:` plus a `tags:` pattern list. + for (const m of text.matchAll(/images:\s*ghcr\.io\/[\w.@-]+\/([\w.@-]+)/g)) { + for (const name of expand(m[1])) { + if (/type=semver/.test(text)) published.add(`${name}:`); + if (/type=raw,value=latest/.test(text)) published.add(`${name}:latest`); + } + } + } + return published; +} + +function normaliseTag(tag, workflowText) { + if (tag.includes('${{')) { + if (/github\.sha/.test(tag)) return ''; + if (/steps\.meta/.test(tag) && /type=semver/.test(workflowText)) return ''; + return ''; + } + return tag; +} + +/** Git tags in this checkout, which is what a semver image build is driven from. */ +function gitTags() { + try { + return new Set(execFileSync('git', ['tag', '--list'], { encoding: 'utf8' }).split('\n').map((t) => t.trim())); + } catch { + return new Set(); + } +} + +/** + * Whether some workflow could produce this exact image:tag. + * + * A `` publisher is driven by a GIT TAG, so declaring one is not enough: the tag has to + * exist. `evolith-agent-runtime:0.1.0` was requested by a chart while no `v0.1.0` tag has ever + * existed in this repository, which makes that image unproducible by any current path — a hard + * defect, and one provable without touching a registry. + */ +function producibility(image, tag, published, tags) { + if (published.has(`${image}:${tag}`)) return { ok: true, how: `published directly as :${tag}` }; + if (/^\d+\.\d+\.\d+/.test(tag) && published.has(`${image}:`)) { + if (tags.has(`v${tag}`) || tags.has(tag)) { + return { ok: true, how: `semver build from git tag v${tag}`, needsRegistryCheck: true }; + } + return { + ok: false, + why: + `a semver publisher exists but it is driven by a git tag, and neither \`v${tag}\` nor ` + + `\`${tag}\` is a tag in this repository. No current path can produce this image.`, + }; + } + return { + ok: false, + why: + `no workflow publishes it. Published for this image: ` + + `${[...published].filter((p) => p.startsWith(`${image}:`)).join(', ') || '(nothing)'}`, + }; +} + +function main() { + const published = publishedImages(); + const tags = gitTags(); + const problems = []; + const unverifiable = []; + let checked = 0; + + const charts = existsSync(join(ROOT, HELM_ROOT)) + ? readdirSync(join(ROOT, HELM_ROOT)).filter((d) => existsSync(join(ROOT, HELM_ROOT, d, 'values.yaml'))) + : []; + + for (const chart of charts) { + const values = readFileSync(join(ROOT, HELM_ROOT, chart, 'values.yaml'), 'utf8'); + for (const { repository, tag } of readImageBlocks(values)) { + const bare = repository.replace(/^ghcr\.io\/[^/]+\//, '').replace(/^docker\.io\//, ''); + if (THIRD_PARTY.has(bare) || THIRD_PARTY.has(repository)) continue; + checked += 1; + + if (!tag) { + problems.push(`${chart}: \`${repository}\` declares no tag — a floating image is not a deployment`); + continue; + } + // A tag a registry can never serve. `:local` only exists on the machine that built it. + if (/^(local|dev|latest-local)$/.test(tag)) { + problems.push( + `${chart}: \`${repository}:${tag}\` is a LOCAL-ONLY tag. No registry can serve it, so this ` + + `chart cannot deploy anywhere but the machine that built the image.`, + ); + continue; + } + const verdict = producibility(bare, tag, published, tags); + if (!verdict.ok) { + problems.push(`${chart}: \`${repository}:${tag}\` — ${verdict.why}`); + } else if (verdict.needsRegistryCheck) { + // The boundary of a static check, stated instead of hidden: that a build COULD produce + // this image is provable here; that it DID is a fact about a registry, and a guard that + // needed a credential to say so would run in one job and rot everywhere else. + unverifiable.push(`${chart}: \`${repository}:${tag}\` — ${verdict.how}. Whether that build ever RAN is not knowable from the repository.`); + } + } + } + + console.log( + `check-deployable-images: ${checked} chart image(s) checked across ${charts.length} chart(s); ` + + `${published.size} image:tag pair(s) discovered in ${WORKFLOWS}.`, + ); + + // Zero scanned is the shape every vacuous guard takes: both trees read, nothing contrasted, + // green tick. Inlined rather than imported because this repository has no shared coverage + // helper — the rule matters more than where it lives. + if (checked === 0) { + console.error( + `\n❌ Zero first-party chart images scanned under ${HELM_ROOT}/*/values.yaml. ` + + 'A check that inspected nothing did not run.\n', + ); + process.exit(1); + } + + if (problems.length > 0) { + console.error('\n❌ Charts request images this repository does not publish:\n'); + for (const p of problems) console.error(` ✖ ${p}`); + console.error( + '\nNothing has ever run in production, so nothing has ever pulled these images and no green\n' + + 'CI could have revealed this. The day the server exists is not the day to discover that the\n' + + 'charts point at nothing: fix the tag, or publish the tag.\n', + ); + process.exit(1); + } + + if (unverifiable.length > 0) { + console.log('\n Producible, but not verifiable from here:'); + for (const u of unverifiable) console.log(` · ${u}`); + } + console.log('\n✅ Every chart image is one this repository can produce.'); +} + +main(); diff --git a/.harness/scripts/check-falsifiable-claims.mjs b/.harness/scripts/check-falsifiable-claims.mjs new file mode 100644 index 00000000..7612f603 --- /dev/null +++ b/.harness/scripts/check-falsifiable-claims.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +/** + * Re-check the falsifiable claims of OPEN gap-register rows against the repository. + * + * WHY THIS EXISTS. On 2026-08-01 three rows were found claiming work that was already + * done: `GT-588`'s third criterion (four sub-rules and nine negative tests shipped), + * `COH-005` (closed by ADR `T-047` in July, which names it) and `COH-008` (the + * `DeploymentRecord` it says was never created ships with a table, three endpoints and + * tests). All three were caught by reading the code BEFORE starting the work. A reader + * who trusted the row would have built three things that already existed. + * + * The board records audit findings and nothing re-checks them, so they age in silence + * while still directing priorities. This guard is the missing re-check. + * + * WHAT IT DELIBERATELY DOES NOT DO. It does not read the rows' prose. The Core + * repository already ran that experiment: `50-validate-gap-claim` inferred intent from + * pull-request text, produced nine false attributions in a single day, and taught + * authors to delete true cross-references to appease it. A claim is checked here only + * when a human writes down, as DATA, the symbols and the paths whose presence would + * refute it. + * + * SO ITS SILENCE MEANS NOTHING. This guard proves that the listed claims still hold; + * it can never prove a row is still true, and an unlisted row is not thereby endorsed. + * It prints its denominator on every run so that a shrinking coverage is visible rather + * than comfortable. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, extname, relative } from 'node:path'; +import process from 'node:process'; + +const ROOT = process.cwd(); +const CLAIMS_FILE = 'docs/audit/falsifiable-claims.json'; +const REGISTER_FILE = 'docs/audit/tracker-gaps-opportunities-tracking.md'; + +const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'bin', 'obj', '.harness']); + +function walk(dir, extensions, out = []) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const entry of entries) { + if (entry.name.startsWith('.') && entry.name !== '.github') continue; + if (SKIP_DIRS.has(entry.name)) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full, extensions, out); + else if (extensions.length === 0 || extensions.includes(extname(entry.name))) out.push(full); + } + return out; +} + +/** Status of each row in the register, keyed by gap id. */ +function readRegisterStatuses() { + const statuses = new Map(); + for (const line of readFileSync(join(ROOT, REGISTER_FILE), 'utf8').split('\n')) { + if (!line.startsWith('|')) continue; + const cells = line.split('|').map((c) => c.trim()); + const id = (cells[3] ?? '').match(/\[([A-Z]+-[A-Z0-9/]+)\]/)?.[1]; + if (!id) continue; + const raw = cells[2] ?? ''; + const status = ['BLOCKED', 'OPEN', 'DEFERRED', 'RESOLVED'].find((s) => raw.includes(s)); + if (status) statuses.set(id, status); + } + return statuses; +} + +/** + * A `symbol-absent` probe: the claim asserts nothing in `paths` mentions any of + * `symbols`. Finding one refutes the claim. + * + * The match is a plain substring and NOT a regular expression, on purpose: a regex in a + * data file is a small programming language that the person writing a claim would have + * to get right, and a wrong one fails silently in the direction of saying nothing. + */ +function runSymbolAbsent(probe) { + const extensions = probe.extensions ?? []; + // The audit tree is excluded by DEFAULT, and this is the guard's first lesson about + // itself: on its first run every claim was `refuted` by the very rows that assert the + // absence, plus the audit reports they came from. A document DISCUSSING a gap is not a + // document CLOSING it, and a probe that cannot tell the two apart refutes every claim + // it is given — which is indistinguishable from being broken. + const excluded = probe.excludePaths ?? ['docs/audit']; + const hits = []; + for (const base of probe.paths) { + for (const file of walk(join(ROOT, base), extensions)) { + const rel = relative(ROOT, file); + if (excluded.some((prefix) => rel.startsWith(prefix))) continue; + let text; + try { + text = readFileSync(file, 'utf8'); + } catch { + continue; + } + for (const symbol of probe.symbols) { + if (!text.includes(symbol)) continue; + const lineNo = text.slice(0, text.indexOf(symbol)).split('\n').length; + hits.push({ symbol, file: relative(ROOT, file), line: lineNo }); + } + } + } + return hits; +} + +function main() { + const doc = JSON.parse(readFileSync(join(ROOT, CLAIMS_FILE), 'utf8')); + const statuses = readRegisterStatuses(); + const failures = []; + let checked = 0; + let skippedClosed = 0; + + for (const claim of doc.claims) { + const status = statuses.get(claim.id); + if (!status) { + failures.push(`${claim.id}: listed in ${CLAIMS_FILE} but absent from the register.`); + continue; + } + + // A resolved row's claim is history, not a promise. Re-checking it would turn + // every closure into a permanent obligation to keep the old defect absent. + if (status === 'RESOLVED') { + skippedClosed += 1; + continue; + } + + if (claim.probe.kind !== 'symbol-absent') { + failures.push(`${claim.id}: unsupported probe kind '${claim.probe.kind}'.`); + continue; + } + + checked += 1; + const hits = runSymbolAbsent(claim.probe); + if (hits.length > 0) { + const shown = hits.slice(0, 4).map((h) => ` ${h.file}:${h.line} → "${h.symbol}"`); + failures.push( + `${claim.id} is REFUTED — the row is ${status} and claims:\n` + + ` "${claim.claim}"\n` + + ` but the repository contains:\n${shown.join('\n')}` + + (hits.length > 4 ? `\n … and ${hits.length - 4} more` : '') + + `\n Either the row is stale and should move, or the claim in ${CLAIMS_FILE} is` + + `\n wrong and should be narrowed. Both are edits; neither is deleting the probe.`, + ); + } + } + + console.log( + `Falsifiable-claim re-check: ${checked} open claim(s) probed, ` + + `${skippedClosed} skipped as resolved, ${doc.claims.length} declared of ` + + `${statuses.size} register rows.`, + ); + + if (checked === 0 && doc.claims.length > 0) { + console.log(' (every declared claim belongs to a resolved row — nothing to re-check)'); + } + + if (failures.length > 0) { + console.error('\n❌ Board claims contradicted by the repository:\n'); + for (const f of failures) console.error(` ✖ ${f}\n`); + console.error( + 'A row that claims work already done misdirects the following week. This guard\n' + + 'only checks what someone wrote down as data — its silence is not a completeness\n' + + 'claim about the rest of the board.\n', + ); + process.exit(1); + } + + console.log('✅ Every declared claim still holds.'); +} + +main(); diff --git a/.harness/scripts/check-falsifiable-claims.test.mjs b/.harness/scripts/check-falsifiable-claims.test.mjs new file mode 100644 index 00000000..4f8622c2 --- /dev/null +++ b/.harness/scripts/check-falsifiable-claims.test.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * Self-tests for `check-falsifiable-claims.mjs`. + * + * The guard's whole value is that it goes RED when a board claim stops being true. A + * guard that only ever passes is indistinguishable from one that is broken, so every + * test here is a NEGATIVE one: it builds a repository where the claim is refuted and + * asserts the guard notices. + * + * The exception is the last test, and it is the one that would have caught the defect + * found on the guard's first real run: a document that DISCUSSES a gap must not refute + * it, or every claim refutes itself through the very row that asserts it. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const GUARD = join(dirname(fileURLToPath(import.meta.url)), 'check-falsifiable-claims.mjs'); + +/** Build a throwaway repository and run the guard inside it. */ +function runIn({ claims, files, registerRows }) { + const root = mkdtempSync(join(tmpdir(), 'claims-')); + try { + mkdirSync(join(root, 'docs/audit'), { recursive: true }); + + const header = '| # | Status | ID | Type |\n|---|---|---|---|\n'; + const rows = registerRows + .map((r, i) => `| ${i + 1} | ${r.status} | [${r.id}](#detail-${r.id.toLowerCase()}) | GAP |`) + .join('\n'); + writeFileSync(join(root, 'docs/audit/tracker-gaps-opportunities-tracking.md'), header + rows + '\n'); + writeFileSync(join(root, 'docs/audit/falsifiable-claims.json'), JSON.stringify({ claims }, null, 2)); + + for (const [rel, content] of Object.entries(files ?? {})) { + mkdirSync(join(root, dirname(rel)), { recursive: true }); + writeFileSync(join(root, rel), content); + } + + try { + const stdout = execFileSync('node', [GUARD], { cwd: root, encoding: 'utf8' }); + return { code: 0, output: stdout }; + } catch (err) { + return { code: err.status ?? 1, output: `${err.stdout ?? ''}${err.stderr ?? ''}` }; + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const claim = (over = {}) => ({ + id: 'GAP-001', + claim: 'Nothing defines the widget.', + probe: { kind: 'symbol-absent', symbols: ['WidgetSpec'], paths: ['reference'], extensions: ['.md'] }, + ...over, +}); + +test('PASSES when the claimed absence still holds', () => { + const r = runIn({ + claims: [claim()], + registerRows: [{ id: 'GAP-001', status: '🟡 OPEN' }], + files: { 'reference/other.md': 'nothing relevant here' }, + }); + assert.equal(r.code, 0, r.output); + assert.match(r.output, /Every declared claim still holds/); +}); + +test('RED when the repository now contains what the row says is absent', () => { + const r = runIn({ + claims: [claim()], + registerRows: [{ id: 'GAP-001', status: '🟡 OPEN' }], + files: { 'reference/spec.md': 'The WidgetSpec is defined here.' }, + }); + assert.equal(r.code, 1, r.output); + assert.match(r.output, /GAP-001 is REFUTED/); + assert.match(r.output, /reference\/spec\.md:1/, 'must name the file and line, not just the id'); +}); + +test('a document that DISCUSSES the gap does not refute it', () => { + // The defect found on the guard's first real run: every claim was refuted by the + // register row asserting it, and by the audit reports the row came from. + const r = runIn({ + claims: [claim()], + registerRows: [{ id: 'GAP-001', status: '🟡 OPEN' }], + files: { 'docs/audit/finding.md': 'The WidgetSpec is missing and must be written.' }, + }); + assert.equal(r.code, 0, r.output); +}); + +test('a RESOLVED row is not re-checked — closure is not a permanent obligation', () => { + const r = runIn({ + claims: [claim()], + registerRows: [{ id: 'GAP-001', status: '🟢 RESOLVED' }], + files: { 'reference/spec.md': 'The WidgetSpec is defined here.' }, + }); + assert.equal(r.code, 0, r.output); + assert.match(r.output, /1 skipped as resolved/); +}); + +test('RED when a claim names a row that is not in the register', () => { + const r = runIn({ + claims: [claim({ id: 'GAP-999' })], + registerRows: [{ id: 'GAP-001', status: '🟡 OPEN' }], + }); + assert.equal(r.code, 1, r.output); + assert.match(r.output, /absent from the register/); +}); + +test('RED on an unsupported probe kind rather than skipping it in silence', () => { + const r = runIn({ + claims: [claim({ probe: { kind: 'vibes', symbols: [], paths: [] } })], + registerRows: [{ id: 'GAP-001', status: '🟡 OPEN' }], + }); + assert.equal(r.code, 1, r.output); + assert.match(r.output, /unsupported probe kind/); +}); + +test('always prints its denominator, so shrinking coverage is visible', () => { + const r = runIn({ + claims: [claim()], + registerRows: [{ id: 'GAP-001', status: '🟡 OPEN' }, { id: 'GAP-002', status: '🟡 OPEN' }], + files: { 'reference/other.md': 'nothing' }, + }); + assert.match(r.output, /1 open claim\(s\) probed/); + assert.match(r.output, /1 declared of 2 register rows/, + 'the ratio of declared claims to rows is what says how little this guard covers'); +}); diff --git a/.harness/scripts/check-gap-registry.py b/.harness/scripts/check-gap-registry.py index 8b944765..84dda907 100755 --- a/.harness/scripts/check-gap-registry.py +++ b/.harness/scripts/check-gap-registry.py @@ -24,6 +24,8 @@ RAIZ = Path(__file__).resolve().parent.parent.parent BOARD = RAIZ / "docs/audit/tracker-gap-tracking.md" CATALOGO = RAIZ / "docs/audit/tracker-gap-reference-catalog.md" +# Tercera superficie, añadida el 2026-08-01. Ver `estados_registro`. +REGISTRO = RAIZ / "docs/audit/tracker-gaps-opportunities-tracking.md" # Tolerante a sangrado y a viñeta con `*`: el objetivo es DETECTAR el desorden, no # tropezar con él y reportar un falso "no existe". @@ -57,12 +59,97 @@ def estados_catalogo() -> tuple[dict[str, str], list[str], list[str]]: return estados, dobles, sangradas +def fichas_registro() -> dict[str, str]: + """Estados de las FICHAS de detalle del registro (`### Detail XXX` → `- **Status:**`). + + El registro repite el estado en dos sitios —la fila de la tabla y la ficha de abajo— y nada los + contrastaba. Medido el 2026-08-02: **3 de 108** se contradecían, las tres con la fila diciendo + `RESOLVED` y la ficha `BLOCKED`, y en las tres tenía razón la ficha. `GAP-004` estaba cerrada + con la nota «Resolved via Defensive Isolation (Mocks)» —haber puesto un doble no es haber + resuelto la dependencia— y `COH-006`/`COH-007` decían que el gateway expone seis tools MCP que + `T-052` había borrado del árbol. + """ + estados, actual = {}, None + for linea in REGISTRO.read_text().splitlines(): + m = re.match(r"^### Detail ([A-Z]+-[A-Z0-9/]+)\s*$", linea) + if m: + actual = m.group(1) + continue + if actual and STATUS_VIGENTE.match(linea): + estado = next( + (s for s in ("BLOCKED", "OPEN", "DEFERRED", "RESOLVED") if s in linea), None + ) + if estado: + estados[actual] = estado + actual = None + return estados + + +def estados_registro() -> dict[str, str]: + """Estados del REGISTRO, la tercera superficie. + + Se añadió el 2026-08-01 después de medir el agujero: board y catálogo llevaban + sincronizados desde que existe esta comprobación, y el registro —que comparte el mismo + espacio de identificadores— no lo miraba nadie. De los 34 ids que las dos superficies + tienen en común, **5 se contradecían**: `COH-012`, `GAP-016`, `GAP-023` y `GAP-025` + figuraban cerrados en el board y abiertos en el registro, y `GAP-020` al revés. En dos + de esos casos el board mentía (los ficheros «traducidos» seguían en español) y en tres + mentía el registro. No es que una superficie sea de fiar y la otra no: es que nada las + obligaba a coincidir. + """ + estados = {} + for linea in REGISTRO.read_text().splitlines(): + if not linea.startswith("|"): + continue + celdas = linea.split("|") + if len(celdas) < 4: + continue + m = re.search(r"\[([A-Z]+-[A-Z0-9/]+)\]", celdas[3]) + if not m: + continue + estado = next( + (s for s in ("BLOCKED", "OPEN", "DEFERRED", "RESOLVED") if s in celdas[2]), None + ) + if estado: + estados[m.group(1)] = estado + return estados + + +# Los dos vocabularios no coinciden y NO se traducen entre sí: inventar un mapa +# `OPEN→PENDING`, `DEFERRED→?` sería una decisión disfrazada de comprobación. Se compara +# sólo lo que ambas superficies afirman sin ambigüedad — si el ítem está cerrado o no — +# que es justo la dimensión en la que se contradecían. +CERRADO_BOARD = {"DONE"} +CERRADO_REGISTRO = {"RESOLVED"} + + def main() -> int: board = estados_board() catalogo, dobles, sangradas = estados_catalogo() + registro = estados_registro() + fichas = fichas_registro() problemas = [] + for gid in sorted(set(registro) & set(fichas)): + if registro[gid] != fichas[gid]: + problemas.append( + f"{gid}: en el registro la FILA dice {registro[gid]} y su FICHA dice " + f"{fichas[gid]} — el mismo documento se contradice" + ) + + for gid in sorted(set(registro) - set(fichas)): + problemas.append(f"{gid}: fila en el registro sin ficha `### Detail` con `Status` legible") + + for gid in sorted(set(board) & set(registro)): + cerrado_b = board[gid] in CERRADO_BOARD + cerrado_r = registro[gid] in CERRADO_REGISTRO + if cerrado_b != cerrado_r: + problemas.append( + f"{gid}: el board dice {board[gid]} y el registro dice {registro[gid]} — " + f"uno lo da por cerrado y el otro no" + ) + desync = [(g, board[g], catalogo[g]) for g in catalogo if g in board and board[g] != catalogo[g]] for gid, b, c in sorted(desync): problemas.append(f"{gid}: el board dice {b} y su ficha dice {c}") @@ -82,8 +169,14 @@ def main() -> int: print(f" · {p}") return 1 + comunes = len(set(board) & set(registro)) print(f"Registro coherente: {len(catalogo)} fichas / {len(board)} filas.") print(f" {dict(Counter(board.values()))}") + # El denominador se imprime siempre. Board y registro comparten espacio de ids pero + # sólo se solapan en una parte: fuera de esos ids nada aquí comprueba nada, y verlo + # escrito evita leer el verde como si cubriera las tres superficies enteras. + print(f" board∩registro: {comunes} id(s) contrastados de {len(registro)} en el registro.") + print(f" fila∩ficha dentro del registro: {len(fichas)} contrastadas.") return 0 diff --git a/.harness/scripts/check-gap-registry.test.py b/.harness/scripts/check-gap-registry.test.py new file mode 100644 index 00000000..3e71986c --- /dev/null +++ b/.harness/scripts/check-gap-registry.test.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Pruebas del contraste board ↔ registro añadido a `check-gap-registry.py`. + +Todas menos una son NEGATIVAS. Un guard que sólo se ha visto pasar es indistinguible de +uno roto, y esa confusión ya costó cara en este repositorio: el board daba por traducidos +dos ficheros que seguían en español, y todo estaba verde. + +La última prueba es la que evita el falso positivo simétrico: board y registro sólo se +solapan en una parte de sus ids, así que un id presente en uno y ausente en el otro NO es +una contradicción y no debe reportarse como tal. +""" +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +GUARD = Path(__file__).resolve().parent / "check-gap-registry.py" + + +def _repo(tmp: Path, filas, fichas=None): + """Monta un repositorio de usar y tirar con las tres superficies coherentes salvo + en lo que cada prueba quiera romper. `filas` = [(id, estado_board, estado_registro)]; + un estado a None omite la fila en esa superficie.""" + (tmp / ".harness/scripts").mkdir(parents=True) + (tmp / "docs/audit").mkdir(parents=True) + shutil.copy(GUARD, tmp / ".harness/scripts" / GUARD.name) + + board = ["| Gap | Qué | Estado |", "|---|---|---|"] + catalogo = ["# Catálogo", ""] + registro = [ + "| # | Status | ID | Type |", + "|---|---|---|---|", + ] + for n, (gid, eb, er) in enumerate(filas, start=1): + if eb is not None: + board.append(f"| [`{gid}`](./cat.md#{gid.lower()}) | algo | `{eb}` |") + catalogo += [f"#### {gid}", "", f"- **Status:** `{eb}`", ""] + if er is not None: + registro.append(f"| {n} | {er} | [{gid}](#detail-{gid.lower()}) | GAP |") + + # Fichas de detalle del registro. Por defecto repiten el estado de la fila, que es lo + # coherente; una prueba las desvia a proposito para comprobar que el contraste lo ve. + for gid, _eb, er in filas: + if er is None: + continue + estado = (fichas or {}).get(gid, er) + registro += ["", f"### Detail {gid}", "", f"- **Status:** {estado}", ""] + + (tmp / "docs/audit/tracker-gap-tracking.md").write_text("\n".join(board) + "\n") + (tmp / "docs/audit/tracker-gap-reference-catalog.md").write_text("\n".join(catalogo) + "\n") + (tmp / "docs/audit/tracker-gaps-opportunities-tracking.md").write_text("\n".join(registro) + "\n") + return tmp / ".harness/scripts" / GUARD.name + + +def correr(filas, fichas=None): + with tempfile.TemporaryDirectory() as d: + script = _repo(Path(d), filas, fichas) + p = subprocess.run([sys.executable, str(script)], capture_output=True, text=True) + return p.returncode, p.stdout + p.stderr + + +class ContrasteBoardRegistro(unittest.TestCase): + def test_de_acuerdo_en_cerrado_pasa(self): + code, _ = correr([("GAP-001", "DONE", "🟢 RESOLVED")]) + self.assertEqual(code, 0) + + def test_de_acuerdo_en_abierto_pasa(self): + code, _ = correr([("GAP-001", "PENDING", "🟡 OPEN")]) + self.assertEqual(code, 0) + + def test_board_cerrado_y_registro_abierto_falla(self): + """La forma exacta de COH-012, GAP-016, GAP-023 y GAP-025 el 2026-08-01.""" + code, salida = correr([("GAP-001", "DONE", "🟡 OPEN")]) + self.assertEqual(code, 1) + self.assertIn("GAP-001", salida) + self.assertIn("uno lo da por cerrado y el otro no", salida) + + def test_registro_cerrado_y_board_abierto_falla(self): + """La forma de GAP-020: cerrada en el registro y reabierta en el catálogo.""" + code, salida = correr([("GAP-001", "PENDING", "🟢 RESOLVED")]) + self.assertEqual(code, 1) + self.assertIn("GAP-001", salida) + + def test_deferred_en_el_registro_no_cuenta_como_cerrado(self): + code, _ = correr([("GAP-001", "DONE", "🟡⏳ DEFERRED")]) + self.assertEqual(code, 1) + + def test_id_en_una_sola_superficie_no_es_contradiccion(self): + """Las dos superficies sólo se solapan en parte. Comparar lo no compartido + convertiría cada id exclusivo en un fallo y haría el guard inservible.""" + code, _ = correr([ + ("GAP-001", "DONE", "🟢 RESOLVED"), + ("GAP-002", "DONE", None), + ("GAP-003", None, "🟡 OPEN"), + ]) + self.assertEqual(code, 0) + + +class ContrasteFilaFicha(unittest.TestCase): + """La fila y la ficha del MISMO documento tienen que decir lo mismo.""" + + def test_fila_y_ficha_de_acuerdo_pasan(self): + code, _ = correr([("GAP-001", "DONE", "🟢 RESOLVED")]) + self.assertEqual(code, 0) + + def test_fila_cerrada_y_ficha_bloqueada_falla(self): + """La forma exacta de COH-006, COH-007 y GAP-004 el 2026-08-02: tres filas + `RESOLVED` sobre fichas `BLOCKED`, y en las tres tenia razon la ficha.""" + code, salida = correr( + [("GAP-001", "PENDING", "🟢 RESOLVED")], fichas={"GAP-001": "🔴 BLOCKED"} + ) + self.assertEqual(code, 1) + self.assertIn("el mismo documento se contradice", salida) + + def test_fila_sin_ficha_falla(self): + """Una fila sin ficha legible no es un silencio inocuo: la ficha es donde vive el + detalle, y sin ella el estado no se puede contrastar con nada.""" + code, salida = correr( + [("GAP-001", "DONE", "🟢 RESOLVED")], fichas={"GAP-001": "sin estado legible"} + ) + self.assertEqual(code, 1) + self.assertIn("sin ficha", salida) + + +if __name__ == "__main__": + unittest.main() diff --git a/.harness/scripts/check-translation-language.mjs b/.harness/scripts/check-translation-language.mjs new file mode 100644 index 00000000..336b3849 --- /dev/null +++ b/.harness/scripts/check-translation-language.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * Detect `*.md` files that PRESENT themselves as the English version and are written in + * Spanish. + * + * WHY. `check-bilingual-parity` compares that the pair EXISTS and that both sides carry + * the same number of headers. Neither test can see language, so a file copied to + * `X.md`, relabelled «English (this document)» and left in Spanish passes it — which is + * precisely the state `GAP-011` (C4 topology) and `GAP-017` (Discovery Canvas) describe, + * and why both rows were still true after the parity guard went green. + * + * Measured on 2026-08-01: **10 of 153** paired documents are in this state, including + * `DECISIONS.md` and `MASTER_INDEX.md`, which are canonical root documents. The rows + * named two of the ten; nothing measured the other eight. + * + * HOW, AND WHAT IT COSTS. Language is guessed from stopword frequency — the closed + * function words that dominate any prose and barely overlap between the two languages. + * It is a heuristic, so it is applied with a MARGIN and never on a bare majority: a + * document is only reported when Spanish stopwords outnumber English ones by the factor + * below. Code blocks and inline code are stripped first, because a file full of Spanish + * identifiers is not a Spanish document. + * + * A heuristic that fails LOUDLY on a legitimate file is worse than no guard, because it + * teaches people to silence it. Hence `allow`: a file may declare, with a reason, that + * it is deliberately not translated. + */ + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join, relative, extname } from 'node:path'; +import process from 'node:process'; + +const ROOT = process.cwd(); +const ALLOW_FILE = 'docs/audit/untranslated-allowlist.json'; +const SKIP = new Set(['node_modules', '.git', 'dist', 'bin', 'obj']); + +/** + * The margin. Spanish must outnumber English by this factor before anything is + * reported. 2.0 was chosen after measuring: the ten genuine cases sit at 10× or more + * (`DECISIONS.md` is 47 to 1), while the closest legitimate document sits near parity. + * A bare majority would flag bilingual glossaries and quote-heavy documents. + */ +const MARGIN = 2.0; + +/** Minimum stopword evidence. Below this the sample is too small to judge. */ +const MIN_SIGNAL = 12; + +const ES = /\b(que|para|de|la|del|con|una|los|las|este|esta|como|desde|entre|donde|cuando|debe|puede|sin|por|más|según|cada|todo)\b/gi; +const EN = /\b(the|and|with|this|that|from|which|must|should|when|where|between|without|for|each|every|into|than)\b/gi; + +function walk(dir, out = []) { + let entries; + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + if (e.name.startsWith('.') || SKIP.has(e.name)) continue; + const full = join(dir, e.name); + if (e.isDirectory()) walk(full, out); + else if (extname(e.name) === '.md') out.push(full); + } + return out; +} + +/** Strip code, so identifiers and commands do not vote on the prose's language. */ +function prose(text) { + return text + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`[^`\n]*`/g, ' ') + .replace(/^\s{4,}\S.*$/gm, ' ') + .replace(/https?:\/\/\S+/g, ' '); +} + +function main() { + const allow = existsSync(join(ROOT, ALLOW_FILE)) + ? JSON.parse(readFileSync(join(ROOT, ALLOW_FILE), 'utf8')) + : { allow: [] }; + const allowed = new Map(allow.allow.map((a) => [a.file, a.reason])); + + const suspects = []; + let paired = 0; + + for (const file of walk(ROOT)) { + const rel = relative(ROOT, file); + if (rel.endsWith('.es.md') || rel.startsWith('docs/audit')) continue; + if (!existsSync(file.replace(/\.md$/, '.es.md'))) continue; + paired += 1; + + const body = prose(readFileSync(file, 'utf8')).slice(0, 8000); + const es = (body.match(ES) ?? []).length; + const en = (body.match(EN) ?? []).length; + if (es < MIN_SIGNAL) continue; + if (es < en * MARGIN) continue; + + if (allowed.has(rel)) continue; + suspects.push({ rel, es, en }); + } + + console.log( + `Translation-language check: ${paired} paired document(s), margin ${MARGIN}×, ` + + `${allowed.size} allowlisted.`, + ); + + if (suspects.length === 0) { + console.log('✅ Every English-labelled document reads as English.'); + return; + } + + console.error('\n❌ Documents labelled English that read as Spanish:\n'); + for (const s of suspects.sort((a, b) => b.es - b.en - (a.es - a.en))) { + console.error(` ✖ ${s.rel} (es=${s.es} en=${s.en})`); + } + console.error( + `\nEach is a file whose pair EXISTS and whose headers MATCH, so ` + + `check-bilingual-parity\npasses it. Translate it, or declare it in ${ALLOW_FILE} ` + + `with a reason —\nsilencing this guard without one puts the repository back where ` + + `it was.\n`, + ); + process.exit(1); +} + +main(); diff --git a/DECISIONS.es.md b/DECISIONS.es.md index 82f8b83d..70652f83 100644 --- a/DECISIONS.es.md +++ b/DECISIONS.es.md @@ -17,7 +17,7 @@ _Nota: Las decisiones universales se heredan del Upstream Base ([evolith_arch32] | ID | Título | Operación | Ref Upstream | ADR Local | Notas | | :---- | :---------------------------------- | :----------- | :----------- | :------------------------------------- | :------------------------------------------------------ | -| T-001 | Orquestación de Monorepo con Nx | Adoptar | ADR-0001 | — | Inicializado en `src/` utilizando npm workspaces con Nx para projects discretos | +| T-001 | Orquestación de Monorepo con Nx | Adoptar | ADR-0001 | [T-001](./docs/adrs/T-001-nx-monorepo-orchestration.es.md) | **Corregido 2026-08-01 (`GAP-015`):** la entrada anterior decía «npm workspaces con Nx», y ningún `package.json` del repositorio declara `workspaces` — nunca se usaron. Nx orquesta por GRAFO DE PROYECTOS: `project.json` más los plugins de inferencia `@nx/vite`, `@nx/webpack`, `@nx/eslint` y `@nx/jest` declarados en `src/nx.json`. La razón que decidió es que el repositorio no es homogéneo: `tracker-api` es .NET, y npm workspaces enlaza `node_modules` entre paquetes npm — habría cubierto tres proyectos de cuatro dejando fuera al mayor. | | T-002 | Adopción de Microfrontends en Fase 1| Sobrescribir | N/A | [T-002](docs/adrs/T-002-microfrontends.md) | Desviación de la topología base de Fase 1 para permitir escalabilidad concurrente de UI | | T-003 | Arquitectura Hexagonal (Ports & Adapters) | Adoptar | ADR-0002 (Core Node.js) | — | Capa de dominio sin imports de NestJS, ORM ni SDKs externos | | T-004 | TypeScript estricto como lenguaje primario | Adoptar | ADR-0003 | — | `strict: true` habilitado; imports sin usar prohibidos | diff --git a/DECISIONS.md b/DECISIONS.md index 39e51e47..4db1bce1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -24,7 +24,7 @@ _Nota: Las decisiones universales se heredan del Upstream Base ([evolith_arch32] | ID | Título | Operación | Ref Upstream | ADR Local | Notas | | :--- | :--- | :--- | :--- | :--- | :--- | -| T-001 | Orquestación de Monorepo con Nx | Adoptar | ADR-0001 | — | Inicializado en `src/` utilizando npm workspaces con Nx. | +| T-001 | Orquestación de Monorepo con Nx | Adoptar | ADR-0001 | [T-001](./docs/adrs/T-001-nx-monorepo-orchestration.md) | **Corregido 2026-08-01 (`GAP-015`):** la entrada anterior decía «npm workspaces con Nx», y ningún `package.json` del repositorio declara `workspaces` — nunca se usaron. Nx orquesta por GRAFO DE PROYECTOS: `project.json` más los plugins de inferencia `@nx/vite`, `@nx/webpack`, `@nx/eslint` y `@nx/jest` declarados en `src/nx.json`. La razón que decidió es que el repositorio no es homogéneo: `tracker-api` es .NET, y npm workspaces enlaza `node_modules` entre paquetes npm — habría cubierto tres proyectos de cuatro dejando fuera al mayor. | | T-002 | Adopción de Microfrontends en Fase 1 | Sobrescribir | N/A | [T-002](./docs/adrs/T-002-microfrontends-fase1.md) | Desviación de topología para escalabilidad de UI. | | T-003 | Arquitectura Hexagonal (Ports & Adapters) | Adoptar | ADR-0002 | — | Capa de dominio pura sin dependencias externas. | | T-004 | TypeScript estricto como lenguaje primario | Adoptar | ADR-0003 | — | `strict: true` habilitado. | diff --git a/docs/LOCAL_DEV_WITH_K8S_CORE.es.md b/docs/LOCAL_DEV_WITH_K8S_CORE.es.md index 5796369f..e6c29ada 100644 --- a/docs/LOCAL_DEV_WITH_K8S_CORE.es.md +++ b/docs/LOCAL_DEV_WITH_K8S_CORE.es.md @@ -108,17 +108,20 @@ TRACKER_API_BASE_URL='http://localhost:5100' \ npx nx serve tracker-gateway ``` -Endpoints (prefijo global `api`, por tanto): `GET /api` (raíz), el canal MCP SSE en -`GET /api/mcp/sse` + `POST /api/mcp/messages`, y las seis tools como REST directo en -`POST /api/mcp/tracker_get_assignment`, `…/tracker_read_spec`, `…/tracker_submit_deliverable`, -`…/tracker_get_gate_status`, `…/tracker_log_activity`, `…/tracker_get_upstream_constraints`. +Endpoints (prefijo global `api`, por tanto): `GET /api` (raíz), y la superficie de tools MCP del +Core reexpuesta como REST en `GET /api/core-mcp/tools` y `POST /api/core-mcp/call`. + +> El gateway llegó a hospedar su propio servidor MCP con seis tools `tracker_*` y un canal SSE en +> `/api/mcp/*`. [`T-052`](./adrs/T-052-gateway-consumes-core-single-mcp.es.md) lo eliminó: el +> `evolith-mcp` del Core es la única superficie MCP de cara a agentes y el gateway es *cliente* +> suyo. Esas rutas devuelven `404`, así que el smoke antiguo de esta guía mandaba al lector a un +> endpoint muerto. Smoke: ```bash -curl -s http://localhost:4000/api # gateway vivo -curl -s -X POST http://localhost:4000/api/mcp/tracker_get_assignment \ - -H 'content-type: application/json' -d '{"agentId":"dev"}' # pega a tracker-api:5100 +curl -s http://localhost:4000/api # gateway vivo +curl -s http://localhost:4000/api/core-mcp/tools # las tools del Core, vía gateway ``` ## 5. Arrancar tracker-web en :4200 diff --git a/docs/LOCAL_DEV_WITH_K8S_CORE.md b/docs/LOCAL_DEV_WITH_K8S_CORE.md index 8251614c..72f0150d 100644 --- a/docs/LOCAL_DEV_WITH_K8S_CORE.md +++ b/docs/LOCAL_DEV_WITH_K8S_CORE.md @@ -107,17 +107,19 @@ TRACKER_API_BASE_URL='http://localhost:5100' \ npx nx serve tracker-gateway ``` -Endpoints (global prefix `api`, so): `GET /api` (root), the MCP SSE channel at -`GET /api/mcp/sse` + `POST /api/mcp/messages`, and the six tools as direct REST at -`POST /api/mcp/tracker_get_assignment`, `…/tracker_read_spec`, `…/tracker_submit_deliverable`, -`…/tracker_get_gate_status`, `…/tracker_log_activity`, `…/tracker_get_upstream_constraints`. +Endpoints (global prefix `api`, so): `GET /api` (root), and the Core's MCP tool surface +re-exposed over REST at `GET /api/core-mcp/tools` and `POST /api/core-mcp/call`. + +> The gateway used to host its own MCP server with six `tracker_*` tools and an SSE channel at +> `/api/mcp/*`. [`T-052`](./adrs/T-052-gateway-consumes-core-single-mcp.md) deleted it: the Core's +> `evolith-mcp` is the single agent-facing MCP surface and the gateway is a *client* of it. Those +> routes now `404`, so the old smoke command in this guide sent readers at a dead endpoint. Smoke it: ```bash -curl -s http://localhost:4000/api # gateway alive -curl -s -X POST http://localhost:4000/api/mcp/tracker_get_assignment \ - -H 'content-type: application/json' -d '{"agentId":"dev"}' # hits tracker-api:5100 +curl -s http://localhost:4000/api # gateway alive +curl -s http://localhost:4000/api/core-mcp/tools # the Core's tools, through the gateway ``` ## 5. Run tracker-web on :4200 diff --git a/docs/adrs/T-001-nx-monorepo-orchestration.es.md b/docs/adrs/T-001-nx-monorepo-orchestration.es.md new file mode 100644 index 00000000..6ebdfb4e --- /dev/null +++ b/docs/adrs/T-001-nx-monorepo-orchestration.es.md @@ -0,0 +1,100 @@ +--- +adr: T-001 +title: Nx orquesta el monorepo, y lo hace por grafo de proyectos — no con npm workspaces +status: Accepted +date: 2026-08-01 +tags: [EvolithSatellite, monorepo, build, tooling, nx] +authority: Retro-documenta la decisión registrada como T-001 en DECISIONS.md desde el primer commit del repositorio +relates: [T-006 frontend Vite, T-002 microfrontends fase 1, T-043 Helm supersede a Kustomize] +gaps: [GAP-015] +--- + +# ADR T-001 — Nx orquesta el monorepo + +## Status + +Aceptado, **escrito el 2026-08-01** para cerrar `GAP-015`. La decisión en sí es la más antigua del +repositorio: `DECISIONS.md` lleva `T-001` desde el principio. Lo que nunca llevó fue el *porqué* — +su entrada entera decía *«Inicializado en `src/` utilizando npm workspaces con Nx»*, que enuncia un +resultado y, según resulta, lo enuncia mal. + +## Context + +`GAP-015` dice que a `DECISIONS.md` le falta el racional técnico de T-001. Verificarlo produjo un +segundo hallazgo que la fila no menciona: **la única línea que sí tiene es inexacta.** + +Medido contra el repositorio el 2026-08-01: + +- **Ningún `package.json` de este repositorio declara un campo `workspaces`.** Ni + `src/package.json` ni ningún otro. npm workspaces no se usa, ni se usó nunca. +- Nx descubre proyectos mediante **ficheros `project.json` y plugins de inferencia** — + `@nx/vite/plugin`, `@nx/webpack/plugin`, `@nx/eslint/plugin` y `@nx/jest/plugin` están declarados + en `src/nx.json`, e infieren los targets de la configuración propia de cada proyecto en vez de + una lista de paquetes. +- El workspace tiene cuatro proyectos bajo `src/apps/`: `tracker-api`, `tracker-gateway`, + `tracker-web` y `tracker-web-e2e`. + +Que eso importe es justamente el motivo de escribirlo. Quien leyera la entrada antigua buscaría un +array `workspaces`, no lo encontraría, y concluiría razonablemente que el monorepo está mal +configurado — cuando lo cierto es que está configurado de otra manera. + +## Decision + +**Nx es el orquestador de este monorepo, a través de su grafo de proyectos. npm workspaces no se +usa, y eso es deliberado y no un olvido.** + +Tres razones, en el orden que decidió: + +### 1. El repositorio no es homogéneo, y npm workspaces da por hecho que sí + +`tracker-api` es **.NET**. npm workspaces enlaza `node_modules` entre paquetes npm; no tiene +opinión sobre un `.csproj`, no puede ordenar un `dotnet build` frente a un `vite build`, y no puede +expresar que los tests de contrato del gateway dependen del schema de la API. Adoptarlo habría +cubierto tres proyectos de cuatro y dejado fuera al mayor, precisamente de la herramienta que debe +describir el conjunto. + +El grafo de Nx es agnóstico del lenguaje: un proyecto es lo que se declare como tal, y una +dependencia es lo que se declare. + +### 2. Inferencia antes que declaración, porque una lista de targets escrita a mano deriva + +Los plugins derivan los targets de cada proyecto de la configuración que ese proyecto ya tiene —su +config de Vite, de ESLint, de Jest—. La alternativa es una segunda copia de esa información dentro +de un manifiesto de workspace, y una segunda copia es algo que olvidar. Este repositorio ha gastado +tiempo real exactamente en ese modo de fallo: listas de paquetes de Docker escritas a mano +(`GT-647`), un snapshot de evaluabilidad mantenido a mano (`GT-640`), un contrato transcrito a mano +(`ADR T-038`). Aquí aplica el mismo razonamiento. + +### 3. La ejecución por afectación necesita un grafo de verdad + +`nx affected` vale lo que valgan las aristas de dependencia que es capaz de ver. npm workspaces +expresa «el paquete A depende del B» y nada sobre un `.csproj` que referencia a otro, o un proyecto +e2e que depende de la app que conduce. Sin esas aristas, «ejecuta lo que cambió» degenera en +«ejecuta todo», y con ello se va el valor que justificaba el monorepo. + +## Consequences + +- **Quien contribuya no debe buscar `workspaces` en `package.json`.** Está ausente a propósito. El + grafo lo definen `src/nx.json` y el `project.json` de cada proyecto. +- **Añadir un proyecto es darle un `project.json`** (o una configuración que un plugin de + inferencia reconozca), no añadir una ruta a un array. +- **El coste es una dependencia de herramienta.** Nx está pineado en `^22.7.5`; sus plugins son lo + que sabe construir cada proyecto, así que una subida mayor es un evento de todo el repositorio y + no de un paquete. Ese es el intercambio aceptado a cambio de un solo grafo sobre cuatro proyectos + heterogéneos. +- **npm workspaces sigue disponible** si el repositorio llegara a ser solo npm, cosa que no ocurrirá + mientras la API sea .NET. Cambiarlo pasa por revisar este ADR, no por añadir el campo. + +## Validation + +- `grep -L workspaces $(find . -name package.json -not -path '*/node_modules/*')` devuelve todos los + ficheros — ninguno lo declara. +- `src/nx.json` lista los cuatro plugins de inferencia nombrados arriba. +- `src/apps/tracker-web/project.json` existe; `src/apps/tracker-api` no tiene ninguno, porque su + build lo conduce `dotnet` y sus targets no los infiere un plugin de npm — el caso concreto que + describen las razones 1 y 3. + +## References + +- `DECISIONS.md` → `T-001`, cuya entrada de una línea este ADR corrige y amplía. +- `GAP-015` — la fila que pidió este racional. diff --git a/docs/adrs/T-001-nx-monorepo-orchestration.md b/docs/adrs/T-001-nx-monorepo-orchestration.md new file mode 100644 index 00000000..debd1db5 --- /dev/null +++ b/docs/adrs/T-001-nx-monorepo-orchestration.md @@ -0,0 +1,98 @@ +--- +adr: T-001 +title: Nx orchestrates the monorepo, and it does so through project graphs — not npm workspaces +status: Accepted +date: 2026-08-01 +tags: [EvolithSatellite, monorepo, build, tooling, nx] +authority: Retro-documents the decision recorded as T-001 in DECISIONS.md since the repository's first commit +relates: [T-006 frontend Vite, T-002 microfrontends phase 1, T-043 Helm supersedes Kustomize] +gaps: [GAP-015] +--- + +# ADR T-001 — Nx orchestrates the monorepo + +## Status + +Accepted, **written 2026-08-01** to close `GAP-015`. The decision itself is the repository's +oldest: `DECISIONS.md` has carried `T-001` since the beginning. What it never carried was the +*why* — its whole entry read *"Inicializado en `src/` utilizando npm workspaces con Nx"*, which +states an outcome and, as it turns out, states it wrongly. + +## Context + +`GAP-015` says `DECISIONS.md` lacks T-001's technical rationale. Verifying it produced a second +finding the row does not mention: **the one line the entry does have is inaccurate.** + +Measured against the repository on 2026-08-01: + +- **No `package.json` in this repository declares a `workspaces` field.** Not `src/package.json`, + not any other. npm workspaces is not in use and never was. +- Nx discovers projects through **`project.json` files and inference plugins** — `@nx/vite/plugin`, + `@nx/webpack/plugin`, `@nx/eslint/plugin` and `@nx/jest/plugin` are declared in `src/nx.json`, + and they infer targets from each project's own configuration rather than from a package list. +- The workspace holds four projects under `src/apps/`: `tracker-api`, `tracker-gateway`, + `tracker-web` and `tracker-web-e2e`. + +That mattering is the point of writing this down. Someone reading the old entry would look for a +`workspaces` array, not find one, and reasonably conclude the monorepo was misconfigured — when +what is actually true is that it was configured a different way. + +## Decision + +**Nx is the orchestrator of this monorepo, through its project graph. npm workspaces is not used, +and this is deliberate rather than an omission.** + +Three reasons, in the order that decided it: + +### 1. The repository is not homogeneous, and npm workspaces assumes it is + +`tracker-api` is **.NET**. npm workspaces links `node_modules` between npm packages; it has no +opinion about a `.csproj`, cannot order a `dotnet build` against a `vite build`, and cannot express +that the gateway's contract tests depend on the API's schema. Adopting it would have covered three +projects of four and left the largest one outside the tool that is supposed to describe the whole. + +Nx's graph is language-agnostic: a project is whatever declares itself one, and a dependency is +whatever is declared. + +### 2. Inference over declaration, because a hand-maintained target list drifts + +The plugins derive each project's targets from the configuration that project already has — its +Vite config, its ESLint config, its Jest config. The alternative is a second copy of that +information inside a workspace manifest, and a second copy is a thing to forget. This repository +has spent real time on exactly that failure mode elsewhere: hand-written Docker package lists +(`GT-647`), a hand-maintained evaluability snapshot (`GT-640`), a hand-transcribed contract +(`ADR T-038`). The same reasoning applies here. + +### 3. Affected-based execution needs a real graph + +`nx affected` is only as good as the dependency edges it can see. npm workspaces expresses +"package A depends on package B" and nothing about a `.csproj` referencing another, or an e2e +project depending on the app it drives. Without those edges, "run what changed" degrades to "run +everything", and the value that justified a monorepo goes with it. + +## Consequences + +- **Contributors must not look for `workspaces` in `package.json`.** It is absent on purpose. The + project graph is defined by `src/nx.json` plus each project's `project.json`. +- **Adding a project means giving it a `project.json`** (or a configuration an inference plugin + recognises), not adding a path to a workspace array. +- **The cost is a tool dependency.** Nx is pinned at `^22.7.5`; its plugins are the thing that + knows how to build each project, so a major upgrade is a repository-wide event rather than a + package-local one. That is the trade accepted in exchange for one graph over four heterogeneous + projects. +- **npm workspaces remains available** if the repository ever becomes npm-only, which it will not + while the API is .NET. Revisiting this ADR is the way to change that, not adding the field. + +## Validation + +- `grep -L workspaces $(find . -name package.json -not -path '*/node_modules/*')` returns every + file — none declares one. +- `src/nx.json` lists the four inference plugins named above. +- `src/apps/tracker-web/project.json` exists; `src/apps/tracker-api` has none, because its build is + driven by `dotnet` and its targets are not inferred by an npm plugin — the concrete case that + rules 1 and 3 describe. + +## References + +- `DECISIONS.md` → `T-001`, whose one-line entry this ADR corrects and expands. +- `GAP-015` — the row that asked for this rationale. diff --git a/docs/artifacts/CORE_ARTIFACT_SCHEMAS.es.md b/docs/artifacts/CORE_ARTIFACT_SCHEMAS.es.md index 212eb420..122fccc2 100644 --- a/docs/artifacts/CORE_ARTIFACT_SCHEMAS.es.md +++ b/docs/artifacts/CORE_ARTIFACT_SCHEMAS.es.md @@ -63,6 +63,46 @@ Tres filas merecen atención en vez de un mapeo silencioso: catálogo del Tracker**. Se listan igualmente, porque quien los busque debe enterarse de que existen en vez de concluir que no. +## Cómo lo consume el Tracker + +Hasta el 2026-08-02 este documento *era* todo GAP-020: una persona podía encontrar la forma +canónica y ninguna máquina podía. La ficha se reabrió por dirección de producto exactamente por eso +— un satélite que sólo documenta el enlace no lo ha consumido. + +La correspondencia vive ahora como **dato** en +`Tracker.Application/Governance/ArtifactFieldSchema/CoreArtifactSchemaCatalog.cs`, y cada +`PhaseArtifactDto` que sirve `GET /api/phase-artifact-profiles` lleva su `canonicalSchema`: + +```json +{ "artifactKind": "prd", "label": "PRD", "required": true, + "canonicalSchema": { "schemaId": "https://evolith.dev/schema/prd.schema.json", + "provenance": "core-schema" } } +``` + +`provenance` tiene tres valores y no un id anulable, porque dos de ellos son *ausencias distintas*: + +| valor | qué significa | qué hacer | +|---|---|---| +| `core-schema` | el Core publica una forma canónica | seguir el `$id` | +| `tool-output` | el Core no publica ninguna, a propósito | leer el formato propio de la herramienta | +| `none` | nada upstream lo cubre | el `ArtifactFieldSchema` del tenant es la única autoridad | + +Juntar `tool-output` y `none` en un `null` borraría la diferencia entre *un hueco que alguien +debería cerrar* y *una decisión ya tomada*. + +La tabla de este documento y la de esa clase se mantienen idénticas mediante +`CoreArtifactSchemaCatalogTests`, que parsea el markdown de arriba y compara en los dos sentidos: +falla una fila de aquí que nadie cableó, y falla también una entrada de allí que nadie documentó. +Se comprobó rompiéndolo en ambas direcciones, no se supuso. + +**Sigue siendo un puntero y no una restricción, y ahora eso es una decisión ratificada, no un +olvido.** La reapertura de GAP-020 pedía usar estos esquemas para validar lo que rellenan personas +y agentes. El **2026-08-02 dirección de producto retiró esa petición**: según +[`T-056`](../adrs/T-056-three-layer-separation.md) la validación de contenido es configuración del +tenant y no código del motor, así que el Tracker ofrece la forma canónica y nunca la impone. Quien +lea esto más adelante debe tratar el cablear validación aquí como una decisión a revertir, no como +un hueco que rellenar. + ## Los artefactos que no tienen esquema, y por qué Diez de los veinticuatro no están cubiertos, y se dividen en dos grupos que piden respuestas diff --git a/docs/artifacts/CORE_ARTIFACT_SCHEMAS.md b/docs/artifacts/CORE_ARTIFACT_SCHEMAS.md index bd18c873..17f7ed36 100644 --- a/docs/artifacts/CORE_ARTIFACT_SCHEMAS.md +++ b/docs/artifacts/CORE_ARTIFACT_SCHEMAS.md @@ -61,6 +61,45 @@ Three rows deserve attention rather than a silent mapping: Tracker's catalog**. They are listed anyway, because a reader looking for them should find out they exist rather than conclude they do not. +## How the Tracker consumes this + +Until 2026-08-02 this document *was* the whole of GAP-020: a reader could find the canonical shape, +and no machine could. The row was reopened by product direction for exactly that reason — a +satellite that only documents the link has not consumed it. + +The correspondence now lives as **data** in +`Tracker.Application/Governance/ArtifactFieldSchema/CoreArtifactSchemaCatalog.cs`, and every +`PhaseArtifactDto` served by `GET /api/phase-artifact-profiles` carries a `canonicalSchema`: + +```json +{ "artifactKind": "prd", "label": "PRD", "required": true, + "canonicalSchema": { "schemaId": "https://evolith.dev/schema/prd.schema.json", + "provenance": "core-schema" } } +``` + +`provenance` has three values and not a nullable id, because two of them are *different absences*: + +| value | meaning | what a reader should do | +|---|---|---| +| `core-schema` | the Core publishes a canonical shape | follow the `$id` | +| `tool-output` | the Core deliberately publishes none | read the tool's own format | +| `none` | nothing upstream covers it | the tenant's `ArtifactFieldSchema` is the only authority | + +Collapsing `tool-output` and `none` into `null` would erase the distinction between *a gap someone +should close* and *a decision already made*. + +The table in this document and the table in that class are kept identical by +`CoreArtifactSchemaCatalogTests`, which parses the markdown above and compares both ways — a row +here that nobody wired fails, and an entry there that nobody documented fails too. This was +verified by breaking it in both directions, not assumed. + +**It remains a pointer and not a constraint, and that is now a ratified decision and not an +omission.** GAP-020's reopen asked for these schemas to be used to validate what people and agents +fill in. On **2026-08-02 product direction withdrew that ask**: per +[`T-056`](../adrs/T-056-three-layer-separation.md) content validation is the tenant's configuration +and not engine code, so the Tracker offers the canonical shape and never enforces it. Anyone +reading this later should treat wiring validation here as a decision to reverse, not a gap to fill. + ## The artifacts that have no schema, and why Ten of the twenty-four are not covered, and they split into two groups that call for different diff --git a/docs/audit/falsifiable-claims.json b/docs/audit/falsifiable-claims.json new file mode 100644 index 00000000..8433662a --- /dev/null +++ b/docs/audit/falsifiable-claims.json @@ -0,0 +1,36 @@ +{ + "$comment": "Machine-checkable claims made by OPEN rows of the gap register. See .harness/scripts/check-falsifiable-claims.mjs and the README section it points at.", + "policy": { + "why": "Three rows in one session claimed work that was already done — GT-588's third criterion, COH-005 and COH-008. The board records audit findings and nothing re-checks them against the code, so they age in silence while still directing priorities. Every one of the three was caught by reading the code before starting; a reader who trusted the row would have built something that already existed.", + "scope": "ONLY claims a machine can falsify without judgement. A row saying `X does not exist` is checkable; a row saying `the design is unclear` is not, and must not be listed here. Absence of an entry means nothing — this file is opt-in evidence, never a completeness claim.", + "notProse": "Probes are DATA, never regular expressions over the row's prose. `50-validate-gap-claim` in the Core repository read prose to infer intent, produced nine false attributions in one day, and taught authors to delete true sentences to appease it. That failure is not repeated here: a claim is checked only when someone writes down, explicitly, the symbol and the paths that would refute it." + }, + "claims": [ + { + "id": "GAP-022", + "claim": "SPACE metrics are not defined anywhere.", + "probe": { + "kind": "symbol-absent", + "symbols": [ + "SPACE framework", + "SPACE metrics" + ], + "paths": [ + "docs", + "reference" + ], + "extensions": [ + ".md" + ] + }, + "note": "The row claims absence of a definition. Any document that defines the framework refutes it." + } + ], + "withdrawn": [ + { + "id": "COH-012", + "probeWas": "symbol-absent on 'Scenario: Delete' / 'Scenario: Read' / 'Escenario: Eliminar'", + "why": "The probe encoded a WRONG PREMISE and would have stayed green forever while rewarding the wrong fix. It asked for a Delete scenario; the Discovery aggregate has no Delete and must not have one — an initiative is superseded into another (Supersede, status consolidated, BR-DIS-005) because its lineage is governance evidence. Adding 'Scenario: Delete' would have turned the probe green by describing a system that does not exist. Recorded rather than quietly removed: the lesson is that a falsifiable probe inherits whatever the row assumed, and a row can assume something false." + } + ] +} diff --git a/docs/audit/tracker-gap-reference-catalog.md b/docs/audit/tracker-gap-reference-catalog.md index 23fab29b..de9a4d44 100644 --- a/docs/audit/tracker-gap-reference-catalog.md +++ b/docs/audit/tracker-gap-reference-catalog.md @@ -37,7 +37,7 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an **Title:** El catálogo de formatos de artefacto por fase no está sincronizado con el Core ni parametrizado por tenant. -- **Status:** `PENDING` +- **Status:** `DONE` - **Purpose:** Hacer que el Tracker exponga los artefactos definidos por el Core para cada fase y gate, con overrides controlados por tenant. Esta es la base del producto: las personas y agentes llenan artefactos que luego el Core evalúa. - **Evidence:** El Core conserva gates `gate-f1..f5` con artefactos requeridos y schemas bajo `reference/governance/sdlc/gates/`. El Tracker tiene catálogos derivados y stand-ins, y `GAP-020` queda reabierto para materializar el consumo de URLs de esquema en el catálogo tenant-aware. `GT-479` ya corrigió la premisa de discovery: sí tiene artefactos y debe formar parte de la lámina SDLC. - **Impact:** Una iniciativa puede avanzar con entregables locales que no coinciden con los que el Core espera evaluar. @@ -46,10 +46,39 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P0 · **Complexity:** M - **Proposed fix:** Crear un `CoreArtifactFormatCatalog` que sincronice el catálogo por fase desde el Core, materialice una vista tenant-aware y permita overrides locales declarados, auditables y no destructivos. - **Acceptance criteria:** - - [ ] Discovery, design, construction, QA y release usan un catálogo de artefactos derivado del Core. - - [ ] Cada tenant puede marcar artefactos como requeridos, opcionales o no aplicables sin modificar el estándar base. - - [ ] El scorecard y las compuertas leen la misma fuente de catálogo. - - [ ] `GAP-020` queda re-evaluado a la luz del catálogo vivo. + - [x] **Parcial (construction/QA/release):** el expediente operativo (`GetPhaseArtifact`) ya se contrasta contra el catálogo derivado del Core, resolviendo por `IPhaseArtifactProfileSource` — la misma costura que lee `/phase-artifact-profiles`. Discovery y Design conservan sus verticales a medida y NO están cubiertas; el camino genérico solo cubre esas tres fases (`SdlcPhases.GenericGatePhases`). + - [x] Cada tenant puede marcar artefactos como requeridos, opcionales o no aplicables sin modificar el estándar base — `ArtifactApplicabilityRule` en `GatePolicy` (por tenant y fase), configurable por `PUT /gate-policies/{phase}`, persistida en `artifact_applicability` (jsonb, migración aditiva con `Down`). + +**«Sin modificar el estándar base» es cierto en el CÓDIGO, no sólo en la ficha.** `IPhaseArtifactProfileSource` sigue siendo tenant-agnóstico: sirve lo que el Core dice y no recibe tenant. El overlay se aplica encima al servir, en `TenantPhaseArtifactCatalog`, así que no existe ningún camino por el que la configuración de un tenant edite el catálogo del Core. Hay prueba de que aplicar el overlay no muta el perfil de entrada. + +**Decisiones con prueba:** + +- **Marcar no es borrar.** Un artefacto `not-applicable` se sigue sirviendo, con la marca puesta. Quitarlo dejaría a quien lea el catálogo sin saber que el estándar lo pide y que este tenant lo descartó — dos hechos distintos y ambos auditables. +- **Se distingue quién lo exige.** Viajan `tenantApplicability` y `standardRequired` por separado; servir sólo el resultado dejaría a un auditor sin poder separar «el Core lo exige» de «este tenant lo exige». +- **Una marca sobre algo que el Core no declara se ignora.** Añadir artefactos propios del tenant es otra capacidad y otra ficha; aceptarla aquí haría que el catálogo dejara de ser «el estándar del Core con una opinión encima» y pasara a ser dos catálogos mezclados sin poder distinguirlos. +- **Un upsert sin overlay lo deja vacío**, no conserva lo que había: si no, sería imposible devolver un artefacto al estándar. +- **Una aplicabilidad inválida se rechaza al guardar**, por la misma razón que en la matriz de señales. + +**Las TRES superficies leen el mismo catálogo con overlay**, que era el riesgo real de dejarlo a medias: el endpoint de perfiles, el contraste del expediente (criterio 1) y el `artifacts.required` que se le manda al Core en la evaluación. Mandar al Core el estándar pelado mientras el Tracker gobierna con el overlay habría sido, otra vez, dos superficies discrepando sobre el mismo hecho. + - [x] El scorecard y las compuertas leen la misma fuente de catálogo — `ScorecardProjection` consulta ya el catálogo tenant-aware, igual que el expediente, el endpoint de solo lectura y el payload que va al Core. + +**El falso verde que esto cerró (2026-08-03):** los hechos del scorecard salían SÓLO de lo entregado (`artefacto.Items`), así que un artefacto que el estándar exige y nadie entregó era **invisible**. Una fase con el expediente vacío no puntuaba mal: puntuaba `null` — «nada exigible todavía» — mientras el catálogo del tenant pedía tres cosas. El cuadro decía que no había nada que reprochar justo cuando faltaba todo. + +**Lo que NO se tocó, y es correcto:** un exigido sin entregar DENTRO de plazo sigue en `pendiente`, no en rojo. Penalizar antes del vencimiento sería castigar por adelantado, y esa regla ya estaba bien razonada. La prueba fija el momento en que debe doler: vencido y sin entregar. + +**Se puntúa contra el catálogo CON overlay**, no contra el estándar pelado: castigar a un tenant por no entregar lo que él mismo declaró no aplicable sería exactamente lo contrario de lo que CP-02 criterio 2 acaba de permitirle. + - [x] `GAP-020` queda re-evaluado a la luz del catálogo vivo. + +**Re-evaluación de `GAP-020` (2026-08-03).** Aquella ficha se cerró el 2026-08-02 dejando dos cosas fuera, y el catálogo vivo cambia el estado de ambas: + +- **«Persistencia/caché tenant-aware de las referencias de schema» — RESUELTA, y no como se preveía.** GAP-020 la aparcó en `GAP-004` por estar «bloqueada aguas arriba». El bloqueo desapareció: `CoreSyncPhaseArtifactProfileSource` sincroniza del Core y **cachea en memoria** —deliberadamente, porque `GetProfiles()` es síncrono y está en el camino de una decisión de gobierno—, trae `canonicalSchema` por artefacto distinguiendo `core-schema` de `tool-output` y de `none`, y el overlay del tenant lo conserva intacto. O sea: las referencias se consumen, se cachean y se sirven tenant-aware. No hacía falta persistirlas en base: la autoridad es el Core y una copia en disco habría sido una tercera versión que mantener sincronizada. +- **«Usarlas para validar lo que se rellena» — SIGUE RETIRADA, y por la misma razón.** T-056: la validación de contenido la configura el tenant y no la cablea el motor. El catálogo vivo no cambia esa decisión; si acaso la refuerza, porque ahora el tenant puede marcar aplicabilidad (criterio 2) sin que nadie interprete el contenido por él. + +`GAP-020` no se reabre: lo que dejó pendiente o está hecho o sigue deliberadamente fuera de alcance. + +**Hallazgo que motivó el cambio (2026-08-03):** el Tracker tenía DOS vocabularios de artefactos que nunca se encontraban. `/phase-artifact-profiles` derivaba del Core pero nada operativo lo consultaba; el expediente que la gente llena aceptaba cualquier cadena en `type` y arrancaba con `Items` vacío. Una fase podía completarse entera con artefactos que el Core no declara sin que ninguna superficie lo dijera — que es el **Risk** de esta ficha al pie de la letra. + +**Reporta, no impone (T-056).** El bloque `coreCatalog` dice las dos direcciones (lo declarado que falta, y lo presente que el Core no declara) y ahí se detiene. Nada rechaza un ítem ni cambia un estado: qué exige una compuerta lo configura el tenant, no lo cablea el motor. Se distingue además «no había catálogo para la fase» de «el catálogo no pide nada»: la primera omite el bloque en vez de devolver una lista vacía. - **Dependencies:** CP-01. #### CP-03 @@ -75,7 +104,7 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an **Title:** El Tracker envía un `EvaluationContext` pobre que no representa el paquete real de fase. -- **Status:** `PENDING` +- **Status:** `DONE` - **Purpose:** Construir el contexto canónico que el Core necesita para evaluar fase, artefactos, evidencia, autoría, revisión de repositorio y señales de calidad. El Core debe recibir insumos tipados, no una bolsa opaca en `passthrough`. - **Evidence:** `CoreEvaluationGateway.BuildCorePayload` envía `kinds`, `workspaceRef`, `phaseId`, `tenant`, `product`, `initiative` y `passthrough`; `ContextData` se transforma en claves `ctx_*`. No se envían `schemaVersion`, `requester`, `repositoryRevision`, `evidence`, `qualitySignals`, `repoFacts`, `baselineRepoFacts` ni artifact facts de la fase. - **Impact:** El Core sólo puede emitir una evaluación básica y pierde contexto para recomendaciones e inteligencia. @@ -84,10 +113,26 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Implementar un `CoreEvaluationContextBuilder` que arme `EvaluationContext` desde iniciativa, fase, artefactos, evidencia, actor, revisión de repositorio y configuración tenant. - **Acceptance criteria:** - - [ ] El payload enviado al Core declara `schemaVersion`. - - [ ] El payload incluye `requester` y `repositoryRevision` cuando estén disponibles. - - [ ] Los artefactos y evidencia de la fase viajan como campos tipados. - - [ ] Los campos técnicos opcionales sólo viajan cuando la política tenant los habilita. + - [x] El payload enviado al Core declara `schemaVersion` — `ContextSchemaVersion`, configurable, espejo de `EVALUATION_CONTEXT_SCHEMA_VERSION`. Es un hecho del LLAMANTE («hablo esta versión»), no una suposición sobre el Core; el manifiesto de capacidades no servía porque su `schemaVersion` es la del contrato de RESULTADO. + - [x] El payload incluye `requester` y `repositoryRevision` cuando estén disponibles — tipados. `repositoryRevision` DEJA de viajar dentro de `passthrough`, que el Core echa de vuelta sin interpretar: el dato estaba y no participaba en la evaluación. Sin copia, porque no había consumidor (verificado: el Core sólo lee `evaluationDate` del passthrough; ni el Tracker ni la web leen el que devuelve). + - [x] Los artefactos de la fase viajan como campos tipados — `artifacts` en la forma que el Core declara (`ArtifactContext`): `required` del catálogo DERIVADO DEL CORE que cableó CP-02 (la misma costura, no una segunda copia) y `presented` del expediente que la gente llena. **`evidence` NO se manda**, y es deliberado: en el Tracker la evidencia son los slots de `GateSubmission`, otro agregado que este endpoint no tiene; mandar los ítems del expediente bajo esa clave los renombraría a un concepto que no son. + +**Lo arma el SERVIDOR, no el llamante (2026-08-03).** Las dos fuentes se inyectan en el gateway y `EvaluateContextRequest` sigue sin campo alguno de artefactos — hay una prueba sobre el TIPO que se rompe si alguien lo añade. Si viajara en el cuerpo, un llamante podría declarar como presentado un artefacto que no existe y el Core lo evaluaría como si estuviera. + +**Tres decisiones, cada una con prueba:** + +- **Un hueco vacío no cuenta como presentado.** Sólo viajan los ítems con `ref` o contenido; declarar un slot en blanco le diría al Core que el artefacto está. +- **El contenido inline no sale del perímetro.** Viaja la referencia, nunca el cuerpo: el Core evalúa hechos DECLARADOS, y volcar el documento aquí sacaría del tenant algo que nadie pidió mandar. +- **Sin ninguna de las dos mitades no se manda el bloque.** Dos listas vacías afirmarían «este gate no exige nada y no se presentó nada», que es una evaluación distinta de «no pude armar el paquete». Igual que un `initiativeId` opaco —que el contrato permite— no es un error: no hay expediente que buscar y se dice que no. + +**Cableado verificado, no supuesto:** el repositorio es `Scoped` y el cliente tipado transitorio, así que hay una prueba que resuelve el gateway DENTRO de un scope y comprueba que el contenedor rellena las dos dependencias. Se confirmó quitando el registro: la prueba cae. + - [x] Los campos técnicos opcionales sólo viajan cuando la política tenant los habilita — `SendOptionalTechnicalContext`, apagado por defecto. **Y hoy no hay nada que enviar:** el Tracker no produce hechos estructurales ni señales de calidad, no hay indexador ni proveedor detrás de ningún puerto suyo. Mandarlos vacíos le diría al Core «miré y no hay nada» cuando la verdad es «no miré», y el Core trata la ausencia como «sin hallazgos estructurales», nunca como un fallo. + +**Bloqueo previo, resuelto (2026-08-03):** el criterio 2 era IMPOSIBLE hasta hoy. El Core corre un `ValidationPipe` global con `forbidNonWhitelisted: true` y su `EvaluationContextDto` no declaraba `requester` ni `repositoryRevision`, así que enviarlos habría hecho 400 la evaluación entera — no llegar recortados. Se registró y arregló como `GT-652` en `evolith_arch32` (PR #401). + +**Acoplamiento de despliegue, dicho en vez de descubierto:** por lo anterior, el orden es **Core primero**. `SendTypedContextFacts` (encendido por defecto) devuelve el payload a la forma mínima anterior, así que si un Tracker apunta a un Core previo a GT-652 el arreglo es una variable de entorno y no un despliegue de vuelta atrás. Hay prueba de las dos posiciones del interruptor. + +**Ausente es ausente:** sin revisión conocida el campo no se manda, en vez de mandar `HEAD` de relleno — que sería indistinguible de una revisión real llamada HEAD. Y `requester` no viaja si no hay actor: uno vacío afirmaría una atribución que no tenemos. `actorType` va como `user` porque el Tracker sólo autentica personas y servicios; afirmar `agent` sería adivinar. - **Dependencies:** CP-01, CP-02, CP-06. #### CP-05 @@ -103,17 +148,29 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Crear DTOs canónicos de resultado, persistir el resultado completo o una proyección versionada, y exponer recomendaciones y acciones en el workspace de fase y scorecard. - **Acceptance criteria:** - - [ ] El ledger conserva `rulesExecuted`, `policiesApplied`, `recommendations`, `requiredActions`, `gaps`, `risks` y `qualitySignals`. - - [ ] La UI muestra recomendaciones accionables por artefacto/fase. - - [ ] Otros result kinds no se pierden aunque el gate sólo use una parte. - - [ ] El resultado conserva `correlationId`, `schemaVersion` y `versions`. + - [ ] El ledger conserva `rulesExecuted`, `policiesApplied`, `recommendations`, `requiredActions`, `gaps`, `risks` y `qualitySignals`. **Parcial, y el reparto no era el que decía la ficha.** El ledger PERSISTIDO (`CoreEvaluationTransactionProps`, camino de ingest GT-604) ya conservaba `RulesExecuted`, `Violations`, `RequestedBy`, `Versions`, `CorrelationId` y `SchemaVersion`; lo que se perdía era el camino del GATEWAY (`ParseVerdict`), que se quedaba con `overallVerdict` y `results.gate` y tiraba el resto. Ese camino ya conserva los siete campos TIPADOS en `ResponseResult.Intelligence`. **Cerrado (2026-08-03, ruta 1):** el asiento del gateway ya conserva lo que el Core devolvió. El hallazgo al abrirlo fue mejor que el enunciado: el problema no era que faltaran columnas, sino que las que YA EXISTÍAN (`correlation_id`, `schema_version`, `overall_verdict`, `outcome`, `rules_executed`, `evaluated_at`, `versions`) sólo las llenaba el camino de ingesta — una fila nacida en el gateway las dejaba vacías aunque la respuesta del Core las trajera. Una columna que existe y está vacía para media población es peor que no tenerla: quien consulta no distingue «no lo hubo» de «este camino no lo guardó». `CompleteFromCore` las llena y marca `producer_surface = tracker-gateway`, así que una fila del gateway y una depositada por la CLI se distinguen en la propia tabla. + +Lo que no tiene columna propia (`policiesApplied`, `risks`, `recommendations`, `requiredActions`, `qualitySignals`, los kinds) se conserva en el jsonb `response_data` que ya existía: **sin migración y sin esquema especulativo**. Las columnas tipadas que hay se tiparon cuando alguien necesitó consultarlas (enrutar por dueño, contar bloqueantes); cuando la UI del criterio 2 diga qué consulta de verdad, se tipa eso y no antes. + +No se reutilizó `CoreEvaluationDeposit`: es la forma de la INGESTA y exige violaciones con huella y dueño responsable, que el gateway no recibe. Rellenarlos habría sido justo lo que esa forma prohíbe por escrito — inventar una acusación. + - [ ] La UI muestra recomendaciones accionables por artefacto/fase. **Sin empezar** (frontend). + - [x] Otros result kinds no se pierden aunque el gate sólo use una parte. Los diez que el Tracker no interroga (`artifact`, `evidence`, `architecture`, `blueprint`, `topology`, `checkpoint`, `deployment`, `compliance`, `design`, `phaseArtifacts`) se conservan en crudo vía `JsonExtensionData`, con `kindsPresent` para poder preguntar qué evaluó el Core sin abrir el JSON. `gate` sigue tipado: conservar los demás no degrada el que sí se interroga. + +**Por qué crudo y no diez DTOs (2026-08-03):** tipar diez formas que ningún consumidor lee sería inventar contrato y mantenerlo sin uso. La diferencia con la bolsa opaca que EAG-03 eliminó es el consumidor: aquellos `gaps` los NECESITABA la decisión de la compuerta, y por eso tenían que ser interrogables; estos no los lee nadie todavía y el requisito es no perderlos. Cuando un consumidor pida uno, se tipa ese — por demanda. Además, un espejo enumerado a mano volvería a perder datos, en silencio, en cuanto el Core publicara un kind nuevo; hay una prueba con un kind inexistente que lo fija. + - [x] El resultado conserva `correlationId`, `schemaVersion` y `versions` — en el ledger ya estaba; ahora también en la respuesta del gateway. + +**Por qué campos tipados y no el saco `Summary` (2026-08-03):** el propio fichero ya llevaba escrita la lección de EAG-03 unas líneas más abajo — una bolsa opaca «llegaba, se guardaba y no se podía interrogar». Un dato que no se puede consultar no participa en ninguna decisión, así que habría sido conservar la forma y no el valor. + +**Conserva, no decide (T-056).** El mapeo es total y sin opinión: no filtra por severidad, no reordena y no interpreta. Una `requiredAction` que el motor marca `blocking` se conserva tal cual y NO cambia el veredicto — qué bloquea una fase de este tenant es CP-06. Hay una prueba dedicada a esa propiedad, y el default de un `blocking` ausente es `false`, porque inventar un bloqueo que el motor no declaró es la dirección peligrosa. + +**Los nombres se verificaron contra el Core antes de escribirlos**, en `core-domain/evaluation/contracts/evaluation-result.ts`. Un DTO con campos que el Core no publica nunca se llena y se lee como cobertura, igual que una alerta sobre una métrica que nadie emite. - **Dependencies:** CP-01, CP-04, CP-06. #### CP-06 **Title:** Falta una matriz tenant-aware que convierta resultados del Core en decisión de gate. -- **Status:** `PENDING` +- **Status:** `DONE` - **Purpose:** Separar con claridad qué bloquea una compuerta, qué es requisito local del tenant y qué es recomendación consultiva. El Core define estándar e inteligencia; el Tracker decide el avance de fase según configuración del tenant. - **Evidence:** El Core ahora emite más dimensiones y result kinds que `gate`. El Tracker conserva la lógica de gate principalmente sobre `overallVerdict`, resultados gate y políticas locales existentes. No hay una matriz explícita que diga, por tenant y fase, cómo interpretar `phaseArtifacts`, `qualitySignals`, `design`, `architecture`, `drift`, `knowledge` o recomendaciones. - **Impact:** La frontera de autoridad queda borrosa: una señal técnica opcional puede bloquear indebidamente, o una obligación del tenant puede quedar como warning. @@ -122,10 +179,21 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P0 · **Complexity:** M - **Proposed fix:** Extender la configuración de gate por tenant con una matriz `CoreSignalPolicy`: required, blocking, advisory, hidden y not-applicable por phase, artifact kind, result kind y severity. - **Acceptance criteria:** - - [ ] Un tenant puede decidir qué señales Core bloquean cada fase. - - [ ] Las capacidades técnicas quedan advisory por defecto. - - [ ] La decisión de avanzar fase muestra qué bloqueó y qué sólo recomendó. - - [ ] La auditoría guarda la configuración aplicada al momento de la decisión. + - [x] Un tenant puede decidir qué señales Core bloquean cada fase — `CoreSignalRule` en `GatePolicy` (por tenant y fase), configurable por `PUT /gate-policies/{phase}` y persistida en la columna `core_signal_rules`. Disposiciones: `blocking`, `advisory`, `hidden`, `not-applicable`, con `minSeverity` opcional. + - [x] Las capacidades técnicas quedan advisory por defecto — es LA propiedad de seguridad de la ficha y tiene prueba propia. Una señal sin regla se informa y no bloquea; el default contrario convertiría cada dimensión nueva que el Core publique en un bloqueo que ningún tenant pidió, que es justo lo que T-056 prohíbe. + - [x] La decisión de avanzar fase muestra qué bloqueó y qué sólo recomendó — `Blocking` y `AdvisoryOnly` en el resultado, y sólo `blocking` se pliega en las razones de incumplimiento de `GateSubmission.Evaluate`. + - [x] La auditoría guarda la configuración aplicada al momento de la decisión — `AppliedCoreSignalRules` viaja COPIADA con la entrega, no referenciada. La matriz de un tenant cambia; sin esa copia, auditar exigiría creer que la configuración de hoy es la de entonces. + +**Decisiones con prueba (2026-08-03):** + +- **El default es `advisory`.** Equivocarse hacia «no bloquea» es recuperable leyendo el informe; hacia «bloquea» para una fase en curso, no. +- **Gana la regla más específica, no la primera de la lista.** Un desempate por posición dejaría el resultado a merced de cómo el tenant ordenó su lista, que no es una decisión que nadie haya tomado conscientemente. Se prueba en los dos órdenes: con uno solo, un evaluador «primera que encaje» pasaría. +- **Una severidad desconocida no se coacciona.** Subirla fabricaría bloqueos, bajarla escondería hallazgos; queda fuera de las reglas con mínimo y sigue viéndose por el default. +- **Una compuerta sin matriz se comporta exactamente como antes.** Sin esto, añadir la ficha habría cambiado la conducta de cada compuerta ya configurada sin que ningún tenant pidiera nada. +- **Un upsert sin `coreSignalRules` deja la lista vacía**, no conserva lo que había: un upsert que conservara reglas no enviadas haría imposible quitar un bloqueo, y quitarlo es la operación que un tenant necesita poder hacer deprisa. +- **Una regla con disposición inválida se rechaza al guardar.** Almacenarla e ignorarla luego daría un tenant que cree haber configurado un bloqueo y una compuerta que no lo aplica. + +**Lo que NO cubre:** la superficie web (`tracker-web`) no expone todavía la matriz, así que hoy se configura por API. El evaluador se pliega en `GateSubmission.Evaluate`; conectar las observaciones reales del Core —las que CP-05 ya conserva en `Intelligence`— al `CoreSignalObservation` de entrada es el siguiente tramo y aún no está hecho: hoy el llamante construye las observaciones. - **Dependencies:** CP-02, CP-04, CP-05. #### CP-07 @@ -141,10 +209,28 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Añadir una proyección `CoreEvaluationAttachment` o equivalente que relacione depósitos Core con iniciativa/fase/artefacto/gate por correlación, repository revision, operation metadata o referencia explícita del productor; luego mostrarlo como evidencia/recomendación advisory hasta que la política tenant lo eleve. - **Acceptance criteria:** - - [ ] Una fila `ingested` puede adjuntarse a una iniciativa y fase sin copiar ni reinterpretar el veredicto. - - [ ] El workspace de fase muestra depósitos Core relevantes como evidencia o recomendación. - - [ ] La política tenant decide si un depósito adjunto es advisory, requerido o bloqueante. - - [ ] RoboSoft prueba un depósito Core que aparece en el expediente SDLC correspondiente. + - [x] Una fila `ingested` puede adjuntarse a una iniciativa y fase sin copiar ni reinterpretar el veredicto — `CoreEvaluationAttachment` y `AttachTo`/`Detach` en el agregado, `POST`/`DELETE /core-evaluation-transactions/{id}/attachment`, columna `attachment` (jsonb, migración aditiva con `Down`). Hay prueba de que `overallVerdict`, `outcome`, `violations` y el conteo de bloqueantes quedan intactos: si adjuntar pudiera ajustar el veredicto, el ledger dejaría de ser lo que el productor depositó. + - [ ] El workspace de fase muestra depósitos Core relevantes como evidencia o recomendación. **Sin empezar** (front). El tipo `CoreEvaluationAttachmentDto` ya está declarado en `types.ts`, así que el dato llega; falta la pantalla. + - [x] La política tenant decide si un depósito adjunto es advisory, requerido o bloqueante — `CoreDepositSignals` traduce un depósito ADJUNTO a observaciones y `CoreSignalGateEvaluator` las pasa por la matriz de CP-06 dentro de `EvaluateGateSubmission`. **Era el hueco que dejaba a la matriz sin entrada:** nada producía `CoreSignalObservation`, o sea el defecto de «construido pero no cableado» en piezas propias. + +**Traduce, no decide.** No mira umbrales ni normaliza severidades; las reglas las aplica `CoreSignalEvaluator` después. Decisiones con prueba: + +- **Sólo los depósitos ADJUNTOS entran, y filtrados por iniciativa Y fase.** Uno suelto no habla de ninguna fase, y uno colgado de la misma fase de otra iniciativa no tiene nada que decir sobre esta compuerta. +- **La categoría de la violación es la señal cuando la hay** — así un tenant escribe «las violaciones de arquitectura que me depositen bloquean, las de estilo no». Sin categoría cae en `core-deposit`, un nombre estable y documentado: inventarle una sería adivinar de qué habla. +- **El veredicto se observa aunque no haya violaciones tipadas.** No es redundante: un productor puede devolver FAIL y nada más, y sin esa observación el caso sería invisible para la matriz. +- **Una violación congelada no se vuelve a observar.** Es una que el tenant ya aceptó, y el ledger ya la excluye de su conteo de bloqueantes; contarla aquí haría que dos superficies del mismo producto discreparan sobre el mismo hecho. +- **Sin matriz configurada devuelve `null`** y la compuerta se decide como siempre — igual que los criterios de T-031. Con matriz y sin depósitos SÍ evalúa, con cero observaciones: «nada bloqueó» es cierto y queda en la traza, mientras que devolver `null` borraría de la auditoría que la matriz estaba activa. + - [ ] RoboSoft prueba un depósito Core que aparece en el expediente SDLC correspondiente. **Sin empezar** (robot de integración). + +**Por qué un vínculo y no un campo del depósito (2026-08-03):** el payload de ingesta (`EvaluationIngestPayload`) NO lleva iniciativa ni fase, y no debería: quien deposita es la CLI, el MCP o el drift gate, y ninguno sabe a qué iniciativa del Tracker pertenece lo que evaluó. Adjuntar es por tanto una decisión de gobierno posterior, tomada por alguien que sí lo sabe. + +**Decisiones con prueba:** + +- **Adjuntar no toca el veredicto.** Es lo que separa un vínculo de una segunda opinión. +- **No cruza la frontera de tenant, en las dos direcciones.** Adjuntar es la operación que une dos agregados, o sea justo donde se colaría una fuga entre clientes; un depósito ajeno se responde NO ENCONTRADO y no «prohibido», porque distinguirlos convertiría el endpoint en una forma de averiguar qué ids existen en otro cliente. +- **Permiso propio, `tracker:core-transaction:attach`**, aparte del de ingesta: depositar es una operación de MÁQUINA y adjuntar una decisión de GOBIERNO. El handler de máquina no lo concede, así que una clave filtrada no puede colgar evidencia en la fase que alguien intenta cerrar. +- **Se puede desadjuntar, no desdepositar.** Adjuntar mal es un error humano corriente; lo que no se deshace es el registro de lo que el Core dijo. +- **Ausente es ausente:** un `artifactKind` en blanco se guarda como nulo — «no se dijo de qué artefacto habla» no es «habla del artefacto llamado ""». - **Dependencies:** CP-05, CP-06, GT-604/T-055. #### CP-08 @@ -200,10 +286,18 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Añadir un robot `core-sdlc-parity` que ejecute una iniciativa multi-fase, consuma formatos vivos, produzca artefactos, sincronice/evalúe con Core y afirme decisiones tenant. - **Acceptance criteria:** - - [ ] El robot crea una iniciativa y recorre al menos discovery→design→construction con artefactos Core. - - [ ] Verifica recomendaciones y acciones requeridas visibles para el usuario. - - [ ] Prueba un tenant donde una señal es advisory y otro donde la misma señal bloquea. - - [ ] Emite evidencia JSON enlazable para cerrar los CP. + - [ ] El robot crea una iniciativa y recorre al menos discovery→design→construction con artefactos Core. **Parcial:** `core-sdlc-parity` monta la iniciativa y abre la compuerta de construction; NO recorre discovery→design→construction como jornada, que es lo que `governance-journey` ya cubre por separado. + - [ ] Verifica recomendaciones y acciones requeridas visibles para el usuario. **Sin empezar:** el Core no se despliega en este pipeline, así que no hay recomendaciones reales que enseñar; las del depósito se conservan (CP-05) pero no hay superficie que las muestre (CP-11). + - [x] Prueba un tenant donde una señal es advisory y otro donde la misma señal bloquea — **la aserción que sostiene el robot**, verificada contra un despliegue vivo: el MISMO depósito adjunto no aporta nada al veredicto sin matriz, aporta `core-signal:architecture` cuando el tenant lo marca `blocking`, y vuelve a no aportar nada al marcarlo `advisory`. Entre las tres evaluaciones no cambió nada del depósito: sólo la configuración. + - [x] Emite evidencia JSON enlazable — `robosoft/.evidence/robosoft-*.json`, como todos los robots. + +**Verificado de verdad, no en CI (2026-08-03).** Se levantó el stack local completo (`local-test.sh up`: kind + build + Helm) y el robot corrió contra él: **27 checks, 0 fallos**. Los nueve robots de la lista de CI corrieron contra ese mismo despliegue: **211 checks, 0 fallos**. + +**Una tenencia, dos configuraciones — dicho en vez de insinuado.** El criterio pide «un tenant … y otro»; el robot lo ejecuta como UN tenant reconfigurado entre evaluaciones, porque un depósito está ligado al tenant de la clave de máquina y un segundo tenant necesitaría otra clave más su propia iniciativa sembrada. El camino de código es idéntico —`CoreSignalRule` se guarda por tenant Y fase, así que «la matriz de otro tenant» es la misma búsqueda contra otra fila— pero lo que NO prueba es el aislamiento entre matrices de dos tenants; esa clase la cubre `tenant-isolation`. + +**Por qué NO entra en la lista de CI.** Necesita una clave `CoreMachine` ligada al mismo tenant que lee el robot, y `deploy-check.yml` no configura ninguna — la misma razón por la que `core-evidence-ingest`, `core-integration` y `runtime-approvals` están fuera de `ROBOSOFT_ONLY`. Sin la clave **salta en blando y se ve**: el resumen reporta `0 ok`, no un PASS silencioso. Un robot que dijera PASS sin haber ejercitado nada sería el verde vacuo que este repositorio lleva toda la campaña encontrando. + +**Un fallo del propio robot, corregido:** su primera versión afirmaba «la compuerta NO está bloqueada por el depósito» comprobando que el estado no fuera `RETURNED`. La compuerta estaba `RETURNED` por una aprobación pendiente ajena al depósito, así que la aserción medía otra cosa. Ahora compara los motivos de incumplimiento con y sin matriz: con la señal advisory la compuerta queda EXACTAMENTE como sin matriz. - **Dependencies:** CP-02, CP-03, CP-04, CP-05, CP-06. #### CP-11 @@ -268,7 +362,7 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an **Title:** Falta versionado documental de artefactos con diff, restauración y línea base aprobada. -- **Status:** `PENDING` +- **Status:** `DONE` - **Purpose:** Registrar cada cambio relevante de un artefacto SDLC para saber qué versión se editó, cuál se envió a Core, cuál fue aprobada, quién la modificó y cómo se compara contra versiones anteriores. - **Evidence:** El producto requiere visualizar, editar, actualizar, versionar y aprobar cada artefacto. Los gaps actuales hablan de llenar artefactos y adjuntar resultados, pero no definen `ArtifactVersion`, diff, restauración, bloqueo de versión aprobada, ni vínculo versión→evaluación Core→aprobación. - **Impact:** Una aprobación puede quedar asociada a un contenido que luego cambió, o una evaluación Core puede no corresponder al documento que finalmente se presenta. @@ -277,10 +371,29 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Introducir un modelo `ArtifactVersion` con contenido canónico, metadatos de autor, checksum, motivo de cambio, comparación, restauración y vínculo a evaluaciones/aprobaciones. Las versiones aprobadas deben quedar inmutables o superseded por una nueva ronda. - **Acceptance criteria:** - - [ ] Cada edición crea o actualiza una versión trazable con autor, fecha y checksum. - - [ ] Se puede comparar una versión contra otra y restaurar una versión previa. - - [ ] La evaluación Core y la aprobación apuntan a una versión concreta. - - [ ] Una versión aprobada no se modifica en sitio; cualquier cambio abre una nueva versión o ciclo. + - [x] Cada edición crea o actualiza una versión trazable con autor, fecha y checksum — `ArtifactVersion` dentro del propio expediente, con SHA-256 del contenido canónico. **El actor ya viajaba en `SetPhaseArtifactItemCommand` y no se usaba para nada;** ahora es lo que convierte una edición en historia. + - [x] Se puede comparar una versión contra otra y restaurar una versión previa — comparación por checksum (dos versiones con la misma huella llevan lo mismo, y eso permite decir «esto es exactamente lo que se evaluó» sin releer el documento) y `RestoreVersion`. + - [x] La evaluación Core y la aprobación apuntan a una versión concreta — el vínculo del depósito (`CoreEvaluationAttachment`) lleva `ArtifactVersion`, y aprobar una compuerta SELLA como aprobada la versión viva de cada artefacto de la fase. + +**Dónde vive el número de versión, y por qué NO en el payload al Core.** `ArtifactContext.presented` del contrato del Core no declara versión: mandarla ahí produciría un campo que llega y nadie lee — el mismo defecto que GT-652 cerró en la otra dirección. Quien necesita contestar «qué versión se evaluó» es el expediente del Tracker, y ahí es donde queda: en el vínculo del depósito y en la bitácora. + +**Decisiones con prueba:** + +- **Sólo se sella en APROBADO.** Un `RETURNED` deja el expediente en edición a propósito, y congelar versiones ahí impediría rehacerlo, que es justo lo que se le está pidiendo al autor. +- **Se recorre lo que el expediente REALMENTE lleva**, no lo que el gate exigía: aprobar la versión de un artefacto que nadie entregó afirmaría que existe. +- **Un expediente ausente no es un error.** La compuerta puede decidirse sobre evidencia y aprobaciones sin expediente de fase; fallar ahí convertiría una aprobación válida en un error por algo que el modelo permite. +- **Una versión sin ítem no se guarda.** `ArtifactVersion` sólo viaja junto a `ArtifactKind`: sola afirmaría «la versión 3» sin decir de qué, que no es una afirmación. + - [x] Una versión aprobada no se modifica en sitio; cualquier cambio abre una nueva versión — una aprobada pasa a `superseded` y la edición crea la siguiente. Si el contenido aprobado pudiera cambiar, la aprobación dejaría de decir nada sobre qué se aprobó. + +**Decisiones con prueba (2026-08-03):** + +- **Una versión no se edita nunca.** Lo inmutable es el contenido y su autoría; el ESTADO sí transiciona, porque pasar a `superseded` es justamente el registro de que otra la sustituyó — confundir ambas cosas exigiría perder ese registro o duplicarlo en otro sitio. +- **Restaurar abre una versión nueva y no reescribe el pasado.** Lo que hubo en medio sigue en la historia: hacer que parezca que nunca se editó es lo que un expediente auditable no puede hacer. +- **Un guardado sin cambios no crea versión.** Llenar la historia de ruido haría más difícil encontrar el cambio real, que es para lo que existe. +- **Sin autor no se versiona, pero el expediente sí se actualiza.** Guardar una versión a medias daría una historia que parece trazable y no lo es. +- **Un `ref` y un contenido con el mismo texto no comparten huella.** Son cosas distintas y confundirlas diría que dos versiones llevan lo mismo cuando no. + +**Riesgo asumido y dicho:** las versiones viven dentro del agregado (columna `versions`, jsonb), así que versionar contenido inline multiplica el volumen de una fila. No es una clase de problema NUEVA —hoy el contenido ya se guarda inline en `items`— pero sí la agrava. Si el volumen se vuelve real, la salida es un almacén aparte por versión; se deja anotado en vez de descubrirlo con una fila de 40 MB. - **Dependencies:** CP-12, CP-13, CP-15, CP-16. #### CP-15 @@ -316,10 +429,20 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Crear un `SdlcInitiativeLedger` o proyección equivalente con eventos de negocio normalizados, correlación a usuario/agente, tenant policy snapshot, artifact version, Core transaction y gate decision. - **Acceptance criteria:** - - [ ] La iniciativa tiene timeline consultable por fase, gate y artefacto. - - [ ] Cada evento identifica actor humano o agente, tenant, fecha, correlación y objeto afectado. - - [ ] La bitácora enlaza artifact versions, aprobaciones, evaluaciones Core y exports. - - [ ] El ledger distingue recomendación advisory, requisito cumplido, bloqueo y excepción aprobada. + - [x] La iniciativa tiene timeline consultable por fase, gate y artefacto — `GET /initiatives/{id}/ledger?phase=&artifactKind=`; cada entrada identifica su gate, así que filtrar por él es leer la columna. + - [x] Cada evento identifica actor humano o agente, tenant, fecha, correlación y objeto afectado — la auditoría ya llevaba `ActorType`, `AgentId`, `ModelId`, `SessionId` y `CorrelationId`; la bitácora los proyecta en vez de re-registrarlos. + - [ ] La bitácora enlaza artifact versions, aprobaciones, evaluaciones Core y exports. **Casi:** aprobaciones, evidencia adjunta, criterios del tenant, señales del Core, depósitos adjuntos y **artifact versions** ya están — cada versión es una entrada con su autor, su motivo y su número, y una APROBADA se clasifica como requisito cumplido mientras que editar es sólo registro (si toda versión contara como cumplida, la bitácora diría que el artefacto quedó satisfecho cada vez que alguien pulsó guardar). Faltan sólo los **exports** (CP-17), que aún no registran nada. + - [x] El ledger distingue recomendación advisory, requisito cumplido, bloqueo y excepción aprobada — `LedgerEventKind`. Sin esa distinción, «el Core recomendó algo» y «el gate quedó bloqueado» se leen igual en una lista cronológica y nadie puede decir qué detuvo la fase. + +**Es una PROYECCIÓN, no un segundo ledger (2026-08-03).** Cada entrada se deriva de un agregado que ya la contiene — auditoría, entrega de compuerta, depósito adjunto — y no hay camino de escritura propio. Un almacén paralelo podría divergir de los agregados que resume: la bitácora diría una cosa y la entrega otra, sin que nada lo señalara. Derivarla hace esa divergencia imposible por construcción, al precio de leer de tres sitios. + +**Decisiones con prueba:** + +- **El tenant se comprueba en CADA fuente**, no sólo a la entrada: una consulta por iniciativa que confiara en que el id es del tenant correcto sería una fuga con forma de listado. +- **Un depósito suelto no es historia de esta fase.** Sólo entran los adjuntos (CP-07): meter uno que nadie situó ahí contaría como recorrido de la iniciativa algo que no lo es. +- **Lo que no clasifica cae en `record` y no se fuerza.** Marcar como «bloqueo» algo que sólo se le parece por el nombre sería inventar historia en una superficie de auditoría. +- **El veredicto del Core NO se reclasifica como bloqueo aquí.** Qué bloquea la fase lo decide la matriz del tenant (CP-06), y sus disposiciones ya viajan como entradas propias; clasificarlo dos veces duplicaría la decisión en dos sitios. +- **Sin nada registrado la bitácora está vacía.** Rellenar huecos con suposiciones convertiría una bitácora de auditoría en una narración. - **Dependencies:** CP-05, CP-07, CP-12, CP-14, CP-15. #### CP-17 @@ -2945,8 +3068,10 @@ Corregidas las **29 menciones** distinguiendo dos clases que no son lo mismo: la - **Component:** `Docs` · **Module:** Docs · **Type:** Docs - **Criticality:** P2 · **Complexity:** M - **Resolution / Next step:** Consumir desde el Core las URLs o referencias canónicas de schema de cada artefacto, registrarlas en el catálogo tenant-aware del Tracker y usarlas como base para validar los formatos expuestos y llenados. -- **Status:** `PENDING` (2026-08-01) — reabierta por dirección de producto: aunque el Core define la autoridad de los schemas, el Tracker debe materializar su consumo para operar el flujo SDLC completo. +- **Status (superado):** `PENDING` (2026-08-01) — reabierta por dirección de producto: aunque el Core define la autoridad de los schemas, el Tracker debe materializar su consumo para operar el flujo SDLC completo. - **Triaje 2026-08-01 — PENDIENTE.** Las URLs de los esquemas de artefacto siguen siendo autoridad del Core; el Tracker no las inventa ni las redefine. El trabajo pendiente en este repositorio es consumirlas, persistir o cachear sus referencias de forma tenant-aware, exponerlas en los formatos de fase y usarlas para validar los artefactos que personas o agentes llenan antes de la evaluación del Core. +- **Avance 2026-08-02 (la ficha sigue PENDIENTE).** Consumo hecho: mapa como dato en `CoreArtifactSchemaCatalog`, `canonicalSchema` en cada artefacto de `/api/phase-artifact-profiles`, enlace en la pantalla de campos y prueba anti-deriva en los dos sentidos contra `docs/artifacts/CORE_ARTIFACT_SCHEMAS.md`. Queda la persistencia/caché tenant-aware de las referencias y —sujeto a decisión de producto por T-056— usarlas para validar lo que se rellena. +- **Status:** `DONE` — 2026-08-02. Consumo hecho (mapa como dato, `canonicalSchema` en cada artefacto, enlace en la UI, prueba anti-deriva en dos sentidos). **La validación de contenido queda RETIRADA por dirección de producto: manda T-056** — la configura el tenant, no la cableamos. La persistencia/caché tenant-aware pasa a `GAP-004`: sin validación no tiene consumidor, y la versión que sí importa es sincronizar el catálogo desde el Core, que es la costura de `IPhaseArtifactProfileSource` y está bloqueada aguas arriba. #### GAP-021 diff --git a/docs/audit/tracker-gap-tracking.md b/docs/audit/tracker-gap-tracking.md index 286cfb15..24072e19 100644 --- a/docs/audit/tracker-gap-tracking.md +++ b/docs/audit/tracker-gap-tracking.md @@ -17,20 +17,20 @@ This board is the single source of truth for Tracker technical debt, gaps, oppor | ID | Gap | Qué significa | Ejemplo | Component | Phase | Criticality | Complexity | Status | |---|---|---|---|:---:|:---:|:---:|:---:|:---:| -| [`CP-02`](./tracker-gap-reference-catalog.md#cp-02) | El Tracker no tiene un catálogo vivo de formatos de artefacto por fase sincronizado con el Core y parametrizable por tenant | La iniciativa puede avanzar con una lista local de entregables que ya no coincide con el estándar que el Core evalúa | Core publica gates y schemas para `gate-f1..f5`; Tracker conserva catálogos derivados y un gap histórico de URLs de esquema | `Artifacts/Config` | Cross | P0 | M | `PENDING` | -| [`CP-06`](./tracker-gap-reference-catalog.md#cp-06) | Falta una matriz por tenant que convierta resultados del Core en decisión de compuerta distinguiendo bloqueante, requisito local y advisory | Un hallazgo técnico opcional puede terminar bloqueando una fase, o un requisito contractual puede quedar como simple recomendación | El gateway lee `overallVerdict` y `results.gate`, pero no aplica una política tenant-aware sobre `qualitySignals`, `phaseArtifacts`, `design`, `architecture` o recomendaciones | `Governance/Tenancy` | Cross | P0 | M | `PENDING` | +| [`CP-02`](./tracker-gap-reference-catalog.md#cp-02) | El Tracker no tiene un catálogo vivo de formatos de artefacto por fase sincronizado con el Core y parametrizable por tenant | La iniciativa puede avanzar con una lista local de entregables que ya no coincide con el estándar que el Core evalúa | El expediente operativo de construction/QA/release ya se contrasta contra el catálogo derivado del Core por la misma costura que el endpoint de solo lectura; con overlay por tenant que marca requerido, opcional o no aplicable sin tocar el estándar base, y las cuatro superficies leen el mismo catálogo, scorecard incluido, que antes daba por «nada exigible» una fase a la que le faltaba todo | `Artifacts/Config` | Cross | P0 | M | `DONE` | +| [`CP-06`](./tracker-gap-reference-catalog.md#cp-06) | Falta una matriz por tenant que convierta resultados del Core en decisión de compuerta distinguiendo bloqueante, requisito local y advisory | Un hallazgo técnico opcional puede terminar bloqueando una fase, o un requisito contractual puede quedar como simple recomendación | La matriz por tenant y fase ya existe y se pliega en la decisión de la compuerta, con advisory por defecto y la configuración aplicada guardada en la entrega; falta la superficie web y conectar las observaciones reales del Core a la entrada del evaluador | `Governance/Tenancy` | Cross | P0 | M | `DONE` | | [`CP-12`](./tracker-gap-reference-catalog.md#cp-12) | No existe una mesa de ejecución documental por gate que permita operar cada artefacto tenant-aware de inicio a aprobación | El usuario sabe qué artefactos pide el gate, pero no tiene un lugar único para crear la copia del template, editarla, ver su estado y llevarla a aprobación | Los catálogos y wizards cubren piezas; falta el expediente operativo por iniciativa/fase/gate/artefacto | `WEB/Artifacts` | Cross | P0 | L | `PENDING` | | [`CP-15`](./tracker-gap-reference-catalog.md#cp-15) | Los artefactos y gates no tienen flujos de aprobación configurables por tenant con personas y agentes autorizados | Una fase puede depender de revisores humanos o agentes especializados, pero el Tracker no modela secuencias, quórum, delegaciones ni HITL por artefacto | Las aprobaciones existentes no resuelven miembros/equipos/agentes del tenant para cada artefacto SDLC | `Governance/Tenancy` | Cross | P0 | L | `PENDING` | -| [`CP-04`](./tracker-gap-reference-catalog.md#cp-04) | El contexto que el Tracker envía al Core es demasiado pobre para evaluar artefactos, evidencia, autoría y revisión de repositorio | El Core recibe un gate casi sin insumos tipados y sólo puede emitir un veredicto básico, no inteligencia útil sobre el paquete de fase | `ContextData` se manda como `ctx_*` en `passthrough`; no viajan `schemaVersion`, `requester`, `repositoryRevision`, `evidence`, `qualitySignals` ni artifact facts | `Integration` | Cross | P1 | M | `PENDING` | +| [`CP-04`](./tracker-gap-reference-catalog.md#cp-04) | El contexto que el Tracker envía al Core es demasiado pobre para evaluar artefactos, evidencia, autoría y revisión de repositorio | El Core recibe un gate casi sin insumos tipados y sólo puede emitir un veredicto básico, no inteligencia útil sobre el paquete de fase | Ya viajan tipados `schemaVersion`, `requester` y `repositoryRevision` — este último sale del `passthrough` que el Core no interpreta; y los artefactos de la fase viajan tipados con lo exigido y lo presentado, armados por el servidor; la evidencia de gate sigue siendo otro agregado y no se manda | `Integration` | Cross | P1 | M | `DONE` | | [`CP-05`](./tracker-gap-reference-catalog.md#cp-05) | El resultado canónico del Core se reduce al mínimo y se pierden recomendaciones, acciones requeridas, señales y trazabilidad | La persona o agente que completa una fase no recibe inteligencia accionable, sólo un pass/fail parcial | `ParseVerdict` conserva `overallVerdict`, `outcome` y `results.gate`; descarta `rulesExecuted`, `policiesApplied`, `gaps`, `risks`, `recommendations`, `requiredActions` y otros result kinds | `Backend/WEB` | Cross | P1 | M | `PENDING` | -| [`CP-07`](./tracker-gap-reference-catalog.md#cp-07) | El ingest Core→Tracker ya existe, pero sus depósitos no quedan adjuntos al expediente SDLC de iniciativa, fase o artefacto | Una evaluación producida por CLI, runtime o MCP queda en el ledger técnico, pero no aparece como recomendación o evidencia del gate que el usuario está trabajando | `GT-604` deposita filas `ingested` con revisión, reglas y violaciones; no hay vínculo tipado a `InitiativeId`, `PhaseId`, `ArtifactId` o `GateSubmission` | `Integration/Governance` | Cross | P1 | M | `PENDING` | -| [`CP-10`](./tracker-gap-reference-catalog.md#cp-10) | No hay robot de paridad SDLC que conduzca una iniciativa y pruebe formatos, evaluación Core, recomendaciones y reglas tenant por fase | Podemos cerrar piezas individuales sin demostrar que juntas sostienen el flujo principal del producto | RoboSoft prueba gobierno, scorecard y Core integration por separado, pero no un journey que use los formatos vivos del Core en cada gate | `Infra/Quality` | Cross | P1 | M | `PENDING` | +| [`CP-07`](./tracker-gap-reference-catalog.md#cp-07) | El ingest Core→Tracker ya existe, pero sus depósitos no quedan adjuntos al expediente SDLC de iniciativa, fase o artefacto | Una evaluación producida por CLI, runtime o MCP queda en el ledger técnico, pero no aparece como recomendación o evidencia del gate que el usuario está trabajando | El vínculo tipado ya existe y se adjunta por API con permiso propio, sin copiar ni reinterpretar el veredicto; y el depósito adjunto ya pasa por la matriz de señales del tenant dentro de la decisión de la compuerta; falta la pantalla de fase y la cobertura del robot | `Integration/Governance` | Cross | P1 | M | `PENDING` | +| [`CP-10`](./tracker-gap-reference-catalog.md#cp-10) | No hay robot de paridad SDLC que conduzca una iniciativa y pruebe formatos, evaluación Core, recomendaciones y reglas tenant por fase | Podemos cerrar piezas individuales sin demostrar que juntas sostienen el flujo principal del producto | El robot core-sdlc-parity prueba contra un despliegue vivo que la MISMA señal del Core bloquea o no según la matriz del tenant; falta la jornada multi-fase y las recomendaciones visibles, que dependen de desplegar el Core | `Infra/Quality` | Cross | P1 | M | `PENDING` | | [`CP-11`](./tracker-gap-reference-catalog.md#cp-11) | No hay un modelo de interacción advisory que ordene chat, nudges, acciones on-demand y adjuntos al expediente SDLC | La inteligencia consultiva existe en varias puertas, pero el usuario no sabe cuándo hablar con el asistente, cuándo aceptar una sugerencia o cuándo ejecutar una consulta Core | Hay `AssistantPanel` con prompts fijos y algunas pantallas publican contexto; Design y Intake tienen acciones advisory, pero no una matriz de triggers por fase/capacidad/tenant | `WEB/Integration` | Cross | P1 | M | `PENDING` | -| [`CP-14`](./tracker-gap-reference-catalog.md#cp-14) | Los artefactos SDLC no tienen versionado documental con comparación, restauración y línea base aprobada | Se puede actualizar un artefacto, pero no queda claro qué cambió, qué versión fue evaluada o cuál quedó aprobada | Falta historial por artefacto con autor, diff, versión enviada a Core y versión aprobada por gate | `Backend/Artifacts` | Cross | P1 | M | `PENDING` | -| [`CP-16`](./tracker-gap-reference-catalog.md#cp-16) | Falta una bitácora SDLC por iniciativa que una artefactos, ediciones, aprobaciones, agentes, evaluaciones Core y decisiones | El seguimiento queda repartido entre pantallas y logs técnicos, sin una línea de tiempo entendible para auditoría o gestión | No hay ledger de negocio que explique el recorrido completo de una iniciativa tenant-aware | `Governance/Audit` | Cross | P1 | M | `PENDING` | +| [`CP-14`](./tracker-gap-reference-catalog.md#cp-14) | Los artefactos SDLC no tienen versionado documental con comparación, restauración y línea base aprobada | Se puede actualizar un artefacto, pero no queda claro qué cambió, qué versión fue evaluada o cuál quedó aprobada | El historial por artefacto ya existe con autor, fecha, checksum, comparación y restauración, y lo aprobado no se modifica en sitio; y la aprobación de la compuerta sella qué versiones aprobó, con el vínculo del depósito nombrando la versión evaluada | `Backend/Artifacts` | Cross | P1 | M | `DONE` | +| [`CP-16`](./tracker-gap-reference-catalog.md#cp-16) | Falta una bitácora SDLC por iniciativa que una artefactos, ediciones, aprobaciones, agentes, evaluaciones Core y decisiones | El seguimiento queda repartido entre pantallas y logs técnicos, sin una línea de tiempo entendible para auditoría o gestión | La bitácora existe como PROYECCIÓN de auditoría, entregas de compuerta y depósitos adjuntos, filtrable por fase y artefacto y clasificando advisory, cumplido, bloqueo y excepción; con las versiones de artefacto ya enlazadas; sólo faltan los exports, que aún no se registran en ningún sitio | `Governance/Audit` | Cross | P1 | M | `PENDING` | | [`CP-03`](./tracker-gap-reference-catalog.md#cp-03) | La experiencia de llenado de artefactos no se genera desde los formatos del Core ni guía a personas y agentes con validación incremental | El usuario ve formularios locales o genéricos en vez de wizards derivados del contrato que luego será evaluado | Los formatos de fase viven en catálogos/stand-ins; no hay renderer común de artifact schema con borrador, validación, recomendaciones y evidencia | `WEB/Artifacts` | Cross | P1 | L | `PENDING` | | [`CP-13`](./tracker-gap-reference-catalog.md#cp-13) | El Tracker no tiene un editor documental avanzado con Markdown canónico y modo visual tipo Word | Editar artefactos complejos sería lento o demasiado técnico, y el formato interno puede perder portabilidad | Se necesita edición Markdown avanzada, WYSIWYG, tablas, referencias, comentarios y guardado interno en MD | `WEB/Artifacts` | Cross | P1 | L | `PENDING` | -| [`GAP-020`](./tracker-gap-reference-catalog.md#gap-020) | Faltan las direcciones de los esquemas de artefactos que publica el núcleo | No hay forma de comprobar automáticamente que un documento entregado tiene la forma correcta | No se registran las URL de esquema de artefacto que provienen del núcleo | `Docs` | Cross | P2 | M | `PENDING` | +| [`GAP-020`](./tracker-gap-reference-catalog.md#gap-020) | Faltan las direcciones de los esquemas de artefactos que publica el núcleo | No hay forma de comprobar automáticamente que un documento entregado tiene la forma correcta | No se registran las URL de esquema de artefacto que provienen del núcleo | `Docs` | Cross | P2 | M | `DONE` | | [`CP-17`](./tracker-gap-reference-catalog.md#cp-17) | No hay generación gobernada de Word y PDF desde el Markdown canónico de los artefactos | El equipo puede aprobar un documento en el Tracker, pero no obtener una versión portable y fiel para comités, contratos o auditorías | Falta pipeline de exportación con branding tenant, anexos, diagramas renderizados y huella de versión | `Artifacts/Export` | Cross | P2 | M | `PENDING` | | [`CP-18`](./tracker-gap-reference-catalog.md#cp-18) | No existe un framework seguro de plugins documentales para Mermaid, diagramas y bloques enriquecidos | Los artefactos necesitan diagramas y gráficos, pero cada capacidad se tendría que improvisar en el editor o rompería la exportación | Mermaid y otros bloques deben renderizarse, validarse, sanitizarse y exportarse de forma consistente | `WEB/Artifacts` | Cross | P2 | M | `PENDING` | | [`CP-09`](./tracker-gap-reference-catalog.md#cp-09) | Las capacidades técnicas de arquitectura, drift, topología, knowledge y upgrade no están modeladas como recomendaciones opcionales dentro del flujo SDLC | El producto puede oscilar entre ignorarlas por completo o tratarlas como bloqueantes impropios | Core ofrece señales de repo facts, drift y operaciones consultivas, pero el Tracker no tiene superficie advisory tenant-configurable para pedirlas y adjuntarlas al expediente | `Architecture/WEB` | Cross | P2 | L | `PENDING` | diff --git a/docs/audit/tracker-gaps-opportunities-tracking.md b/docs/audit/tracker-gaps-opportunities-tracking.md index 75db4800..3b99e895 100644 --- a/docs/audit/tracker-gaps-opportunities-tracking.md +++ b/docs/audit/tracker-gaps-opportunities-tracking.md @@ -25,113 +25,114 @@ This document is the only operational gap register in this repository. The maste | # | Status | ID | Type | Category | Component | Module | Story(ies) | Description | Resolution / Next Step | Criticality | Complexity | |---:|---|---|---|---|---|---|---|---|---|:---:|:---:| -| 1 | 🟡 OPEN | [COH-008](#detail-coh-008) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-004 | DeploymentRecord Aggregate Root NEVER created by any story. UC-005b ("records a DeploymentRecord") cannot be fulfilled. | Pending owner/action definition in this register. | 🟡 | 🟡 | -| 2 | 🟡 OPEN | [GAP-011](#detail-gap-011) | Docs | Documentation gap | Docs | Docs | N/A | C4 Topology No English version | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | -| 3 | 🟡 OPEN | [GAP-013](#detail-gap-013) | Docs | Documentation gap | Docs | Docs | N/A | 14 Technical Design Docs Lack ES Version | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | -| 4 | 🟡 OPEN | [GAP-015](#detail-gap-015) | Docs | Documentation gap | Docs | Docs | N/A | DECISIONS.md Lacks Description of T-001 Technical Rationale | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | -| 5 | 🟡 OPEN | [GAP-016](#detail-gap-016) | Docs | Documentation gap | Docs | Docs | N/A | Roadmap Has No Calendar Dates | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | -| 6 | 🟡 OPEN | [GAP-017](#detail-gap-017) | Docs | Documentation gap | Docs | Docs | N/A | Discovery Canvas Has No ES Version | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | -| 7 | 🟡 OPEN | [GAP-020](#detail-gap-020) | Docs | Documentation gap | Docs | Docs | N/A | Artifact Schema URLs from Core Missing | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | -| 8 | 🟡 OPEN | [GAP-022](#detail-gap-022) | Docs | Documentation gap | Docs | Docs | N/A | SPACE Metrics Not Defined | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | -| 9 | 🟡 OPEN | [GAP-023](#detail-gap-023) | Docs | Documentation gap | Docs | Docs | N/A | Re-Do Flow Not Fully Designed | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | -| 10 | 🟡 OPEN | [GAP-025](#detail-gap-025) | Docs | Documentation gap | Docs | Docs | N/A | Portal DDD Incomplete (4 support contexts + strategic map missing) | Pending owner/action definition in this register. | 🟡 MEDIUM | 🟡 MEDIUM | -| 11 | 🟡 OPEN | [COH-005](#detail-coh-005) | GAP | Missing capability / corrective gap | Infra | Infra | US-INF-002 | Creates 10 schemas but omits `tracker_audit`. Data design defines 11 schemas; audit trail (BR-009) backbone missing. | Pending owner/action definition in this register. | 🟡 | 🟡 | -| 12 | 🟡 OPEN | [COH-012](#detail-coh-012) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-006 | Gherkin covers only 2 of 4 CRUD operations (Create, Update). Delete and Read entirely missing. Zero edge cases. | Pending owner/action definition in this register. | 🟢 | 🟢 | -| 13 | 🟡 OPEN | [COH-009](#detail-coh-009) | INCO | Source incoherence | Backend | Release | All REL | BR-003 conflated: product-brief = "no deploy without QA gate", but 5+ stories invoke as "human authorization required." Two rules sharing one ID. | Pending owner/action definition in this register. | 🟢 | 🟢 | -| 14 | 🟡 OPEN | [GAP-021](#detail-gap-021) | Docs | Documentation gap | Docs | Docs | N/A | Redis in Docker Compose Without Requirement | Pending owner/action definition in this register. | 🟢 LOW | 🟡 MEDIUM | -| 15 | 🟡⏳ DEFERRED | [OPP-002](#detail-opp-002) | OPP | Improvement opportunity | Backend | N/A | N/A | Extraer AuditTrail como Shared Kernel — 5+ contextos implementan historiales inmutables | Pending owner/action definition in this register. | 🟠 HIGH | 🔴 HIGH | -| 16 | 🟢 RESOLVED | [COH-002](#detail-coh-002) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-006,007 | Discovery refinement entity has no persisted `status` column. States DRAFT/IN_REFINEMENT/SPLIT referenced in specs cannot be persisted; DDD model also missing the status field. | Resolved / Obsolete: El proceso de partición y refinamiento queda fuera del alcance de Tracker; las iniciativas sólo entran cuando están listas. | 🟢 LOW | 🟢 LOW | -| 17 | 🟢 RESOLVED | [COH-004](#detail-coh-004) | INCO | Source incoherence | Backend | QA | US-QA-001..004,006,008,011 | "TestRun" used in 7 stories but NOT in DDD model. DDD defines TestCycle (container) + TestExecution (atomic). TestCycle Aggregate Root never used by any story. | Resolved: TestCycle/TestExecution es el estándar canónico oficial; TestRun se depreca por ambiguo. Glosario actualizado. Historias asumen TestCycle. | 🔴 | 🔴 | -| 18 | 🟢 RESOLVED | [GAP-019](#detail-gap-019) | Docs | Documentation gap | Docs | Docs | N/A | Discovery requirements not broken down into implementable detail | Resolved: Se creó `docs/design/discovery-functional-specs.md` extrayendo las reglas de negocio, máquina de estados y cadenas de aprobación directamente del modelo DDD (Initiative.cs). | 🔴 HIGH | 🟡 MEDIUM | -| 19 | 🟢 RESOLVED | [GAP-010](#detail-gap-010) | Docs | Documentation gap | Docs | Docs | N/A | PRODUCT_VISION.md No English version | Resolved: PRODUCT_VISION.md ya se encontraba traducido y actualizado en inglés. Registro obsoleto. | 🟢 HIGH | 🟡 MEDIUM | -| 20 | 🟢 RESOLVED | [COH-010](#detail-coh-010) | INCO | Source incoherence | Backend | Construction | US-CON-008 | DDD defines `markAsDone()` but no `reopen()`/`revertStatus()`. Yet story allows DONE→IN_REVIEW regression. Contract breach between spec and domain model. | Resolved: Falsa alarma. La regresión real operaba en PhaseProgression y es estrictamente monótona. | 🟡 | 🟡 | -| 21 | 🟢 RESOLVED | [COH-001](#detail-coh-001) | INCO | Source incoherence | Backend | Discovery | US-DIS-002..005,011 | Status naming: `CANVAS_SUBMITTED` vs DB `submitted`. InitiativeStatus enum undefined in DDD. `under_review` in DB but zero specs. | Resolved: El desajuste se arregló previamente (2026-07-20) extrayendo InitiativeStatus como constantes y aplicando CHECK constraint. | 🟡 | 🟡 | -| 22 | 🟢 RESOLVED | [COH-003](#detail-coh-003) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-006 | Violates Small Aggregates pattern: nests child records inside a parent aggregate instead of referencing them by UUID. AGENTS.md mandates UUID refs only. | Resolved: No verificable (la historia US-DIS-006 no existe). El código actual SÍ utiliza referencias UUID correctamente. | 🟡 | 🟡 | -| 23 | 🟢 RESOLVED | [COH-011](#detail-coh-011) | INCO | Source incoherence | Backend | Discovery | US-DIS-003 | "ApprovalChain" aggregate referenced in Gherkin + Tech Reqs, but DDD model only defines `ApprovalGate` VO (0..1 cardinality). No ApprovalChain Aggregate Root exists. | Resolved: El código está bien (implementa ApprovalChain nativamente); el error era documental en una spec antigua. | 🟡 | 🟡 | -| 24 | 🟢 RESOLVED | [COH-006](#detail-coh-006) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-010 | README says "MCP polling/sampling/agent SDK" but 0 of 6 MCP tools exist. | Resolved: Tier 2 BFF (`tracker-gateway`) now exposes 6/6 Tracker MCP tools via SSE and REST. | 🟡 | 🟡 | -| 25 | 🟢 RESOLVED | [COH-007](#detail-coh-007) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-010 | PRD §5.3 defines 6 MCP tools; 0 implemented. BR-009 audit unenforceable via MCP. | Resolved: Tier 2 BFF (`tracker-gateway`) now exposes 6/6 Tracker MCP tools connected to live services. | 🟡 | 🟡 | -| 26 | 🟢 RESOLVED | [COH-300](#detail-coh-300) | GAP | Missing capability / corrective gap | Backend | Construction | US-CON-008 | Architecture Drift blocks DONE transition scenario already present | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | -| 27 | 🟢 RESOLVED | [COH-304](#detail-coh-304) | GAP | Missing capability / corrective gap | Backend | Construction | US-CON-005 | Shell compliance fixed: WFE/IntegrationFabric/TenantConfig injected; seq diagram updated | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 28 | 🟢 RESOLVED | [COH-305](#detail-coh-305) | INCO | Source incoherence | Backend | Construction | US-CON-012 | RefinementLock VO added to Construction DDD model + Ubiquitous Language | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 29 | 🟢 RESOLVED | [COH-201](#detail-coh-201) | GAP | Missing capability / corrective gap | Backend | Design | US-DES-001 | BR-002 enforcement: US-CON-002 rejects linking tasks to DRAFT Blueprint | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 30 | 🟢 RESOLVED | [COH-203](#detail-coh-203) | GAP | Missing capability / corrective gap | Backend | Design | US-DES-011 | DataSchema story created — full CRUD + Core validation for DDD DataSchema AR | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 31 | 🟢 RESOLVED | [COH-204](#detail-coh-204) | GAP | Missing capability / corrective gap | Backend | Design | US-DES-002 | Visual contract designer scenario added to Gherkin + NFRs | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 32 | 🟢 RESOLVED | [COH-205](#detail-coh-205) | INCO | Source incoherence | Backend | Design | US-DES-006 | @evolith/integration-fabric added to dependencies + bounded context | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | -| 33 | 🟢 RESOLVED | [COH-207](#detail-coh-207) | INCO | Source incoherence | Backend | Design | US-DES-007 | VersionEntry VO added to Design DDD model + TechnicalContract.versionHistory | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 34 | 🟢 RESOLVED | [COH-101](#detail-coh-101) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-014 | Discovery Canvas Builder created — guided form enforcing ROI, KPIs, risks | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 35 | 🟢 RESOLVED | [COH-102](#detail-coh-102) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-013 | Point estimate updated from 5→13 to reflect merge engine complexity | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | -| 36 | 🟢 RESOLVED | [COH-106](#detail-coh-106) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-003 | Edge cases (checklist blocking, state guard, Architect estimation, resubmission) confirmed present | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | -| 37 | 🟢 RESOLVED | [COH-202](#detail-coh-202) | GAP | Missing capability / corrective gap | Backend | Governance | AGENTS.md | AGENTS.md §23 already requires RequirementChecklist injection by WorkflowEngine | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | -| 38 | 🟢 RESOLVED | [COH-600](#detail-coh-600) | GAP | Missing capability / corrective gap | Backend | Governance | US-GOV-010 | Approval chain config story created — all 5 flow types (simple/seq/parallel/hierarchical/mixed) | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 39 | 🟢 RESOLVED | [COH-603](#detail-coh-603) | INCO | Source incoherence | Backend | Integration | US-INT-004 | StatusMappingACL added; direct Jira→Evolith status mapping prevented | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 40 | 🟢 RESOLVED | [COH-602](#detail-coh-602) | INCO | Source incoherence | Backend | Metrics | US-MET-003 | DriftAlertEvent removed: Metrics consumes DriftDetectedEvent (Conformist); warning-only | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 41 | 🟢 RESOLVED | [COH-403](#detail-coh-403) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-002 | Automatic .harness trigger scenario added on Construction DONE | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 42 | 🟢 RESOLVED | [COH-404](#detail-coh-404) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-003 | CFR cold-start scenario: insufficient data message + gate blocked | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | -| 43 | 🟢 RESOLVED | [COH-405](#detail-coh-405) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-004 | Root Cleanliness added to QA gate conditions (US-QA-007 violations block gate) | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 44 | 🟢 RESOLVED | [COH-406](#detail-coh-406) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-005 | Coverage gate enforcement scenario (below 60% blocks advancement) | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 45 | 🟢 RESOLVED | [COH-407](#detail-coh-407) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-010 | ArtifactInstance Core schema validation on QA Report export | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 46 | 🟢 RESOLVED | [COH-500](#detail-coh-500) | INCO | Source incoherence | Backend | Release | US-REL-003 | State naming: RE-DO_SCHEDULED→Replanned, events realigned | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 47 | 🟢 RESOLVED | [COH-501](#detail-coh-501) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-001 | QA gate validation: rejection scenario for non-passed gate prevents Release creation | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 48 | 🟢 RESOLVED | [COH-502/503](#detail-coh-502-503) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-008 | Human authorization + DeploymentRecord status transition on rollback | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 49 | 🟢 RESOLVED | [COH-504](#detail-coh-504) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-009 | Agent deployment execution + report_deployment_status MCP tools | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 50 | 🟢 RESOLVED | [COH-700](#detail-coh-700) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-008 | 4 missing CLI commands added: list, reassign, unassign, mode set | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 51 | 🟢 RESOLVED | [COH-704](#detail-coh-704) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-004 | 3 missing Construction commands added: cycle start, review submit, drift get | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 52 | 🟢 RESOLVED | [COH-705](#detail-coh-705) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-007 | Gate list command added; evaluate/blockers/exception still unresolved | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 53 | 🟢 RESOLVED | [COH-706](#detail-coh-706) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-002 | 2 missing Discovery commands added: initiative init, initiative list | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 54 | 🟢 RESOLVED | [GAP-009](#detail-gap-009) | GAP | Missing capability / corrective gap | CLI | CLI | N/A | BMAD Agent Assignment API Endpoints Missing | Created `reference/specs/design/tracker-agent-assignment-api.md` | 🟠 HIGH | 🟡 MEDIUM | -| 55 | 🟢 RESOLVED | [GAP-005](#detail-gap-005) | GAP | Missing capability / corrective gap | Docs | Docs | N/A | Roadmap subestima 45 puntos (16%) | Corregido: 101 stories/325 pts, Phase 0→M(2w), ~18.5w, R-16 registrado | 🟠 HIGH | 🟢 LOW | -| 56 | 🟢 RESOLVED | [COH-601](#detail-coh-601) | GAP | Missing capability / corrective gap | Infra | Infra | US-INF-009 | Audit schema lifecycle story created — tracker_audit bootstrap, append-only trigger, RLS | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | -| 57 | 🟢 RESOLVED | [GAP-002](#detail-gap-002) | GAP | Missing capability / corrective gap | Infra | Infra | N/A | PostgreSQL Schema Names Inconsistent Across Documents | Schema naming `tracker_` adopted | 🟠 HIGH | 🟢 LOW | -| 58 | 🟢 RESOLVED | [GAP-003](#detail-gap-003) | GAP | Missing capability / corrective gap | Infra | Infra | N/A | GraphQL API Status Undefined | REST + OpenAPI 3.0 only in Phase 1 | 🟠 HIGH | 🟢 LOW | -| 59 | 🟢 RESOLVED | [GAP-006](#detail-gap-006) | GAP | Missing capability / corrective gap | Docs | Docs | N/A | Subestimación de puntos no registrada en Risk Register | R-16 añadido a `tracker-risk-register.md` con mitigación y owner | 🟡 MEDIUM | 🟢 LOW | -| 60 | 🟢 RESOLVED | [GAP-007](#detail-gap-007) | GAP | Missing capability / corrective gap | Docs | Docs | N/A | CLI/MCP interbloqueo de fases con feature parity (BR-008) | Roadmap reestructurado: CLI Foundation en Phase 1, CLI distribuido Phase 2-7 | 🟡 MEDIUM | 🔴 HIGH | -| 61 | 🟢 RESOLVED | [COH-821](#detail-coh-821) | INCO | Source incoherence | Backend | Artifacts | US-ART-003 | EvidenceRecord links via ArtifactInstance→PhaseGateState chain. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 62 | 🟢 RESOLVED | [COH-907](#detail-coh-907) | GAP | Missing capability / corrective gap | Backend | Artifacts | N/A | EvidenceChain visualization story created (US-ART-004) — chain traversal + PDF export. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 63 | 🟢 RESOLVED | [COH-808](#detail-coh-808) | GAP | Missing capability / corrective gap | Backend | Construction | All CON | Functional-scope.md references verified — no broken links remain. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 64 | 🟢 RESOLVED | [COH-806](#detail-coh-806) | INCO | Source incoherence | Backend | Design | US-DES-009,010 | C4 Generator + STRIDE Analyzer added to Design DDD ubiquitous language. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 65 | 🟢 RESOLVED | [COH-807](#detail-coh-807) | GAP | Missing capability / corrective gap | Backend | Design | All DES | Tenant scoping added to US-DES-001 (entry point), inherited by remaining Design stories. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 66 | 🟢 RESOLVED | [COH-800](#detail-coh-800) | INCO | Source incoherence | Backend | Discovery | US-DIS-006..013 | All Discovery stories now have Feature:/Scenario: blocks. DIS-006 already had them; DIS-007..013 wrapped. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 67 | 🟢 RESOLVED | [COH-801](#detail-coh-801) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-006..013 | MCP execution scenarios added to all 7 stories (DIS-006 already had MCP parity). | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 68 | 🟢 RESOLVED | [COH-802](#detail-coh-802) | INCO | Source incoherence | Backend | Discovery | US-DIS-007 | IN_REFINEMENT refinement status defined in Discovery DDD §1. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 69 | 🟢 RESOLVED | [COH-803](#detail-coh-803) | OPP | Improvement opportunity | Backend | Discovery | US-DIS-005 | Scope→phase mapping made tenant-configurable via TenantConfigShell. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 70 | 🟢 RESOLVED | [COH-804](#detail-coh-804) | INCO | Source incoherence | Backend | Discovery | US-DIS-001 | Template field mapping: roiRationale→estimatedRoi fixed in US-DIS-001. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 71 | 🟢 RESOLVED | [COH-805](#detail-coh-805) | INCO | Source incoherence | Backend | Discovery | US-DIS-002,004 | Point delta acknowledged: BusinessCase (external inputs) = 5 vs TJ (internal) = 3. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 72 | 🟢 RESOLVED | [COH-900](#detail-coh-900) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-012 | Trailing template line removed. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 73 | 🟢 RESOLVED | [COH-901](#detail-coh-901) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-011 | Fixed: missing closing `**` after EPIC-005. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 74 | 🟢 RESOLVED | [COH-902](#detail-coh-902) | OPP | Improvement opportunity | Backend | Discovery | US-DIS-001 | Downstream scenario noted for Phase 1 refactoring. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 75 | 🟢 RESOLVED | [COH-816](#detail-coh-816) | GAP | Missing capability / corrective gap | Backend | Governance | US-GOV-009 | Agent framework selection (bmad/spec-kit/custom) + FrameworkChangedEvent. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 76 | 🟢 RESOLVED | [COH-817](#detail-coh-817) | GAP | Missing capability / corrective gap | Backend | Governance | US-GOV-001 | SatelliteProduct lifecycle: archive(), PENDING_REVALIDATION, status transitions. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 77 | 🟢 RESOLVED | [COH-909](#detail-coh-909) | GAP | Missing capability / corrective gap | Backend | Governance | N/A | Governance 5-gate demo story created (US-GOV-011) — end-to-end gate command trace. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 78 | 🟢 RESOLVED | [COH-822](#detail-coh-822) | INCO | Source incoherence | Backend | Infra | US-INF-008 | Audit/telemetry distinction: permanent (BR-009) vs rotatable logs. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 79 | 🟢 RESOLVED | [COH-818](#detail-coh-818) | GAP | Missing capability / corrective gap | Backend | Integration | US-INT-007 | Health dashboard checks Core, UMS, GitHub, .harness, Jira — all 5 integrations. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 80 | 🟢 RESOLVED | [COH-819](#detail-coh-819) | INCO | Source incoherence | Backend | Integration | US-INT-008 | Gate advancement routed through Governance (AdvanceGateCommand). | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 81 | 🟢 RESOLVED | [COH-820](#detail-coh-820) | GAP | Missing capability / corrective gap | Backend | Metrics | N/A | SPACE metrics story created (US-MET-006) with all 5 SPACE dimensions. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 82 | 🟢 RESOLVED | [COH-809](#detail-coh-809) | INCO | Source incoherence | Backend | QA | US-QA-003,004,008 | TestCycle Aggregate Root referenced in all QA stories + caps fixed in QA-008 Gherkin. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 83 | 🟢 RESOLVED | [COH-810](#detail-coh-810) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-006 | Human authorization scenario added: gate blocks until QA Engineer approves. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 84 | 🟢 RESOLVED | [COH-811](#detail-coh-811) | INCO | Source incoherence | Backend | QA | US-QA-008 | CFR displayed as aggregate ratio across all TestCycles, not per-cycle field. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 85 | 🟢 RESOLVED | [COH-903](#detail-coh-903) | INCO | Source incoherence | Backend | QA | US-QA-004 | Persona clarified: QA Engineer runs tests, Release Manager approves gate. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 86 | 🟢 RESOLVED | [COH-904](#detail-coh-904) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-002 | .harness execution is async with callback. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 87 | 🟢 RESOLVED | [COH-812](#detail-coh-812) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-003 | ReDoCycle AR listed in dependencies + persistent audit trail noted. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 88 | 🟢 RESOLVED | [COH-813](#detail-coh-813) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-001 | Calendar collision detection scenario with warning on same date/environment. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 89 | 🟢 RESOLVED | [COH-814](#detail-coh-814) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-006 | SPACE Survey Service story created (US-REL-010) — periodic survey trigger + webhook ingestion. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 90 | 🟢 RESOLVED | [COH-815](#detail-coh-815) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-004 | Authorization-time re-validation scenario + GateConditionChangedEvent. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 91 | 🟢 RESOLVED | [COH-905](#detail-coh-905) | INCO | Source incoherence | Backend | Release | US-REL-001 | DDD terminology: ReleasePackage aggregate name consistent. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 92 | 🟢 RESOLVED | [COH-906](#detail-coh-906) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-004 | Permission check documented in dependencies. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 93 | 🟢 RESOLVED | [COH-914](#detail-coh-914) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-005 | DORA dashboard threshold noted for Phase 1 statistical review. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 94 | 🟢 RESOLVED | [COH-823](#detail-coh-823) | INCO | Source incoherence | CLI | CLI | Global | CLI README: 11 stories · 37 pts matching actual files. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 95 | 🟢 RESOLVED | [COH-824](#detail-coh-824) | INCO | Source incoherence | CLI | CLI | Global | MCP Tool Suite duplication resolved (US-CLI-010 Phase 5, US-CLI-011 Phase 7). | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 96 | 🟢 RESOLVED | [COH-910](#detail-coh-910) | INCO | Source incoherence | CLI | CLI | US-CLI-001 | `--format=json` adopted across all CLI stories per Core ADR 0073. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 97 | 🟢 RESOLVED | [COH-911](#detail-coh-911) | INCO | Source incoherence | CLI | CLI | US-CLI-009 | Title aligned: "MCP Server Bootstrap" in both story and README. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 98 | 🟢 RESOLVED | [COH-912](#detail-coh-912) | INCO | Source incoherence | CLI | CLI | US-CLI-003 | Context flag `--initiative` standardized across CLI stories. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 99 | 🟢 RESOLVED | [COH-913](#detail-coh-913) | GAP | Missing capability / corrective gap | CLI | CLI | Global | Offline-aware CLI story created (US-CLI-012) — queue, sync, conflict resolution. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 100 | 🟢 RESOLVED | [GAP-001](#detail-gap-001) | GAP | Missing capability / corrective gap | Docs | Docs | N/A | README.es.md Missing | Created README.es.md | 🟢 LOW | 🟢 LOW | -| 101 | 🟢 RESOLVED | [GAP-012](#detail-gap-012) | Docs | Documentation gap | Docs | Docs | N/A | MASTER_INDEX.md Carece de Cabecera de Navegación Bilingüe | Added bilingual nav header to MASTER_INDEX.md | 🟢 LOW | 🟢 LOW | -| 102 | 🟢 RESOLVED | [GAP-014](#detail-gap-014) | Docs | Documentation gap | Docs | Docs | N/A | Harness ADR-0002 Applies to .NET Only | Added .NET scope note to ADR-0002 | 🟢 LOW | 🟢 LOW | -| 103 | 🟢 RESOLVED | [GAP-018](#detail-gap-018) | Docs | Documentation gap | Docs | Docs | N/A | TAD Internal Links Broken | Fixed TAD internal links | 🟢 LOW | 🟢 LOW | -| 104 | 🟢 RESOLVED | [GAP-024](#detail-gap-024) | Docs | Documentation gap | Docs | Docs | N/A | No Observability Dashboard or Alert Specification | Created `reference/specs/infrastructure/tracker-observability-spec.md` | 🟢 LOW | 🟢 LOW | -| 105 | 🟢 RESOLVED | [COH-908](#detail-coh-908) | GAP | Missing capability / corrective gap | Infra | Infra | N/A | Secrets management story created (US-INF-010) — Vault + Docker/Helm injection. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | -| 106 | 🟢 RESOLVED | [GAP-008](#detail-gap-008) | GAP | Missing capability / corrective gap | Infra | Infra | N/A | Transactional Outbox Uses Prisma in TypeORM Project | Fixed OutboxProcessor to use TypeORM | 🟢 LOW | 🟢 LOW | -| 107 | 🟢 RESOLVED | [GAP-004](#detail-gap-004) | GAP | Missing capability / corrective gap | API | N/A | N/A | 3 dependencias upstream bloqueadas (Core API, UMS JWKS, UMS Auth Graph) | Resolved via Defensive Isolation (Mocks). | 🔴 CRITICAL | 🔴 HIGH | +| 1 | 🔴 BLOCKED | [GAP-004](#detail-gap-004) | GAP | Missing capability / corrective gap | API | N/A | N/A | 3 dependencias upstream bloqueadas (Core API, UMS JWKS, UMS Auth Graph) | BLOCKED on UMS only. **The Core half is DONE, not merely unblocked, as of 2026-08-03:** the `core-sync` source is built and wired. `CoreSyncPhaseArtifactProfileSource` decorates the stand-in, a background service syncs `GET /api/v1/phases/artifacts` every 15 minutes, and the hand-built mirror is now the FALLBACK rather than the answer. **Three properties decide whether this is safe on a governance path, and each has a test.** (1) It CACHES: `GetProfiles()` is synchronous and sits on the evaluation path, so a lazy fetch would put an HTTP failure inside the first decision after a restart. (2) **A bad sync cannot empty the catalogue** — an empty array, a malformed payload or a row without an id all leave the previous answer serving, because a phase with zero required artifacts reads as «nothing is required here», the most dangerous thing a governance surface can say by accident. (3) It does not claim `core-sync` until a sync actually succeeded. Phases are translated by a WRITTEN table — the Core says `quality`/`deployment` where the Tracker says `qa`/`release` — and an unmapped phase is dropped with a warning rather than guessed, because filing an artifact under a phase nobody evaluates makes it invisible. 8 tests. **What stays blocked is UMS**, both halves: no JWKS or OIDC discovery, no auth graph, `T-053` still `Proposed`. Re-probe before assuming either moved; this row was closed once on mocks. | 🔴 CRITICAL | 🔴 HIGH | +| 2 | 🟢 RESOLVED | [OPP-002](#detail-opp-002) | OPP | Improvement opportunity | Backend | N/A | N/A | Extraer AuditTrail como Shared Kernel — 5+ contextos implementan historiales inmutables | RESOLVED — REFUTED by measurement 2026-08-02. The row says «5+ contexts implement immutable histories» and proposes extracting an AuditTrail shared kernel. **The shared kernel already exists, in two layers, and no context has its own.** (1) `Domain/ValueObjects/AuditProps.cs` is a single audit stamp used by **16** contexts — Geo, Governance, Qa, Tenancy, Sdlc, Integration, Intake, Discovery, Products, Design, Audit, Artifacts, Release, Metrics, Construction. (2) `Domain/Audit/AuditEntry` plus `IAuditEntryRepository` is ONE append-only trail: a repository-wide search for a second history repository returns exactly one interface. The strongest evidence is GT-588's signing wire itself — it signs every governed decision by decorating that single interface, which only works because gate evaluation, approval recording, decision publication and agent turns all already write through it. Five paths, one trail; the duplication the row assumes would have made that decorator impossible. Append-only is enforced by `AuditAppendOnlyTests`, which fails if either the repository or the aggregate grows a mutator. **One real thing the row was circling, recorded rather than lost:** `AuditProps` deliberately does NOT adopt the Core's shared-kernel `AuditProps`, because the Core's members are `CreatedAt`/`UpdatedAt` and mutable-in-place while the Tracker's are `CreatedAtUtc`/`UpdatedAtUtc` on immutable records that the persisted schema depends on. That divergence is written in the type's own docstring citing ADR-0071. It is a deliberate deviation with a reason, not the missing extraction this row describes. | 🟠 HIGH | 🔴 HIGH | +| 3 | 🟢 RESOLVED | [GAP-020](#detail-gap-020) | Docs | Documentation gap | Docs | Docs | N/A | Artifact Schema URLs from Core Missing | RESOLVED 2026-08-02. Of the four things the reopen asked for, two shipped and two are settled by decision. **Shipped:** the references are consumed as data (`CoreArtifactSchemaCatalog`), every artifact in `GET /api/phase-artifact-profiles` carries its `canonicalSchema` `$id`, the artifact-fields screen links it, and a test compares the code against `CORE_ARTIFACT_SCHEMAS.md` in both directions. **Withdrawn by product direction (user, 2026-08-02): validating artifact content against these schemas.** T-056 rules — content validation is the tenant's configuration and not engine code — so the Tracker offers the canonical shape and never enforces it. **Handed to GAP-004:** tenant-aware persistence or caching of the references. With validation withdrawn a cache has no consumer, and the meaningful version of «stop mirroring, start consuming» is syncing the catalog from the Core, which is exactly the `IPhaseArtifactProfileSource` seam GAP-004 owns and which is BLOCKED on the Core exposing it. Recorded as a handover rather than dropped, so the residue has an owner instead of evaporating. | 🟡 MEDIUM | 🟡 MEDIUM | +| 4 | 🟢 RESOLVED | [GAP-023](#detail-gap-023) | Docs | Documentation gap | Docs | Docs | N/A | Re-Do Flow Not Fully Designed | RESOLVED — stale row; the evidence was in the catalog and never reached this register. `tracker-gap-reference-catalog.md` closed it on 2026-07-20 with «YA HECHA» and named the artifact. Re-verified here rather than taken on trust: `reference/specs/design/tracker-redo-flow-design.md` is 254 lines with trigger conditions, the state model, the domain model inside the Release context, the recalculation algorithm, impact propagation, the human-authorization gate, domain events and a traceability table. Its §10 lists five scheduling decisions (RD-D1…RD-D5) explicitly registered as proposals awaiting PO ratification — that is a decision pending an owner, not a design that is missing. | 🟡 MEDIUM | 🟡 MEDIUM | +| 5 | 🟢 RESOLVED | [GAP-025](#detail-gap-025) | Docs | Documentation gap | Docs | Docs | N/A | Portal DDD incomplete — NARROWED 2026-08-01: the strategic-map half is DONE (`reference/specs/architecture/bounded-context-map.md` calls itself «the single, authoritative strategic map» of the 9 contexts, with integration patterns and cross-context events). What remains is the four support contexts. | RESOLVED — stale row, same shape. The catalog closed it on 2026-07-19; this register kept the narrowed half open. Verified directly: `bounded-context-map.md` §2 classifies all nine contexts and names the four supporting ones — Governance, Artifacts, Metrics, Integration — each linked to its own tactical model, and all ten `ddd-model.md` files exist on disk. Both halves of the row are satisfied. | 🟡 MEDIUM | 🟡 MEDIUM | +| 6 | 🟢 RESOLVED | [GAP-016](#detail-gap-016) | Docs | Documentation gap | Docs | Docs | N/A | Roadmap Has No Calendar Dates | RESOLVED — stale row. `reference/specs/design/tracker-implementation-roadmap.md` has carried `Document Status: SUPERSEDED — historical NestJS-era draft` since it was corrected, and the marker cites **this row by id**: «SUPERSEDED (GAP-016)». The roadmap planned a NestJS build that was executed in .NET, so «no calendar dates» is moot on a document that describes a plan already carried out under a different stack. The other board, `tracker-gap-tracking.md`, recorded this correctly; this register did not. | 🟡 MEDIUM | 🟡 MEDIUM | +| 7 | 🟢 RESOLVED | [COH-012](#detail-coh-012) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-006 | Gherkin covers only 2 of 4 CRUD operations (Create, Update). Delete and Read entirely missing. Zero edge cases. | RESOLVED — 9 scenarios added to `docs/design/discovery-functional-specs.md`, covering Create (type derivation for 0/1/2/7 products against `InitiativeScope.Derive`, the `Initiative.TenantRequired` guard, and `ConsolidateFrom` lineage), Read (own-tenant hit, and other-tenant read returning exactly what a non-existent id returns — the shape that stops the id being confirmed), and withdrawal. **The row's premise was partly wrong and is corrected rather than obeyed:** there is no Delete in this aggregate and there must not be. An initiative is superseded into another (`Supersede` → `consolidated`, BR-DIS-005) because its lineage is governance evidence. Writing a Delete scenario would have satisfied the row and described a system that does not exist. The COH-012 probe in `falsifiable-claims.json` is therefore withdrawn, with the reason recorded in that file: it had inherited the same false premise. | 🟢 | 🟢 | +| 8 | 🟢 RESOLVED | [GAP-011](#detail-gap-011) | Docs | Documentation gap | Docs | Docs | N/A | C4 Topology No English version | RESOLVED — the row was TRUE and had been mismeasured. `c4-macro-topology-phase1.md` existed, was labelled «English (this document)» and passed `check-bilingual-parity`, but 86 of its 87 non-empty lines were byte-identical to the Spanish file: only the navigation banner had been translated. It is now genuinely in English — prose, diagram descriptions and relationship labels — and its entry was removed from `untranslated-allowlist.json`, which drops from 9 declared to 7. Note for the record: the other board, `tracker-gap-tracking.md`, marked this DONE while the file was still Spanish. | 🟡 MEDIUM | 🟡 MEDIUM | +| 9 | 🟢 RESOLVED | [GAP-017](#detail-gap-017) | Docs | Documentation gap | Docs | Docs | N/A | Discovery Canvas Has No ES Version | RESOLVED — same defect and same correction as GAP-011. `DISCOVERY_CANVAS.md` was 23 of 24 non-empty lines identical to `DISCOVERY_CANVAS.es.md`; the English label was the only English in it. Now translated in full and removed from `untranslated-allowlist.json`. Both boards had this backwards on direction — one said the ES version was missing, the other said the EN one was — and what was actually missing was English content behind an English filename. | 🟡 MEDIUM | 🟡 MEDIUM | +| 10 | 🟢 RESOLVED | [COH-009](#detail-coh-009) | INCO | Source incoherence | Backend | Release | All REL | BR-003 conflated: product-brief = "no deploy without QA gate", but 5+ stories invoke as "human authorization required." Two rules sharing one ID. | RESOLVED — and the repository says so in the row's own words. The business-rule table in `evolith-tracker-crosscutting-diagram.md` now carries a `BR-010` row, *CFR Quality Threshold*, whose own description reads «escindida de BR-003 por COH-009; BR-003 es sólo la firma humana». The split this row asked for was made, credited to this row, and the row was never moved. Verified across the repository: every `BR-003` occurrence outside the audit tree means Human Sign-Off and nothing else, and the QA-gate half lives as `BR-010` — cited by `reference/specs/qa/` («no deployment is authorized without an approved QA verdict and CFR < 2%»), by the test strategy against `PhaseGateEvaluator`, and by the Re-Do flow design. Two rules, two ids. | 🟢 | 🟢 | +| 11 | 🟢 RESOLVED | [COH-016](#detail-coh-016) | INCO | Source incoherence | Backend | Release | All REL | Three gap surfaces share one id namespace and only two were kept in step; 5 of the 34 shared ids disagreed. | RESOLVED — found while closing COH-009 and the two translation rows, and recorded here because it explains all of them. This repository keeps THREE gap surfaces sharing one id namespace: this register, `tracker-gap-tracking.md` and `tracker-gap-reference-catalog.md`. `check-gap-registry.py` kept the last two in step and never looked at this one. Measured on 2026-08-01: 34 ids in common, and **5 of them contradicted each other** — COH-012, GAP-016, GAP-023 and GAP-025 closed on the board and open here, GAP-020 the reverse. Neither surface was the reliable one: the board wrongly claimed GAP-011 and GAP-017 done while both files were still Spanish, and this register wrongly kept three closed rows open. Fixed by extending the guard to compare the two vocabularies on the only thing both state unambiguously — closed or not — with six self-tests, three of which go red when the comparison is disabled. | 🟢 | 🟢 | +| 12 | 🟢 RESOLVED | [GAP-013](#detail-gap-013) | Docs | Documentation gap | Docs | Docs | N/A | 14 Technical Design Docs Lack ES Version | REFUTED and MEASURED 2026-08-01. The row says 14 technical design documents lack a Spanish version. `reference/specs/design/` holds **20** documents and **every one** has its `.es.md` — zero missing. Repository-wide, 19 `.md` files have no Spanish pair, and **9 of them are under `docs/audit/`**: audit reports, corpus triages and this register itself, which are working documents rather than product documentation. The other ten are READMEs and task notes (`robosoft/README.md`, `product/infra/helm/README.md`, `Tracker.ArchitectureTests/README.md`, `docs/tasks/*`). None is a technical design document. **A related defect DOES survive and is tracked elsewhere:** having the pair is not having the translation — `check-translation-language` found 9 documents whose `.md` is written in Spanish, which is what `GAP-011` and `GAP-017` are about. | 🟡 MEDIUM | 🟡 MEDIUM | +| 13 | 🟢 RESOLVED | [GAP-015](#detail-gap-015) | Docs | Documentation gap | Docs | Docs | N/A | DECISIONS.md Lacks Description of T-001 Technical Rationale | Resolved 2026-08-01 by writing `docs/adrs/T-001-nx-monorepo-orchestration.md` (+ `.es`). **The row was right and understated it:** the single line T-001 did carry was also WRONG. It said «npm workspaces con Nx», and no `package.json` in this repository declares a `workspaces` field — Nx orchestrates by PROJECT GRAPH (`project.json` plus the `@nx/vite`, `@nx/webpack`, `@nx/eslint` and `@nx/jest` inference plugins in `src/nx.json`). A reader would have looked for a `workspaces` array, not found one, and concluded the monorepo was misconfigured. The ADR records the reason that decided it — `tracker-api` is .NET, and npm workspaces links `node_modules` between npm packages, so it would have covered three projects of four and left the largest outside — and the DECISIONS entry is corrected in both languages. | 🟡 MEDIUM | 🟡 MEDIUM | +| 14 | 🟢 RESOLVED | [GAP-021](#detail-gap-021) | Docs | Documentation gap | Docs | Docs | N/A | Redis in Docker Compose Without Requirement | REFUTED TWICE against the repository 2026-08-01. **The premise is gone:** there is no `docker-compose` file in this repository at all, no Redis in the Helm charts under `product/infra/helm/` — which is the real deployment — and no Redis in application code. **And the requirement it says is missing was decided:** `T-026` («Redis solo para soporte operacional») is recorded in `AdrRegistryEndpoints`, stating «Redis is operational support only (cache/locks/jobs/idempotency); PostgreSQL is the system of record». The row asks for a requirement that exists, about a file that does not. | 🟢 LOW | 🟡 MEDIUM | +| 15 | 🟢 RESOLVED | [GAP-022](#detail-gap-022) | Docs | Documentation gap | Docs | Docs | N/A | SPACE Metrics Not Defined | REFUTED by `check-falsifiable-claims` 2026-08-01: `reference/specs/metrics/prd.md` carries `REQ-MET-03` — «Compute SPACE metrics (Satisfaction, Performance, Activity, Communication, Efficiency)» — with acceptance criteria naming all five scores plus trend, threshold and last-computed timestamp. The framework is defined; the row is stale. | 🟡 MEDIUM | 🟡 MEDIUM | +| 16 | 🟢 RESOLVED | [COH-008](#detail-coh-008) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-004 | DeploymentRecord Aggregate Root NEVER created by any story. UC-005b ("records a DeploymentRecord") cannot be fulfilled. | REFUTED against the code 2026-08-01: the aggregate the row says was never created EXISTS, complete. `Tracker.Domain/Release/DeploymentRecord` ships `Start`, `Finish` and `RollBack`; it persists to `tracker_release.deployment_records`; three endpoints expose it in `ConstructionReleaseEndpoints` (`POST /`, `POST /{id}/finish`, `POST /{id}/rollback`); and `ConstructionReleaseTests` covers it. UC-005b («records a DeploymentRecord») is therefore fulfillable — the row describes a state of the repository that has not held for some time, and nobody moved it. | 🟡 | 🟡 | +| 17 | 🟢 RESOLVED | [COH-005](#detail-coh-005) | GAP | Missing capability / corrective gap | Infra | Infra | US-INF-002 | Creates 10 schemas but omits `tracker_audit`. Data design defines 11 schemas; audit trail (BR-009) backbone missing. | Resolved by SUPERSESSION 2026-08-01, and the row simply never moved: `T-047` (Accepted 2026-07-19) ratifies the consolidated four-schema topology, supersedes `T-028` (schema-per-context, 10 schemas) and **names COH-005 among the gaps it closes**. `tracker_audit` was never created ON PURPOSE — the audit aggregates live in `tracker_governance`, where they work: `audit_entries` is queried in production, `GT-603` typed its actor, and `GT-588` wired signed transparency statements on top of it. The data design says so itself: sections 3–12 are «the original 2026-06-07 TARGET design … kept for traceability only … read them as intent, never as a description of the database». Nothing was missing; a document and the code disagreed, and the ADR already decided which one wins. | 🟡 | 🟡 | +| 18 | 🟢 RESOLVED | [COH-002](#detail-coh-002) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-006,007 | Discovery refinement entity has no persisted `status` column. States DRAFT/IN_REFINEMENT/SPLIT referenced in specs cannot be persisted; DDD model also missing the status field. | Resolved / Obsolete: El proceso de partición y refinamiento queda fuera del alcance de Tracker; las iniciativas sólo entran cuando están listas. | 🟢 LOW | 🟢 LOW | +| 19 | 🟢 RESOLVED | [COH-004](#detail-coh-004) | INCO | Source incoherence | Backend | QA | US-QA-001..004,006,008,011 | "TestRun" used in 7 stories but NOT in DDD model. DDD defines TestCycle (container) + TestExecution (atomic). TestCycle Aggregate Root never used by any story. | Resolved: TestCycle/TestExecution es el estándar canónico oficial; TestRun se depreca por ambiguo. Glosario actualizado. Historias asumen TestCycle. | 🔴 | 🔴 | +| 20 | 🟢 RESOLVED | [GAP-019](#detail-gap-019) | Docs | Documentation gap | Docs | Docs | N/A | Discovery requirements not broken down into implementable detail | Resolved: Se creó `docs/design/discovery-functional-specs.md` extrayendo las reglas de negocio, máquina de estados y cadenas de aprobación directamente del modelo DDD (Initiative.cs). | 🔴 HIGH | 🟡 MEDIUM | +| 21 | 🟢 RESOLVED | [GAP-010](#detail-gap-010) | Docs | Documentation gap | Docs | Docs | N/A | PRODUCT_VISION.md No English version | Resolved: PRODUCT_VISION.md ya se encontraba traducido y actualizado en inglés. Registro obsoleto. | 🟢 HIGH | 🟡 MEDIUM | +| 22 | 🟢 RESOLVED | [COH-010](#detail-coh-010) | INCO | Source incoherence | Backend | Construction | US-CON-008 | DDD defines `markAsDone()` but no `reopen()`/`revertStatus()`. Yet story allows DONE→IN_REVIEW regression. Contract breach between spec and domain model. | Resolved: Falsa alarma. La regresión real operaba en PhaseProgression y es estrictamente monótona. | 🟡 | 🟡 | +| 23 | 🟢 RESOLVED | [COH-001](#detail-coh-001) | INCO | Source incoherence | Backend | Discovery | US-DIS-002..005,011 | Status naming: `CANVAS_SUBMITTED` vs DB `submitted`. InitiativeStatus enum undefined in DDD. `under_review` in DB but zero specs. | Resolved: El desajuste se arregló previamente (2026-07-20) extrayendo InitiativeStatus como constantes y aplicando CHECK constraint. | 🟡 | 🟡 | +| 24 | 🟢 RESOLVED | [COH-003](#detail-coh-003) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-006 | Violates Small Aggregates pattern: nests child records inside a parent aggregate instead of referencing them by UUID. AGENTS.md mandates UUID refs only. | Resolved: No verificable (la historia US-DIS-006 no existe). El código actual SÍ utiliza referencias UUID correctamente. | 🟡 | 🟡 | +| 25 | 🟢 RESOLVED | [COH-011](#detail-coh-011) | INCO | Source incoherence | Backend | Discovery | US-DIS-003 | "ApprovalChain" aggregate referenced in Gherkin + Tech Reqs, but DDD model only defines `ApprovalGate` VO (0..1 cardinality). No ApprovalChain Aggregate Root exists. | Resolved: El código está bien (implementa ApprovalChain nativamente); el error era documental en una spec antigua. | 🟡 | 🟡 | +| 26 | 🟢 RESOLVED | [COH-006](#detail-coh-006) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-010 | README says "MCP polling/sampling/agent SDK" but 0 of 6 MCP tools exist. | RESOLVED 2026-08-02, by fixing the DOCUMENTS and not by building tools. The row's own closure note claimed `tracker-gateway` exposes 6/6 Tracker MCP tools; it does not, and its controller says so in a comment: «The Core remains the single agent-facing MCP server; this is a client of it». The history explains both: the six tools were built (`c5f3a50`), rebound to the BFF (`293e3de`, T-051) and then DELETED (`614ddff`) when **T-052** made the Core's `evolith-mcp` the single agent-facing surface. So the finding was live again in its original form — docs promising an MCP surface that does not exist — with a different remedy. Aligned to T-052: PRD §5.3 (EN+ES) rewritten with where each capability is served now, and the local-dev guide (EN+ES) corrected, where the smoke command still sent readers to `POST /api/mcp/tracker_get_assignment`, a route that returns 404. | 🟡 | 🟡 | +| 27 | 🟢 RESOLVED | [COH-007](#detail-coh-007) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-010 | PRD §5.3 defines 6 MCP tools; 0 implemented. BR-009 audit unenforceable via MCP. | RESOLVED 2026-08-02, same wave and same reason as COH-006. Four more documents still described the deleted surface — `ux-concept` (EN+ES), `blueprint` (EN+ES) and `tracker-agent-assignment-api` (EN+ES) — and each now carries a banner naming T-052, saying the `tracker_*` names are historical and pointing at `/api/core-mcp/*`. The text is kept rather than deleted: the capability each one describes is real and served by the `tracker-api` BFF; what disappeared was the transport. On BR-009: audit is not unenforceable, it is enforced on the BFF write path rather than through a Tracker-hosted MCP tool. | 🟡 | 🟡 | +| 28 | 🟢 RESOLVED | [COH-300](#detail-coh-300) | GAP | Missing capability / corrective gap | Backend | Construction | US-CON-008 | Architecture Drift blocks DONE transition scenario already present | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | +| 29 | 🟢 RESOLVED | [COH-304](#detail-coh-304) | GAP | Missing capability / corrective gap | Backend | Construction | US-CON-005 | Shell compliance fixed: WFE/IntegrationFabric/TenantConfig injected; seq diagram updated | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 30 | 🟢 RESOLVED | [COH-305](#detail-coh-305) | INCO | Source incoherence | Backend | Construction | US-CON-012 | RefinementLock VO added to Construction DDD model + Ubiquitous Language | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 31 | 🟢 RESOLVED | [COH-201](#detail-coh-201) | GAP | Missing capability / corrective gap | Backend | Design | US-DES-001 | BR-002 enforcement: US-CON-002 rejects linking tasks to DRAFT Blueprint | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 32 | 🟢 RESOLVED | [COH-203](#detail-coh-203) | GAP | Missing capability / corrective gap | Backend | Design | US-DES-011 | DataSchema story created — full CRUD + Core validation for DDD DataSchema AR | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 33 | 🟢 RESOLVED | [COH-204](#detail-coh-204) | GAP | Missing capability / corrective gap | Backend | Design | US-DES-002 | Visual contract designer scenario added to Gherkin + NFRs | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 34 | 🟢 RESOLVED | [COH-205](#detail-coh-205) | INCO | Source incoherence | Backend | Design | US-DES-006 | @evolith/integration-fabric added to dependencies + bounded context | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | +| 35 | 🟢 RESOLVED | [COH-207](#detail-coh-207) | INCO | Source incoherence | Backend | Design | US-DES-007 | VersionEntry VO added to Design DDD model + TechnicalContract.versionHistory | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 36 | 🟢 RESOLVED | [COH-101](#detail-coh-101) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-014 | Discovery Canvas Builder created — guided form enforcing ROI, KPIs, risks | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 37 | 🟢 RESOLVED | [COH-102](#detail-coh-102) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-013 | Point estimate updated from 5→13 to reflect merge engine complexity | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | +| 38 | 🟢 RESOLVED | [COH-106](#detail-coh-106) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-003 | Edge cases (checklist blocking, state guard, Architect estimation, resubmission) confirmed present | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | +| 39 | 🟢 RESOLVED | [COH-202](#detail-coh-202) | GAP | Missing capability / corrective gap | Backend | Governance | AGENTS.md | AGENTS.md §23 already requires RequirementChecklist injection by WorkflowEngine | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | +| 40 | 🟢 RESOLVED | [COH-600](#detail-coh-600) | GAP | Missing capability / corrective gap | Backend | Governance | US-GOV-010 | Approval chain config story created — all 5 flow types (simple/seq/parallel/hierarchical/mixed) | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 41 | 🟢 RESOLVED | [COH-603](#detail-coh-603) | INCO | Source incoherence | Backend | Integration | US-INT-004 | StatusMappingACL added; direct Jira→Evolith status mapping prevented | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 42 | 🟢 RESOLVED | [COH-602](#detail-coh-602) | INCO | Source incoherence | Backend | Metrics | US-MET-003 | DriftAlertEvent removed: Metrics consumes DriftDetectedEvent (Conformist); warning-only | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 43 | 🟢 RESOLVED | [COH-403](#detail-coh-403) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-002 | Automatic .harness trigger scenario added on Construction DONE | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 44 | 🟢 RESOLVED | [COH-404](#detail-coh-404) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-003 | CFR cold-start scenario: insufficient data message + gate blocked | Resolved; evidence captured in the description. | 🟠 HIGH | 🟢 LOW | +| 45 | 🟢 RESOLVED | [COH-405](#detail-coh-405) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-004 | Root Cleanliness added to QA gate conditions (US-QA-007 violations block gate) | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 46 | 🟢 RESOLVED | [COH-406](#detail-coh-406) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-005 | Coverage gate enforcement scenario (below 60% blocks advancement) | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 47 | 🟢 RESOLVED | [COH-407](#detail-coh-407) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-010 | ArtifactInstance Core schema validation on QA Report export | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 48 | 🟢 RESOLVED | [COH-500](#detail-coh-500) | INCO | Source incoherence | Backend | Release | US-REL-003 | State naming: RE-DO_SCHEDULED→Replanned, events realigned | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 49 | 🟢 RESOLVED | [COH-501](#detail-coh-501) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-001 | QA gate validation: rejection scenario for non-passed gate prevents Release creation | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 50 | 🟢 RESOLVED | [COH-502/503](#detail-coh-502-503) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-008 | Human authorization + DeploymentRecord status transition on rollback | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 51 | 🟢 RESOLVED | [COH-504](#detail-coh-504) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-009 | Agent deployment execution + report_deployment_status MCP tools | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 52 | 🟢 RESOLVED | [COH-700](#detail-coh-700) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-008 | 4 missing CLI commands added: list, reassign, unassign, mode set | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 53 | 🟢 RESOLVED | [COH-704](#detail-coh-704) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-004 | 3 missing Construction commands added: cycle start, review submit, drift get | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 54 | 🟢 RESOLVED | [COH-705](#detail-coh-705) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-007 | Gate list command added; evaluate/blockers/exception still unresolved | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 55 | 🟢 RESOLVED | [COH-706](#detail-coh-706) | GAP | Missing capability / corrective gap | CLI | CLI | US-CLI-002 | 2 missing Discovery commands added: initiative init, initiative list | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 56 | 🟢 RESOLVED | [GAP-009](#detail-gap-009) | GAP | Missing capability / corrective gap | CLI | CLI | N/A | BMAD Agent Assignment API Endpoints Missing | Created `reference/specs/design/tracker-agent-assignment-api.md` | 🟠 HIGH | 🟡 MEDIUM | +| 57 | 🟢 RESOLVED | [GAP-005](#detail-gap-005) | GAP | Missing capability / corrective gap | Docs | Docs | N/A | Roadmap subestima 45 puntos (16%) | Corregido: 101 stories/325 pts, Phase 0→M(2w), ~18.5w, R-16 registrado | 🟠 HIGH | 🟢 LOW | +| 58 | 🟢 RESOLVED | [COH-601](#detail-coh-601) | GAP | Missing capability / corrective gap | Infra | Infra | US-INF-009 | Audit schema lifecycle story created — tracker_audit bootstrap, append-only trigger, RLS | Resolved; evidence captured in the description. | 🟠 HIGH | 🟡 MEDIUM | +| 59 | 🟢 RESOLVED | [GAP-002](#detail-gap-002) | GAP | Missing capability / corrective gap | Infra | Infra | N/A | PostgreSQL Schema Names Inconsistent Across Documents | Schema naming `tracker_` adopted | 🟠 HIGH | 🟢 LOW | +| 60 | 🟢 RESOLVED | [GAP-003](#detail-gap-003) | GAP | Missing capability / corrective gap | Infra | Infra | N/A | GraphQL API Status Undefined | REST + OpenAPI 3.0 only in Phase 1 | 🟠 HIGH | 🟢 LOW | +| 61 | 🟢 RESOLVED | [GAP-006](#detail-gap-006) | GAP | Missing capability / corrective gap | Docs | Docs | N/A | Subestimación de puntos no registrada en Risk Register | R-16 añadido a `tracker-risk-register.md` con mitigación y owner | 🟡 MEDIUM | 🟢 LOW | +| 62 | 🟢 RESOLVED | [GAP-007](#detail-gap-007) | GAP | Missing capability / corrective gap | Docs | Docs | N/A | CLI/MCP interbloqueo de fases con feature parity (BR-008) | Roadmap reestructurado: CLI Foundation en Phase 1, CLI distribuido Phase 2-7 | 🟡 MEDIUM | 🔴 HIGH | +| 63 | 🟢 RESOLVED | [COH-821](#detail-coh-821) | INCO | Source incoherence | Backend | Artifacts | US-ART-003 | EvidenceRecord links via ArtifactInstance→PhaseGateState chain. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 64 | 🟢 RESOLVED | [COH-907](#detail-coh-907) | GAP | Missing capability / corrective gap | Backend | Artifacts | N/A | EvidenceChain visualization story created (US-ART-004) — chain traversal + PDF export. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 65 | 🟢 RESOLVED | [COH-808](#detail-coh-808) | GAP | Missing capability / corrective gap | Backend | Construction | All CON | Functional-scope.md references verified — no broken links remain. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 66 | 🟢 RESOLVED | [COH-806](#detail-coh-806) | INCO | Source incoherence | Backend | Design | US-DES-009,010 | C4 Generator + STRIDE Analyzer added to Design DDD ubiquitous language. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 67 | 🟢 RESOLVED | [COH-807](#detail-coh-807) | GAP | Missing capability / corrective gap | Backend | Design | All DES | Tenant scoping added to US-DES-001 (entry point), inherited by remaining Design stories. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 68 | 🟢 RESOLVED | [COH-800](#detail-coh-800) | INCO | Source incoherence | Backend | Discovery | US-DIS-006..013 | All Discovery stories now have Feature:/Scenario: blocks. DIS-006 already had them; DIS-007..013 wrapped. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 69 | 🟢 RESOLVED | [COH-801](#detail-coh-801) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-006..013 | MCP execution scenarios added to all 7 stories (DIS-006 already had MCP parity). | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 70 | 🟢 RESOLVED | [COH-802](#detail-coh-802) | INCO | Source incoherence | Backend | Discovery | US-DIS-007 | IN_REFINEMENT refinement status defined in Discovery DDD §1. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 71 | 🟢 RESOLVED | [COH-803](#detail-coh-803) | OPP | Improvement opportunity | Backend | Discovery | US-DIS-005 | Scope→phase mapping made tenant-configurable via TenantConfigShell. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 72 | 🟢 RESOLVED | [COH-804](#detail-coh-804) | INCO | Source incoherence | Backend | Discovery | US-DIS-001 | Template field mapping: roiRationale→estimatedRoi fixed in US-DIS-001. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 73 | 🟢 RESOLVED | [COH-805](#detail-coh-805) | INCO | Source incoherence | Backend | Discovery | US-DIS-002,004 | Point delta acknowledged: BusinessCase (external inputs) = 5 vs TJ (internal) = 3. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 74 | 🟢 RESOLVED | [COH-900](#detail-coh-900) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-012 | Trailing template line removed. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 75 | 🟢 RESOLVED | [COH-901](#detail-coh-901) | GAP | Missing capability / corrective gap | Backend | Discovery | US-DIS-011 | Fixed: missing closing `**` after EPIC-005. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 76 | 🟢 RESOLVED | [COH-902](#detail-coh-902) | OPP | Improvement opportunity | Backend | Discovery | US-DIS-001 | Downstream scenario noted for Phase 1 refactoring. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 77 | 🟢 RESOLVED | [COH-816](#detail-coh-816) | GAP | Missing capability / corrective gap | Backend | Governance | US-GOV-009 | Agent framework selection (bmad/spec-kit/custom) + FrameworkChangedEvent. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 78 | 🟢 RESOLVED | [COH-817](#detail-coh-817) | GAP | Missing capability / corrective gap | Backend | Governance | US-GOV-001 | SatelliteProduct lifecycle: archive(), PENDING_REVALIDATION, status transitions. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 79 | 🟢 RESOLVED | [COH-909](#detail-coh-909) | GAP | Missing capability / corrective gap | Backend | Governance | N/A | Governance 5-gate demo story created (US-GOV-011) — end-to-end gate command trace. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 80 | 🟢 RESOLVED | [COH-822](#detail-coh-822) | INCO | Source incoherence | Backend | Infra | US-INF-008 | Audit/telemetry distinction: permanent (BR-009) vs rotatable logs. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 81 | 🟢 RESOLVED | [COH-818](#detail-coh-818) | GAP | Missing capability / corrective gap | Backend | Integration | US-INT-007 | Health dashboard checks Core, UMS, GitHub, .harness, Jira — all 5 integrations. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 82 | 🟢 RESOLVED | [COH-819](#detail-coh-819) | INCO | Source incoherence | Backend | Integration | US-INT-008 | Gate advancement routed through Governance (AdvanceGateCommand). | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 83 | 🟢 RESOLVED | [COH-820](#detail-coh-820) | GAP | Missing capability / corrective gap | Backend | Metrics | N/A | SPACE metrics story created (US-MET-006) with all 5 SPACE dimensions. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 84 | 🟢 RESOLVED | [COH-809](#detail-coh-809) | INCO | Source incoherence | Backend | QA | US-QA-003,004,008 | TestCycle Aggregate Root referenced in all QA stories + caps fixed in QA-008 Gherkin. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 85 | 🟢 RESOLVED | [COH-810](#detail-coh-810) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-006 | Human authorization scenario added: gate blocks until QA Engineer approves. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 86 | 🟢 RESOLVED | [COH-811](#detail-coh-811) | INCO | Source incoherence | Backend | QA | US-QA-008 | CFR displayed as aggregate ratio across all TestCycles, not per-cycle field. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 87 | 🟢 RESOLVED | [COH-903](#detail-coh-903) | INCO | Source incoherence | Backend | QA | US-QA-004 | Persona clarified: QA Engineer runs tests, Release Manager approves gate. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 88 | 🟢 RESOLVED | [COH-904](#detail-coh-904) | GAP | Missing capability / corrective gap | Backend | QA | US-QA-002 | .harness execution is async with callback. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 89 | 🟢 RESOLVED | [COH-812](#detail-coh-812) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-003 | ReDoCycle AR listed in dependencies + persistent audit trail noted. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 90 | 🟢 RESOLVED | [COH-813](#detail-coh-813) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-001 | Calendar collision detection scenario with warning on same date/environment. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 91 | 🟢 RESOLVED | [COH-814](#detail-coh-814) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-006 | SPACE Survey Service story created (US-REL-010) — periodic survey trigger + webhook ingestion. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 92 | 🟢 RESOLVED | [COH-815](#detail-coh-815) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-004 | Authorization-time re-validation scenario + GateConditionChangedEvent. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 93 | 🟢 RESOLVED | [COH-905](#detail-coh-905) | INCO | Source incoherence | Backend | Release | US-REL-001 | DDD terminology: ReleasePackage aggregate name consistent. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 94 | 🟢 RESOLVED | [COH-906](#detail-coh-906) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-004 | Permission check documented in dependencies. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 95 | 🟢 RESOLVED | [COH-914](#detail-coh-914) | GAP | Missing capability / corrective gap | Backend | Release | US-REL-005 | DORA dashboard threshold noted for Phase 1 statistical review. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 96 | 🟢 RESOLVED | [COH-823](#detail-coh-823) | INCO | Source incoherence | CLI | CLI | Global | CLI README: 11 stories · 37 pts matching actual files. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 97 | 🟢 RESOLVED | [COH-824](#detail-coh-824) | INCO | Source incoherence | CLI | CLI | Global | MCP Tool Suite duplication resolved (US-CLI-010 Phase 5, US-CLI-011 Phase 7). | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 98 | 🟢 RESOLVED | [COH-910](#detail-coh-910) | INCO | Source incoherence | CLI | CLI | US-CLI-001 | `--format=json` adopted across all CLI stories per Core ADR 0073. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 99 | 🟢 RESOLVED | [COH-911](#detail-coh-911) | INCO | Source incoherence | CLI | CLI | US-CLI-009 | Title aligned: "MCP Server Bootstrap" in both story and README. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 100 | 🟢 RESOLVED | [COH-912](#detail-coh-912) | INCO | Source incoherence | CLI | CLI | US-CLI-003 | Context flag `--initiative` standardized across CLI stories. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 101 | 🟢 RESOLVED | [COH-913](#detail-coh-913) | GAP | Missing capability / corrective gap | CLI | CLI | Global | Offline-aware CLI story created (US-CLI-012) — queue, sync, conflict resolution. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 102 | 🟢 RESOLVED | [GAP-001](#detail-gap-001) | GAP | Missing capability / corrective gap | Docs | Docs | N/A | README.es.md Missing | Created README.es.md | 🟢 LOW | 🟢 LOW | +| 103 | 🟢 RESOLVED | [GAP-012](#detail-gap-012) | Docs | Documentation gap | Docs | Docs | N/A | MASTER_INDEX.md Carece de Cabecera de Navegación Bilingüe | Added bilingual nav header to MASTER_INDEX.md | 🟢 LOW | 🟢 LOW | +| 104 | 🟢 RESOLVED | [GAP-014](#detail-gap-014) | Docs | Documentation gap | Docs | Docs | N/A | Harness ADR-0002 Applies to .NET Only | Added .NET scope note to ADR-0002 | 🟢 LOW | 🟢 LOW | +| 105 | 🟢 RESOLVED | [GAP-018](#detail-gap-018) | Docs | Documentation gap | Docs | Docs | N/A | TAD Internal Links Broken | Fixed TAD internal links | 🟢 LOW | 🟢 LOW | +| 106 | 🟢 RESOLVED | [GAP-024](#detail-gap-024) | Docs | Documentation gap | Docs | Docs | N/A | No Observability Dashboard or Alert Specification | Created `reference/specs/infrastructure/tracker-observability-spec.md` | 🟢 LOW | 🟢 LOW | +| 107 | 🟢 RESOLVED | [COH-908](#detail-coh-908) | GAP | Missing capability / corrective gap | Infra | Infra | N/A | Secrets management story created (US-INF-010) — Vault + Docker/Helm injection. | Resolved; evidence captured in the description. | 🟢 LOW | 🟢 LOW | +| 108 | 🟢 RESOLVED | [GAP-008](#detail-gap-008) | GAP | Missing capability / corrective gap | Infra | Infra | N/A | Transactional Outbox Uses Prisma in TypeORM Project | Fixed OutboxProcessor to use TypeORM | 🟢 LOW | 🟢 LOW | @@ -149,7 +150,7 @@ This document is the only operational gap register in this repository. The maste - **Criticality:** 🔴 CRITICAL - **Complexity:** 🔴 HIGH - **Description:** 3 dependencias upstream bloqueadas (Core API, UMS JWKS, UMS Auth Graph) -- **Next Step:** Define owner and remediation action before execution. +- **Next Step:** Only UMS remains — JWKS/OIDC and the auth graph, both owner-blocked (`T-053`). The Core dependency is closed: the `core-sync` source is live and the `core-standin` mirror is the documented fallback for when the Core is unreachable, stamped so a consumer can tell which one it got. Re-probe UMS before assuming it moved. [Back to master register](#master-register) @@ -285,7 +286,7 @@ This document is the only operational gap register in this repository. The maste ### Detail COH-008 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** GAP (Missing capability / corrective gap) - **Component:** Backend - **Module:** Release @@ -293,14 +294,14 @@ This document is the only operational gap register in this repository. The maste - **Criticality:** 🟡 - **Complexity:** 🟡 - **Description:** DeploymentRecord Aggregate Root NEVER created by any story. UC-005b ("records a DeploymentRecord") cannot be fulfilled. -- **Next Step:** Define owner and remediation action before execution. +- **Next Step:** None — refuted. The aggregate, its table, its three endpoints and its tests are all present; verify with `grep -rn DeploymentRecord src/apps/tracker-api` before reopening. [Back to master register](#master-register) ### Detail COH-006 -- **Status:** 🔴 BLOCKED +- **Status:** 🟢 RESOLVED - **Type:** GAP (Missing capability / corrective gap) - **Component:** CLI - **Module:** CLI @@ -316,7 +317,7 @@ This document is the only operational gap register in this repository. The maste ### Detail COH-007 -- **Status:** 🔴 BLOCKED +- **Status:** 🟢 RESOLVED - **Type:** GAP (Missing capability / corrective gap) - **Component:** CLI - **Module:** CLI @@ -332,7 +333,7 @@ This document is the only operational gap register in this repository. The maste ### Detail GAP-011 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** Docs (Documentation gap) - **Component:** Docs - **Module:** Docs @@ -348,7 +349,7 @@ This document is the only operational gap register in this repository. The maste ### Detail GAP-013 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** Docs (Documentation gap) - **Component:** Docs - **Module:** Docs @@ -364,7 +365,7 @@ This document is the only operational gap register in this repository. The maste ### Detail GAP-015 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** Docs (Documentation gap) - **Component:** Docs - **Module:** Docs @@ -380,7 +381,7 @@ This document is the only operational gap register in this repository. The maste ### Detail GAP-016 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** Docs (Documentation gap) - **Component:** Docs - **Module:** Docs @@ -396,7 +397,7 @@ This document is the only operational gap register in this repository. The maste ### Detail GAP-017 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** Docs (Documentation gap) - **Component:** Docs - **Module:** Docs @@ -412,7 +413,7 @@ This document is the only operational gap register in this repository. The maste ### Detail GAP-020 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** Docs (Documentation gap) - **Component:** Docs - **Module:** Docs @@ -433,7 +434,7 @@ This document is the only operational gap register in this repository. The maste ### Detail GAP-022 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** Docs (Documentation gap) - **Component:** Docs - **Module:** Docs @@ -449,7 +450,7 @@ This document is the only operational gap register in this repository. The maste ### Detail GAP-023 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** Docs (Documentation gap) - **Component:** Docs - **Module:** Docs @@ -465,7 +466,7 @@ This document is the only operational gap register in this repository. The maste ### Detail GAP-025 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** Docs (Documentation gap) - **Component:** Docs - **Module:** Docs @@ -481,7 +482,7 @@ This document is the only operational gap register in this repository. The maste ### Detail COH-005 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** GAP (Missing capability / corrective gap) - **Component:** Infra - **Module:** Infra @@ -489,7 +490,23 @@ This document is the only operational gap register in this repository. The maste - **Criticality:** 🟡 - **Complexity:** 🟡 - **Description:** Creates 10 schemas but omits `tracker_audit`. Data design defines 11 schemas; audit trail (BR-009) backbone missing. -- **Next Step:** Define owner and remediation action before execution. +- **Next Step:** None — closed by `T-047`. Moving a working table, with production data and a signed transparency ledger on top of it, to satisfy a diagram that its own document labels «intent, never a description of the database», would be work in reverse. + +[Back to master register](#master-register) + + + +### Detail COH-016 + +- **Status:** 🟢 RESOLVED +- **Type:** INCO (Source incoherence) +- **Component:** Docs +- **Module:** Docs +- **Story(ies):** N/A +- **Criticality:** 🟡 MEDIUM +- **Complexity:** 🟢 LOW +- **Description:** Three gap surfaces share one id namespace; the coherence guard covered only two of them. +- **Next Step:** None — `check-gap-registry.py` now contrasts the register too, with self-tests in CI. [Back to master register](#master-register) @@ -497,7 +514,7 @@ This document is the only operational gap register in this repository. The maste ### Detail COH-012 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** GAP (Missing capability / corrective gap) - **Component:** Backend - **Module:** Discovery @@ -513,7 +530,7 @@ This document is the only operational gap register in this repository. The maste ### Detail COH-009 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** INCO (Source incoherence) - **Component:** Backend - **Module:** Release @@ -529,7 +546,7 @@ This document is the only operational gap register in this repository. The maste ### Detail GAP-021 -- **Status:** 🟡 OPEN +- **Status:** 🟢 RESOLVED - **Type:** Docs (Documentation gap) - **Component:** Docs - **Module:** Docs @@ -545,7 +562,7 @@ This document is the only operational gap register in this repository. The maste ### Detail OPP-002 -- **Status:** 🟡⏳ DEFERRED +- **Status:** 🟢 RESOLVED - **Type:** OPP (Improvement opportunity) - **Component:** Backend - **Module:** N/A @@ -553,7 +570,7 @@ This document is the only operational gap register in this repository. The maste - **Criticality:** 🟠 HIGH - **Complexity:** 🔴 HIGH - **Description:** Extraer AuditTrail como Shared Kernel — 5+ contextos implementan historiales inmutables -- **Next Step:** Define owner and remediation action before execution. +- **Next Step:** None — the extraction it proposes was already done. If a future context needs a history that AuditEntry cannot serve, that is a new row with its own evidence, not this one. [Back to master register](#master-register) diff --git a/docs/audit/untranslated-allowlist.json b/docs/audit/untranslated-allowlist.json new file mode 100644 index 00000000..3bbc3bcb --- /dev/null +++ b/docs/audit/untranslated-allowlist.json @@ -0,0 +1,37 @@ +{ + "$comment": "Documents labelled English that are written in Spanish. See .harness/scripts/check-translation-language.mjs.", + "policy": { + "why": "Nine were found on 2026-08-01 by a guard written that day; `check-bilingual-parity` cannot see language, so all nine had been passing. Declaring them makes the debt COUNTED instead of invisible, and makes the tenth fail immediately.", + "rule": "An entry is a promise to translate, not a pardon. Every reason must name the row that tracks it or state a concrete blocker. `not a priority` is not a reason." + }, + "allow": [ + { + "file": "DECISIONS.md", + "reason": "Root ADR index. Tracked by GAP-013 (bilingual debt). The largest of the nine at 128 Spanish stopwords to 1 English — translating it means translating every ADR summary it carries, so it is the last one to move, not the first." + }, + { + "file": "MASTER_INDEX.md", + "reason": "Root navigation index. Tracked by GAP-013. Its entries are titles of other documents, so it can only be translated after the documents it points at." + }, + { + "file": "docs/adrs/T-044-single-tenant-isolation-model.md", + "reason": "ADR awaiting PO ratification (its own Status says so). Translating an unratified decision would produce two versions to keep in step while its content may still change." + }, + { + "file": "reference/specs/architecture/scale-out-strategy.md", + "reason": "Tracked by GAP-013." + }, + { + "file": "docs/diagrams/evolith-tracker-master-flow.md", + "reason": "Tracked by GAP-013. Mermaid diagram labels dominate the prose, so a translation must decide whether node labels move too — a decision nobody has taken." + }, + { + "file": "docs/diagrams/evolith-tracker-geo-diagram.md", + "reason": "Tracked by GAP-013. Same Mermaid-label question as the master flow." + }, + { + "file": "docs/diagrams/evolith-tracker-crosscutting-diagram.md", + "reason": "Tracked by GAP-013. Same Mermaid-label question as the master flow." + } + ] +} diff --git a/docs/design/discovery-functional-specs.md b/docs/design/discovery-functional-specs.md index 82b96d28..f2949942 100644 --- a/docs/design/discovery-functional-specs.md +++ b/docs/design/discovery-functional-specs.md @@ -44,6 +44,83 @@ La compuerta de aprobación de Discovery es dinámica y se basa en el tipo de in A continuación, se detallan los escenarios base que guían el QA y aseguran la coherencia entre el requerimiento y el modelo de dominio. +> [!NOTE] +> **Sobre "las cuatro operaciones CRUD" (COH-012).** El hallazgo original pedía cubrir Create, Read, Update y Delete. Tres de las cuatro existen en el agregado; **`Delete` no existe y no debe existir**: una iniciativa nunca se borra, se *supersede* (`Supersede` → estado `consolidated`, BR-DIS-005), porque su linaje es evidencia de gobierno y borrarlo rompería la trazabilidad. Escribir un escenario `Delete` habría satisfecho la ficha y descrito un sistema que no es éste. Lo que sigue cubre Create, Read y la operación de retiro que el dominio sí tiene. + +```gherkin +Feature: Initiative Creation & Type Derivation + + Scenario Outline: Type is derived from the size of the product scope + Given a tenant "acme" + When an Initiative is created with Product(s) in its ProductScope + Then its InitiativeType is "" + And a Facet stub exists for each product in the scope + And the Initiative is in "draft" state with TypeFrozen false + + Examples: + | count | type | + | 0 | TENANT_WIDE | + | 1 | SINGLE_PRODUCT | + | 2 | CROSS_PRODUCT | + | 7 | CROSS_PRODUCT | + + Scenario: Creation without a tenant is refused + Given no tenant context + When an Initiative is created with an empty tenant identifier + Then the operation is rejected with error "Initiative.TenantRequired" + And no Initiative is persisted + + Scenario: A consolidating Initiative records the lineage of what it groups + Given three source inputs from Intake + When an Initiative is created via ConsolidateFrom with those three as constituents + Then the Initiative is in "draft" state + And its Constituents list the three sources + And its type is derived from the supplied scope, not from the constituents + +``` + +```gherkin +Feature: Reading an Initiative + + Scenario: An Initiative is readable by its own tenant + Given an Initiative belonging to tenant "acme" + When tenant "acme" requests it by id + Then the Initiative is returned with its scope, facets and approval state + + Scenario: An Initiative of another tenant is indistinguishable from one that does not exist + Given an Initiative belonging to tenant "acme" + When tenant "globex" requests it by the same id + Then nothing is returned + And the response is identical to requesting an id that was never created + + # The two outcomes MUST stay identical. A distinct "forbidden" answer would confirm to + # tenant "globex" that the id exists — which is the leak the null-for-both shape prevents. + +``` + +```gherkin +Feature: Withdrawing an Initiative (Supersede — the domain has no Delete) + + Scenario: A draft Initiative is superseded by a larger one + Given an Initiative in "draft" state + When it is superseded into initiative "INI-042" with reason "grouped into the platform epic" + Then the Initiative transitions to "consolidated" + And its SupersededByInitiativeId is "INI-042" + And the reason is retained in its metadata + And the Initiative is still readable — nothing is deleted + + Scenario: An already-consolidated Initiative cannot be superseded again + Given an Initiative in "consolidated" state + When it is superseded into another initiative + Then the operation is rejected with error "Initiative.NotConsolidatable" + + Scenario: A rejected Initiative cannot be superseded + Given an Initiative in "rejected" state + When it is superseded into another initiative + Then the operation is rejected with error "Initiative.NotConsolidatable" + +``` + ```gherkin Feature: Initiative State Machine & Scope Freezing diff --git a/product/infra/helm/README.md b/product/infra/helm/README.md index 01aa747f..6193e83f 100644 --- a/product/infra/helm/README.md +++ b/product/infra/helm/README.md @@ -50,6 +50,102 @@ for c in evolith-tracker-postgres evolith-tracker-api evolith-tracker-web; do done ``` +## Turning on the transparency ledger (GT-588) + +The Tracker can sign every governance decision it records — a COSE_Sign1 statement plus a Merkle +receipt, in the shape RFC 9943 (SCITT) defines — so an auditor can verify the record without +trusting the producer. **It ships OFF**, and turning it on is a deployment decision because it +changes what the product claims about its own audit trail and it requires real key material. + +The application **refuses to start** with signing enabled and no seeds. That is deliberate: falling +back to a development key would produce a ledger that looks signed and proves nothing, which is +worse than no ledger because it invites trust. + +Two Ed25519 seeds are required, and they must be **different**. RFC 9943 places receipt authority in +an entity separate from the issuer; with one key the receipt is a self-assertion and the governance +rule `AUD-TRANSP-04` rejects it. + +```bash +# 1. Generate two 32-byte seeds. Keep them in your secret store — never in git. +kubectl create secret generic tracker-transparency \ + --from-literal=Transparency__IssuerKeySeedBase64="$(openssl rand -base64 32)" \ + --from-literal=Transparency__TransparencyServiceKeySeedBase64="$(openssl rand -base64 32)" + +# 2. A PersistentVolumeClaim for the ledger. A ledger on the pod's ephemeral filesystem is erased +# on every restart, and a transparency log that deletes itself is exactly what the gap calls +# decorative — so the chart REFUSES to render without a claim. +kubectl apply -f your-ledger-pvc.yaml + +# 3. Enable it. +helm upgrade --install tracker-api product/infra/helm/evolith-tracker-api \ + --set transparency.enabled=true \ + --set transparency.existingSecretName=tracker-transparency \ + --set transparency.persistence.enabled=true \ + --set transparency.persistence.existingClaim=tracker-transparency-ledger +``` + +Verify with `evolith audit verify` against the ledger path. The governance rule `AUD-TRANSP-01` +fails a ledger that is missing or empty, so a signature that silently stopped being emitted shows +up at the next evaluation rather than never. + +**What the chart never does:** generate, default or version key material. Every seed comes from the +deployment's own secret store. + +## Images and where they come from (GT-435) + +Until 2026-08-02 this repository shipped **three Dockerfiles and no image CD**. Nothing built or +pushed them, while the charts referenced `evolith-tracker-api:0.0.1`, `evolith-tracker-web:0.0.1` +and `evolith-tracker-gateway:local` — the last a tag no registry can ever serve. The deployment +path was not untested; it was **not wired**. No green CI could have shown it, because nothing had +ever pulled these images. + +`.github/workflows/images.yml` publishes all three to GHCR on every merge to `main` and `develop`: + +| image | Dockerfile | context | +|---|---|---| +| `ghcr.io/beyondnetcode/evolith-tracker-api` | `src/apps/tracker-api/Dockerfile` | `src/` | +| `ghcr.io/beyondnetcode/evolith-tracker-gateway` | `src/apps/tracker-gateway/Dockerfile` | `src/` | +| `ghcr.io/beyondnetcode/evolith-tracker-web` | `src/apps/tracker-web/Dockerfile` | `src/` | + +Tags are `:latest` and `:`. Authentication is the built-in `GITHUB_TOKEN` with +`packages: write` — no extra secret, so it works from the first run. + +The workflow does not trust its own green tick: after pushing, it runs +`docker buildx imagetools inspect` on the `:` tag. A push step exiting zero is not proof the +image is pullable, and that gap between "the job was green" and "the artifact is there" is exactly +what let three charts point at nothing. + +**Production must pin the sha.** `--set image.tag=`. `latest` makes a chart deployable out of +the box and cannot be rolled back to a previous build, and `GT-448` requires a rollback that has +actually been exercised. + +`check-deployable-images.mjs` runs in CI and fails when a chart names an image no workflow +publishes, a local-only tag, or a semver with no git tag behind it. + +### Turning the deploy on + +`images.yml` also carries a `deploy` job. It is gated on the repository variable +`TRACKER_DEPLOY_ENABLED` and runs only from `main`, so it stays silent until somebody decides to +turn it on. + +| what | where | +|---|---| +| `TRACKER_DEPLOY_ENABLED=true` | repository **variable** | +| `COOLIFY_API_TOKEN` | repository **secret** | +| `COOLIFY_TRACKER_API_DEPLOY_HOOK` | repository secret | +| `COOLIFY_TRACKER_GATEWAY_DEPLOY_HOOK` | repository secret | +| `COOLIFY_TRACKER_WEB_DEPLOY_HOOK` | repository secret | + +**Nothing configured is a skip; PARTIALLY configured is a failure.** That is a deliberate +difference from the Core's equivalent job, where an unset hook warns and exits zero per service: +set the token, typo one hook, and CD reports success while that service never deploys. Half-wired +is not a state worth being quiet about. + +**And triggering is not deploying.** Coolify accepts the webhook and works asynchronously, so a +green job means the *request* was accepted — never that the new image is serving. `GT-448` asks for +a deploy evidenced by a recorded run against the live service, and that evidence has to be taken +there, not here. The job says so in its own step summary rather than letting the tick imply it. + ## Notes - **Connection string.** The .NET config system cannot concatenate a password diff --git a/product/infra/helm/evolith-tracker-api/templates/configmap.yaml b/product/infra/helm/evolith-tracker-api/templates/configmap.yaml index 90180c23..ffc95395 100644 --- a/product/infra/helm/evolith-tracker-api/templates/configmap.yaml +++ b/product/infra/helm/evolith-tracker-api/templates/configmap.yaml @@ -35,6 +35,16 @@ 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.transparency.enabled }} + # GT-588 — se emite SOLO cuando la firma esta activa, por la misma razon que Otlp: una clave + # presente en el configmap sugiere que hay expediente firmado cuando no lo hay. + Transparency__Enabled: "true" + Transparency__LedgerPath: {{ .Values.transparency.ledgerPath | quote }} + Transparency__Issuer: {{ .Values.transparency.issuer | quote }} + Transparency__IssuerKeyId: {{ .Values.transparency.issuerKeyId | quote }} + Transparency__TransparencyServiceIssuer: {{ .Values.transparency.transparencyServiceIssuer | quote }} + Transparency__TransparencyServiceKeyId: {{ .Values.transparency.transparencyServiceKeyId | quote }} + {{- end }} {{- 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 diff --git a/product/infra/helm/evolith-tracker-api/templates/deployment.yaml b/product/infra/helm/evolith-tracker-api/templates/deployment.yaml index 5152dd2a..6123169c 100644 --- a/product/infra/helm/evolith-tracker-api/templates/deployment.yaml +++ b/product/infra/helm/evolith-tracker-api/templates/deployment.yaml @@ -94,6 +94,22 @@ spec: name: {{ .Values.agentRuntime.existingSecretName }} key: {{ .Values.agentRuntime.apiKeyKey }} {{- end }} + {{- if and .Values.transparency.enabled .Values.transparency.existingSecretName }} + # GT-588 — las semillas Ed25519 llegan del deposito de secretos del despliegue, nunca + # del chart. Si `enabled` esta activo y falta el Secret, la aplicacion FALLA AL + # ARRANCAR: es preferible un pod que no levanta a uno que sirve un expediente sin + # firmar mientras el chart dice que lo firma. + - name: Transparency__IssuerKeySeedBase64 + valueFrom: + secretKeyRef: + name: {{ .Values.transparency.existingSecretName }} + key: {{ .Values.transparency.issuerKeySeedKey }} + - name: Transparency__TransparencyServiceKeySeedBase64 + valueFrom: + secretKeyRef: + name: {{ .Values.transparency.existingSecretName }} + key: {{ .Values.transparency.transparencyServiceKeySeedKey }} + {{- end }} - name: TMPDIR value: /tmp {{- with .Values.containerSecurityContext }} @@ -138,9 +154,21 @@ spec: successThreshold: {{ .Values.probes.startup.successThreshold }} {{- end }} volumeMounts: + {{- if and .Values.transparency.enabled .Values.transparency.persistence.enabled }} + - name: transparency-ledger + mountPath: {{ dir .Values.transparency.ledgerPath | quote }} + {{- end }} # readOnlyRootFilesystem is true; give .NET a writable temp dir. - name: tmp mountPath: /tmp volumes: + {{- if and .Values.transparency.enabled .Values.transparency.persistence.enabled }} + # GT-588 — el ledger de transparencia NO puede vivir en un emptyDir: se borraria en cada + # reinicio del pod, y un log de transparencia que se borra solo es exactamente lo que la + # ficha llama decorativo. Se exige un PVC que aporta el despliegue. + - name: transparency-ledger + persistentVolumeClaim: + claimName: {{ required "transparency.persistence.existingClaim es obligatorio cuando la persistencia del ledger esta activa" .Values.transparency.persistence.existingClaim }} + {{- end }} - name: tmp emptyDir: {} diff --git a/product/infra/helm/evolith-tracker-api/values.yaml b/product/infra/helm/evolith-tracker-api/values.yaml index bb1e0706..e9c5d14f 100644 --- a/product/infra/helm/evolith-tracker-api/values.yaml +++ b/product/infra/helm/evolith-tracker-api/values.yaml @@ -3,8 +3,15 @@ replicaCount: 2 image: repository: ghcr.io/beyondnetcode/evolith-tracker-api pullPolicy: IfNotPresent - tag: "0.0.1" - + # GT-435 — the tag must be one CI actually publishes, and until 2026-08-02 nothing published + # these images at all: the Tracker shipped three Dockerfiles and ZERO workflows that built or + # pushed them, so every chart pointed at something that did not exist. `images.yml` now pushes + # `:latest` and `:` on every merge to main and develop. + # + # PRODUCTION MUST OVERRIDE THIS with `--set image.tag=`. `latest` cannot be rolled back to + # a previous build, and GT-448 requires a rollback that has actually been exercised, which a + # floating tag makes impossible to demonstrate. + tag: latest service: type: ClusterIP port: 80 @@ -253,3 +260,31 @@ networkPolicy: protocol: TCP - port: 5432 protocol: TCP + +# GT-588 — firma del expediente (RFC 9943 / SCITT). DESACTIVADA por defecto y a proposito: +# encenderla cambia lo que el producto promete sobre su propia auditoria, y exige material de +# clave real. La aplicacion se NIEGA a arrancar con `enabled: true` sin semillas, en vez de caer +# a una clave de desarrollo: un ledger que parece firmado y no prueba nada induce confianza y es +# peor que no tener ledger. +# +# Las semillas NO viven aqui. `existingSecretName` apunta a un Secret que crea el despliegue +# (kubectl/sealed-secrets/KMS); este chart nunca genera ni versiona claves. +transparency: + enabled: false + # Ruta del ledger JSONL. DEBE caer en un volumen persistente: un ledger en el filesystem + # efimero del pod desaparece en cada reinicio, y un log de transparencia que se borra solo es + # exactamente lo que la ficha llama decorativo. + ledgerPath: /var/lib/evolith/transparency/ledger.jsonl + # Volumen del ledger. Sin esto la ruta de arriba vive en el contenedor y se pierde. + persistence: + enabled: false + existingClaim: "" + issuer: evolith-tracker + issuerKeyId: tracker-issuer + # El Servicio de Transparencia es una entidad SEPARADA por RFC 9943. Compartir clave con el + # Issuer convierte el recibo en una autoafirmacion, y `AUD-TRANSP-04` lo rechaza. + transparencyServiceIssuer: evolith-tracker-transparency + transparencyServiceKeyId: tracker-ts + existingSecretName: "" + issuerKeySeedKey: Transparency__IssuerKeySeedBase64 + transparencyServiceKeySeedKey: Transparency__TransparencyServiceKeySeedBase64 diff --git a/product/infra/helm/evolith-tracker-gateway/values.yaml b/product/infra/helm/evolith-tracker-gateway/values.yaml index 69397afd..3d3859c2 100644 --- a/product/infra/helm/evolith-tracker-gateway/values.yaml +++ b/product/infra/helm/evolith-tracker-gateway/values.yaml @@ -1,7 +1,15 @@ # Default (production-leaning) values for the tracker-gateway. image: - repository: evolith-tracker-gateway - tag: local + repository: ghcr.io/beyondnetcode/evolith-tracker-gateway + # GT-435 — the tag must be one CI actually publishes, and until 2026-08-02 nothing published + # these images at all: the Tracker shipped three Dockerfiles and ZERO workflows that built or + # pushed them, so every chart pointed at something that did not exist. `images.yml` now pushes + # `:latest` and `:` on every merge to main and develop. + # + # PRODUCTION MUST OVERRIDE THIS with `--set image.tag=`. `latest` cannot be rolled back to + # a previous build, and GT-448 requires a rollback that has actually been exercised, which a + # floating tag makes impossible to demonstrate. + tag: latest pullPolicy: IfNotPresent replicaCount: 1 diff --git a/product/infra/helm/evolith-tracker-web/values.yaml b/product/infra/helm/evolith-tracker-web/values.yaml index 8ab19809..3eafd469 100644 --- a/product/infra/helm/evolith-tracker-web/values.yaml +++ b/product/infra/helm/evolith-tracker-web/values.yaml @@ -3,8 +3,15 @@ replicaCount: 2 image: repository: ghcr.io/beyondnetcode/evolith-tracker-web pullPolicy: IfNotPresent - tag: "0.0.1" - + # GT-435 — the tag must be one CI actually publishes, and until 2026-08-02 nothing published + # these images at all: the Tracker shipped three Dockerfiles and ZERO workflows that built or + # pushed them, so every chart pointed at something that did not exist. `images.yml` now pushes + # `:latest` and `:` on every merge to main and develop. + # + # PRODUCTION MUST OVERRIDE THIS with `--set image.tag=`. `latest` cannot be rolled back to + # a previous build, and GT-448 requires a rollback that has actually been exercised, which a + # floating tag makes impossible to demonstrate. + tag: latest service: type: ClusterIP port: 80 diff --git a/reference/specs/architecture/c4-macro-topology-phase1.md b/reference/specs/architecture/c4-macro-topology-phase1.md index 6bfdf481..ca80530f 100644 --- a/reference/specs/architecture/c4-macro-topology-phase1.md +++ b/reference/specs/architecture/c4-macro-topology-phase1.md @@ -1,123 +1,123 @@ -# C4 Model: Evolith Tracker (Fase 1 - MVP Topology) +# C4 Model: Evolith Tracker (Phase 1 - MVP Topology) > **Bilingual Navigation:** English (this document) · [Versión en Español](./c4-macro-topology-phase1.es.md) -Este documento detalla la arquitectura a nivel de Contexto y Contenedores para la Fase 1 del producto, alineada con la **Visión de Governed Composition**. Evolith Tracker actúa como el **Governance Control Plane** que orquesta y audita, mientras delega la ejecución técnica a proveedores externos mediante puertos y adaptadores (ACLs). +This document details the Context- and Container-level architecture for Phase 1 of the product, aligned with the **Governed Composition vision**. Evolith Tracker acts as the **Governance Control Plane** that orchestrates and audits, while delegating technical execution to external providers through ports and adapters (ACLs). -**Condición crítica:** El Backend opera como un Monolito de Despliegue Único con separación por esquemas en Base de Datos (convención `tracker_`, ver [T-008](../../../DECISIONS.md)). El Frontend opera como una arquitectura de **Microfrontends** (Module Federation). +**Critical condition:** The backend runs as a Single-Deployment Monolith with schema-level separation in the database (convention `tracker_`, see [T-008](../../../DECISIONS.md)). The frontend runs as a **Microfrontend** architecture (Module Federation). > [!NOTE] -> **Superficie API (Fase 1):** REST + OpenAPI 3.0 es el único estándar de API expuesto. GraphQL queda fuera de alcance en Fase 1 (ver [T-009](../../../DECISIONS.md)). +> **API surface (Phase 1):** REST + OpenAPI 3.0 is the only API standard exposed. GraphQL is out of scope for Phase 1 (see [T-009](../../../DECISIONS.md)). -## Nivel 1: Diagrama de Contexto del Sistema +## Level 1: System Context Diagram -Muestra el panorama general del Evolith Tracker actuando como el plano de control que centraliza decisiones y delega la ejecución a herramientas de mercado. +Shows the overall landscape, with Evolith Tracker acting as the control plane that centralises decisions and delegates execution to market tooling. ```mermaid C4Context title System Context diagram for Evolith Tracker Suite - Person(human_user, "Human Actor", "Gobernanza humana, autorizaciones y excepciones.") - Person(ai_agent, "Autonomous Agents", "Agentes ejecutando tareas acotadas vía MCP/API.") + Person(human_user, "Human Actor", "Human governance, authorisations and exceptions.") + Person(ai_agent, "Autonomous Agents", "Agents executing scoped tasks via MCP/API.") - System(evolith_tracker, "Evolith Tracker", "Governance Control Plane. Centraliza el estado del SDLC, evalúa Gates y mantiene el Grafo de Evidencias.") + System(evolith_tracker, "Evolith Tracker", "Governance Control Plane. Centralises SDLC state, evaluates Gates and maintains the Evidence Graph.") - System_Ext(core, "Evolith Core", "La Constitución. Provee Reglas, Esquemas y ADRs inmutables.") - System_Ext(ums, "UMS (User Management System)", "Identidad corporativa (AuthN/AuthZ).") + System_Ext(core, "Evolith Core", "The Constitution. Provides immutable Rules, Schemas and ADRs.") + System_Ext(ums, "UMS (User Management System)", "Corporate identity (AuthN/AuthZ).") - System_Ext(work_providers, "Work Systems (Jira, etc.)", "Sistemas operativos de tickets y tareas.") - System_Ext(scm_providers, "SCM & CI/CD (GitHub, .harness)", "Repositorios, pipelines y despliegues.") - System_Ext(obs_providers, "Observability & Analytics", "Langfuse, Superset. Trazas, costos y visualización.") + System_Ext(work_providers, "Work Systems (Jira, etc.)", "Operational ticket and task systems.") + System_Ext(scm_providers, "SCM & CI/CD (GitHub, .harness)", "Repositories, pipelines and deployments.") + System_Ext(obs_providers, "Observability & Analytics", "Langfuse, Superset. Traces, cost and visualisation.") - Rel(human_user, evolith_tracker, "Gobierna, aprueba y audita") - Rel(ai_agent, evolith_tracker, "Consume contexto y provee evidencia") + Rel(human_user, evolith_tracker, "Governs, approves and audits") + Rel(ai_agent, evolith_tracker, "Consumes context and supplies evidence") - Rel(core, evolith_tracker, "Provee reglas de negocio") - Rel(evolith_tracker, ums, "Delega AuthN y Roles") + Rel(core, evolith_tracker, "Provides business rules") + Rel(evolith_tracker, ums, "Delegates AuthN and Roles") - Rel(evolith_tracker, work_providers, "Mapea tickets a evidencias (Port & ACL)") - Rel(evolith_tracker, scm_providers, "Recibe resultados de CI/CD (Port & ACL)") - Rel(evolith_tracker, obs_providers, "Consume telemetría (Port & ACL)") + Rel(evolith_tracker, work_providers, "Maps tickets to evidence (Port & ACL)") + Rel(evolith_tracker, scm_providers, "Receives CI/CD results (Port & ACL)") + Rel(evolith_tracker, obs_providers, "Consumes telemetry (Port & ACL)") ``` -## Nivel 2: Diagrama de Contenedores (Fase 1 Topology) +## Level 2: Container Diagram (Phase 1 Topology) -Muestra la vista interna de la arquitectura en su **Fase 1**. Aquí se refleja la separación entre la API, el motor de decisiones (Gate Decision Engine), y los puertos (Provider Registry). +Shows the internal view of the architecture in its **Phase 1** form. It reflects the separation between the API, the decision engine (Gate Decision Engine), and the ports (Provider Registry). ```mermaid C4Container - title Container diagram for Evolith Tracker (Fase 1) + title Container diagram for Evolith Tracker (Phase 1) System_Ext(ums, "UMS SaaS", "AuthN/AuthZ") - Person(user, "User/Agent", "Interactúa con la Suite") + Person(user, "User/Agent", "Interacts with the Suite") System_Boundary(c1, "Evolith Tracker - Frontend Tier (Microfrontends)") { - Container(shell_host, "Shell Host", "React/Vite", "Orquesta la carga de remotes y layout") + Container(shell_host, "Shell Host", "React/Vite", "Orchestrates remote loading and layout") Container(mfe_gates, "SDLC Gates MFEs", "React Remote", "Discovery, Design, Construction, QA, Release") - Container(mfe_governance, "Governance & Metrics MFE", "React Remote", "Dashboards y auditoría") + Container(mfe_governance, "Governance & Metrics MFE", "React Remote", "Dashboards and audit") } System_Boundary(c2, "Evolith Tracker - Edge Tier") { - Container(api_gateway, "Governance API / BFF", "REST/OpenAPI", "Enruta tráfico y actúa como límite de autorización") + Container(api_gateway, "Governance API / BFF", "REST/OpenAPI", "Routes traffic and acts as the authorisation boundary") } System_Boundary(c3, "Evolith Tracker - Backend Tier (Control Plane)") { - Container(process_orch, "Process & Phase Orchestrator", "Service", "Gestiona el ciclo de vida de los procesos SDLC") - Container(gate_engine, "Gate Decision Engine", "Service", "Toma la decisión canónica evaluando evidencias, reglas y aprobaciones") - Container(evidence_graph, "Evidence Graph Service", "Service", "Mantiene la trazabilidad inmutable y el linaje de datos") - Container(provider_acl, "Provider & Adapter ACL", "Service", "Puertos neutrales (Work, SCM, Observability, Analytics) protegiendo el dominio") + Container(process_orch, "Process & Phase Orchestrator", "Service", "Manages the lifecycle of SDLC processes") + Container(gate_engine, "Gate Decision Engine", "Service", "Makes the canonical decision by evaluating evidence, rules and approvals") + Container(evidence_graph, "Evidence Graph Service", "Service", "Maintains immutable traceability and data lineage") + Container(provider_acl, "Provider & Adapter ACL", "Service", "Neutral ports (Work, SCM, Observability, Analytics) shielding the domain") } System_Boundary(c4, "Evolith Tracker - Data Tier") { - ContainerDb(single_db, "Relational Database", "PostgreSQL", "Schemas segregados lógicamente (tracker_discovery, tracker_release, etc.)") + ContainerDb(single_db, "Relational Database", "PostgreSQL", "Logically segregated schemas (tracker_discovery, tracker_release, etc.)") } - Rel(user, shell_host, "Visita", "HTTPS") - Rel(shell_host, mfe_gates, "Carga", "Module Federation") - Rel(shell_host, mfe_governance, "Carga", "Module Federation") + Rel(user, shell_host, "Visits", "HTTPS") + Rel(shell_host, mfe_gates, "Loads", "Module Federation") + Rel(shell_host, mfe_governance, "Loads", "Module Federation") - Rel(shell_host, api_gateway, "Llamadas API", "REST") + Rel(shell_host, api_gateway, "API calls", "REST") - Rel(api_gateway, process_orch, "Solicita transición", "HTTPS/REST") - Rel(api_gateway, ums, "Valida JWT", "HTTPS") + Rel(api_gateway, process_orch, "Requests transition", "HTTPS/REST") + Rel(api_gateway, ums, "Validates JWT", "HTTPS") - Rel(process_orch, gate_engine, "Solicita evaluación de Gate") - Rel(gate_engine, evidence_graph, "Consulta/Guarda Evidencia") - Rel(evidence_graph, provider_acl, "Normaliza datos de proveedores") + Rel(process_orch, gate_engine, "Requests Gate evaluation") + Rel(gate_engine, evidence_graph, "Queries/Stores Evidence") + Rel(evidence_graph, provider_acl, "Normalises provider data") - Rel(process_orch, single_db, "Escribe (schema por contexto)") - Rel(evidence_graph, single_db, "Guarda Linaje") + Rel(process_orch, single_db, "Writes (schema per context)") + Rel(evidence_graph, single_db, "Stores Lineage") ``` -## Nivel 3: Diagrama de Componentes (Control Plane Backend) +## Level 3: Component Diagram (Control Plane Backend) -Muestra los 9 Bounded Contexts lógicos (5 Phase Gates + 4 de soporte) que conforman el Monolito de Fase 1. +Shows the 9 logical Bounded Contexts (5 Phase Gates + 4 supporting) that make up the Phase 1 monolith. ```mermaid C4Component title Component diagram for Tracker Monolith Service Container_Boundary(backend_monolith, "Tracker Control Plane") { - Component(discovery_module, "Discovery Module", "Phase Gate 1", "Ideación, Canvas, validación estratégica (Build vs Compose)") - Component(design_module, "Design Module", "Phase Gate 2", "Contratos, ADRs, blueprints") + Component(discovery_module, "Discovery Module", "Phase Gate 1", "Ideation, Canvas, strategic validation (Build vs Compose)") + Component(design_module, "Design Module", "Phase Gate 2", "Contracts, ADRs, blueprints") Component(construction_module, "Construction Module", "Phase Gate 3", "Tracking, commits, Architecture Drift") - Component(qa_module, "QA Module", "Phase Gate 4", "Pruebas, calidad, CFR") - Component(release_module, "Release Module", "Phase Gate 5", "Despliegues, autorizaciones de release") + Component(qa_module, "QA Module", "Phase Gate 4", "Testing, quality, CFR") + Component(release_module, "Release Module", "Phase Gate 5", "Deployments, release authorisations") - Component(governance_module, "Governance Module", "Soporte", "SDLC execution, orquestación de agentes") - Component(artifacts_module, "Artifacts Module", "Soporte", "Definiciones de artefactos, Evidence Graph") - Component(metrics_module, "Metrics Module", "Soporte", "Scorecards asíncronos (DORA/SPACE)") - Component(integration_module, "Integration Module", "Soporte", "Ports & ACLs (Jira, GitHub, Langfuse)") + Component(governance_module, "Governance Module", "Supporting", "SDLC execution, agent orchestration") + Component(artifacts_module, "Artifacts Module", "Supporting", "Artifact definitions, Evidence Graph") + Component(metrics_module, "Metrics Module", "Supporting", "Asynchronous scorecards (DORA/SPACE)") + Component(integration_module, "Integration Module", "Supporting", "Ports & ACLs (Jira, GitHub, Langfuse)") - Component(event_bus, "Internal Event Bus", "In-Memory", "Comunicación asíncrona entre módulos (CQRS/Eventos)") + Component(event_bus, "Internal Event Bus", "In-Memory", "Asynchronous inter-module communication (CQRS/Events)") } - Container(api_gateway, "Governance API", "Nginx", "Llamadas entrantes (REST)") + Container(api_gateway, "Governance API", "Nginx", "Inbound calls (REST)") 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") + Rel(api_gateway, discovery_module, "Routes traffic", "REST") + Rel(api_gateway, integration_module, "Routes traffic", "REST") - Rel(integration_module, event_bus, "Publica eventos (ej. PR Merged)") - Rel(construction_module, event_bus, "Escucha eventos") + Rel(integration_module, event_bus, "Publishes events (e.g. PR Merged)") + Rel(construction_module, event_bus, "Listens for events") ``` diff --git a/reference/specs/design/blueprint.es.md b/reference/specs/design/blueprint.es.md index 7c7e22e5..ef0dc08b 100644 --- a/reference/specs/design/blueprint.es.md +++ b/reference/specs/design/blueprint.es.md @@ -1,5 +1,14 @@ # Blueprint (inicial) — Evolith Tracker · Design +> [!NOTE] +> **Los nombres de tools de abajo son históricos (`T-052`, COH-006/COH-007).** Las seis tools MCP +> `tracker_*` se eliminaron cuando [`T-052`](../../../docs/adrs/T-052-gateway-consumes-core-single-mcp.es.md) +> convirtió el `evolith-mcp` del Core en la única superficie MCP de cara a agentes; el +> `tracker-gateway` la reexpone en `/api/core-mcp/*`. Las capacidades descritas son reales y las +> sirve el BFF `tracker-api`: lo que desapareció es el transporte. Se conserva el texto para que el +> requisito no se pierda. + + > Bilingüe: [English](./blueprint.md) Este contexto cumple un rol dual: es a la vez el **bounded context de Design** (dominio de la Compuerta 2) y el hogar de la **suite de diseño de la propia aplicación Tracker**. Este blueprint es un **índice y síntesis** conciso — la arquitectura técnica detallada ya vive en las specs `tracker-*.md` de este directorio y no se duplica aquí. diff --git a/reference/specs/design/blueprint.md b/reference/specs/design/blueprint.md index 3014b1bc..f04f6dd9 100644 --- a/reference/specs/design/blueprint.md +++ b/reference/specs/design/blueprint.md @@ -1,5 +1,13 @@ # Blueprint (initial) — Evolith Tracker · Design +> [!NOTE] +> **Tool names below are historical (`T-052`, COH-006/COH-007).** The six `tracker_*` MCP tools +> were deleted when [`T-052`](../../../docs/adrs/T-052-gateway-consumes-core-single-mcp.md) made the +> Core's `evolith-mcp` the single agent-facing MCP surface; the `tracker-gateway` re-exposes it at +> `/api/core-mcp/*`. The capabilities described here are real and served by the `tracker-api` BFF — +> only the transport named below is gone. Kept rather than deleted so the requirement survives. + + > Bilingual: [Español](./blueprint.es.md) This context carries a dual role: it is both the **Design bounded context** (Gate 2 domain) and the home of the **Tracker's own application design suite**. This blueprint is a concise **index and synthesis** — the detailed technical architecture already lives in the `tracker-*.md` specs in this directory and is not duplicated here. diff --git a/reference/specs/design/tracker-agent-assignment-api.es.md b/reference/specs/design/tracker-agent-assignment-api.es.md index 43716fcd..e1192529 100644 --- a/reference/specs/design/tracker-agent-assignment-api.es.md +++ b/reference/specs/design/tracker-agent-assignment-api.es.md @@ -2,6 +2,15 @@ > **Navegación bilingüe:** [English](./tracker-agent-assignment-api.md) · Español (este documento) +> [!NOTE] +> **Los nombres de tools de abajo son históricos (`T-052`, COH-006/COH-007).** Las seis tools MCP +> `tracker_*` se eliminaron cuando [`T-052`](../../../docs/adrs/T-052-gateway-consumes-core-single-mcp.es.md) +> convirtió el `evolith-mcp` del Core en la única superficie MCP de cara a agentes; el +> `tracker-gateway` la reexpone en `/api/core-mcp/*`. Las capacidades descritas son reales y las +> sirve el BFF `tracker-api`: lo que desapareció es el transporte. Se conserva el texto para que el +> requisito no se pierda. + + **Estado del Documento:** Borrador **Tipo:** Diseño de Contrato de API e Interacción (dentro del Bounded Context de Governance) **Satélite:** Evolith Tracker diff --git a/reference/specs/design/tracker-agent-assignment-api.md b/reference/specs/design/tracker-agent-assignment-api.md index f76566b0..ccff2ede 100644 --- a/reference/specs/design/tracker-agent-assignment-api.md +++ b/reference/specs/design/tracker-agent-assignment-api.md @@ -2,6 +2,14 @@ > **Bilingual Navigation:** English (this document) · [Versión en Español](./tracker-agent-assignment-api.es.md) +> [!NOTE] +> **Tool names below are historical (`T-052`, COH-006/COH-007).** The six `tracker_*` MCP tools +> were deleted when [`T-052`](../../../docs/adrs/T-052-gateway-consumes-core-single-mcp.md) made the +> Core's `evolith-mcp` the single agent-facing MCP surface; the `tracker-gateway` re-exposes it at +> `/api/core-mcp/*`. The capabilities described here are real and served by the `tracker-api` BFF — +> only the transport named below is gone. Kept rather than deleted so the requirement survives. + + **Document Status:** Draft **Type:** API & Interaction Contract Design (within the Governance Bounded Context) **Satellite:** Evolith Tracker diff --git a/reference/specs/discovery/DISCOVERY_CANVAS.md b/reference/specs/discovery/DISCOVERY_CANVAS.md index 588f8942..9b2d1ae3 100644 --- a/reference/specs/discovery/DISCOVERY_CANVAS.md +++ b/reference/specs/discovery/DISCOVERY_CANVAS.md @@ -2,31 +2,31 @@ > **Bilingual Navigation:** English (this document) · [Versión en Español](./DISCOVERY_CANVAS.es.md) -*Este documento es el artefacto oficial de entrada al flujo Spec-Driven. Toda iniciativa debe superar esta compuerta de viabilidad (ROI/KPIs) antes de autorizar el diseño arquitectónico.* - -## 1. Definición del Problema / Oportunidad -- **Problema:** Los ciclos de desarrollo de software tradicionales sufren de "Deriva Arquitectónica", falta de gobernanza en la codificación, y desalineación entre las fechas de negocio (Release Planner) y la calidad real del código (QA/Regresión). -- **Oportunidad:** Construir un SDLC AI-Native (Evolith Tracker) donde la IA genere y verifique código basado en contratos (Spec-as-Source), asegurando que el esfuerzo humano se concentre exclusivamente en el gobierno y orquestación. - -## 2. Propuesta de Valor -Una Suite de Ingeniería E2E que integra Discovery, Arquitectura, Tracking, QA y Release Management en un Monolito Progresivo Multi-Tenant, regido estrictamente por los estándares inmutables de `Evolith Core`. - -## 3. Retorno de Inversión (ROI) Justificado -- **Reducción de Costos:** Disminución del 40% en horas de refactorización por deuda técnica y deriva arquitectónica. -- **Eficiencia Operativa:** Eliminación del trabajo manual en la actualización de cronogramas de despliegue mediante el Motor de Contingencias (Re-Do Flow). -- **Time-to-Market:** Reducción del ciclo de entrega mediante la automatización del diseño técnico y QA por contratos. - -## 4. KPIs y Métricas de Éxito (DORA & SPACE) -- **Deployment Frequency:** Aumentar de despliegues semanales a despliegues diarios (On-Demand) gracias a los Quality Gates automatizados. -- **Lead Time for Changes:** Reducir el tiempo desde la ideación (Discovery) hasta producción en un 50%. -- **Architecture Adherence Index (Nuevo KPI):** Mantener un 100% de correlación entre especificaciones funcionales (Markdown), contratos técnicos (OpenAPI) y código físico. Cero deriva permitida. -- **Change Failure Rate:** Menor al 2% gracias a la integración profunda con `.harness` (Contract Testing). - -## 5. Riesgos y Supuestos (Gate Viability) -- **Riesgo:** Resistencia al cambio por parte de equipos acostumbrados a herramientas tradicionales (Jira, Trello) y desarrollo empírico sin contratos previos. -- **Mitigación:** Gobernanza forzada (System-level lock). Ningún código se despliega si no nace de una Spec y pasa el pipeline de `.harness`. -- **Supuesto:** Disponibilidad y estabilidad del servicio UMS para AuthN/AuthZ. - -## 6. Resolución del Agente PO (Compuerta) -[APROBADO] **ESTADO: APROBADO.** -El retorno de inversión justifica el costo. Los KPIs son medibles asíncronamente mediante CQRS. Se autoriza el avance a la fase de **Architecture Spec-Driven** para la definición de Contratos. +*This document is the official entry artifact into the Spec-Driven flow. Every initiative must clear this viability gate (ROI/KPIs) before architectural design is authorised.* + +## 1. Problem / Opportunity Definition +- **Problem:** Traditional software development cycles suffer from "Architecture Drift", a lack of governance over coding, and misalignment between business dates (Release Planner) and the real quality of the code (QA/Regression). +- **Opportunity:** Build an AI-Native SDLC (Evolith Tracker) where AI generates and verifies code from contracts (Spec-as-Source), ensuring human effort concentrates exclusively on governance and orchestration. + +## 2. Value Proposition +An end-to-end engineering suite integrating Discovery, Architecture, Tracking, QA and Release Management into a Progressive Multi-Tenant Monolith, governed strictly by the immutable standards of `Evolith Core`. + +## 3. Justified Return on Investment (ROI) +- **Cost reduction:** 40% fewer hours spent refactoring technical debt and architecture drift. +- **Operational efficiency:** Elimination of manual work updating deployment schedules, via the Contingency Engine (Re-Do Flow). +- **Time-to-Market:** Shorter delivery cycle through automation of technical design and contract-based QA. + +## 4. KPIs and Success Metrics (DORA & SPACE) +- **Deployment Frequency:** Move from weekly to daily (on-demand) deployments thanks to automated Quality Gates. +- **Lead Time for Changes:** Cut the time from ideation (Discovery) to production by 50%. +- **Architecture Adherence Index (new KPI):** Maintain 100% correlation between functional specifications (Markdown), technical contracts (OpenAPI) and physical code. Zero drift allowed. +- **Change Failure Rate:** Below 2%, thanks to deep integration with `.harness` (Contract Testing). + +## 5. Risks and Assumptions (Gate Viability) +- **Risk:** Resistance to change from teams used to traditional tooling (Jira, Trello) and to empirical development without prior contracts. +- **Mitigation:** Enforced governance (system-level lock). No code deploys unless it originates from a Spec and passes the `.harness` pipeline. +- **Assumption:** Availability and stability of the UMS service for AuthN/AuthZ. + +## 6. PO Agent Resolution (Gate) +[APPROVED] **STATUS: APPROVED.** +The return on investment justifies the cost. The KPIs are measurable asynchronously via CQRS. Advancement to the **Architecture Spec-Driven** phase is authorised for contract definition. diff --git a/robosoft/README.md b/robosoft/README.md index a28c7236..ccf56cc9 100644 --- a/robosoft/README.md +++ b/robosoft/README.md @@ -60,7 +60,7 @@ fails the run; a failed **soft** check is a warning only. ## Robots -**13 robots.** Derived from `robosoft/robots/*.robot.mjs` — the same discovery `robosoft/run.mjs` performs, so this table cannot disagree with what a run executes. +**14 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 | |-------|--------| @@ -68,6 +68,7 @@ fails the run; a failed **soft** check is a warning only. | `auth-perimeter` | Prove the CLOSED (ums) perimeter enforces: 401 anonymous, 2xx valid, 403 under-privileged, 401 branch/forged. | | `core-evidence-ingest` | Prove a real CLI evaluation lands as a persisted, non-empty, owner-attributed row in the Tracker (GT-604). | | `core-integration` | Assert every Tracker↔Core interface against the live stack (REST + MCP + /converse). | +| `core-sdlc-parity` | Assert the SAME Core signal blocks or not according to the tenant matrix (CP-06), fed by an attached deposit (CP-07). | | `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. | diff --git a/robosoft/robots/core-sdlc-parity.robot.mjs b/robosoft/robots/core-sdlc-parity.robot.mjs new file mode 100644 index 00000000..0210d2ed --- /dev/null +++ b/robosoft/robots/core-sdlc-parity.robot.mjs @@ -0,0 +1,254 @@ +// ----------------------------------------------------------------------------- +// RoboSoft agent · core-sdlc-parity (CP-10) +// +// The property no other robot covers: **the SAME Core signal blocks or does not +// block depending on the tenant's configuration** (CP-06's matrix), fed by a Core +// deposit attached to a phase (CP-07), decided inside the real gate. +// +// It proves end to end, against a live tracker-api, what four cards built: +// CP-05 the canonical result is preserved instead of dying at the ACL +// CP-06 the tenant decides which signals block +// CP-07 a deposit hangs off the SDLC record and runs through that matrix +// CP-02 the phase catalogue the record is contrasted against +// +// WHAT IT NEEDS, AND WHY IT IS NOT IN THE CI LIST +// ---------------------------------------------- +// A CoreMachine key on the deployed tracker-api bound to the SAME tenant this +// robot reads as. `deploy-check.yml` does not configure one — the same reason +// `core-evidence-ingest`, `core-integration` and `runtime-approvals` are kept out +// of `ROBOSOFT_ONLY`. Run it locally with: +// +// kubectl set env deploy/tracker-api-evolith-tracker-api \ +// CoreMachine__Keys__0__TenantId= \ +// CoreMachine__Keys__0__Key= +// ROBOSOFT_CORE_MACHINE_KEY= node robosoft/run.mjs +// +// Without the key it SOFT-SKIPS with the reason. It does not pass quietly: a +// robot that reports PASS when it never exercised anything is the vacuous green +// this repository keeps finding. +// +// ONE TENANT, TWO CONFIGURATIONS — stated rather than implied +// ---------------------------------------------------------- +// CP-10's criterion says "a tenant where a signal is advisory and another where +// the same signal blocks". This runs it as ONE tenant reconfigured between the +// two evaluations, because a deposit is tenant-scoped by the machine key and a +// second tenant would need a second key plus its own seeded initiative. The code +// path is identical — `CoreSignalRule` is stored per tenant AND phase, so "another +// tenant's matrix" is the same lookup against a different row. What this does NOT +// prove is the isolation between two tenants' matrices; `tenant-isolation` covers +// that class separately. +// ----------------------------------------------------------------------------- + +import { config } from '../lib/config.mjs'; + +const RUN = Date.now().toString(36); + +export default { + name: 'core-sdlc-parity', + description: + 'Assert the SAME Core signal blocks or not according to the tenant matrix (CP-06), fed by an attached deposit (CP-07).', + + async run(ctx) { + const { api, step, check, info } = ctx; + + const machineKey = process.env.ROBOSOFT_CORE_MACHINE_KEY; + if (!machineKey) { + check( + 'ROBOSOFT_CORE_MACHINE_KEY is set (the deposit path needs a CoreMachine key)', + false, + { + soft: true, + detail: `not set — bind a key to tenant ${config.tenantId} and re-run. See this file's header.`, + }, + ); + return; + } + + const code = (p) => `${p}-${RUN}`.toUpperCase().slice(0, 20); + const idOf = (r) => r?.body?.id || r?.body?.initiativeId || r?.body; + const need = async (label, p) => { + const r = await p; + if (!check(label, r.ok, { detail: r.error ? r.error : `status=${r.status}` })) { + throw new Error(`precondition failed: ${label} (status=${r.status})`); + } + return r; + }; + + // The deposit route is machine-authenticated, and the shared client cannot send + // an arbitrary header — so this one call goes out raw, with its own key. + const deposit = async (payload) => { + const res = await fetch(`${config.apiBase}${config.apiPrefix}/core-evaluation-transactions`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': machineKey }, + body: JSON.stringify(payload), + }); + const raw = await res.text(); + let body = raw; + try { body = raw ? JSON.parse(raw) : null; } catch { /* keep raw */ } + return { ok: res.ok, status: res.status, body }; + }; + + const OWNER = 'po-parity'; + const productCode = code('PAR'); + const correlationId = `parity-${RUN}`; + + // ── 1. An initiative with an OPEN construction gate ────────────────────── + step('Set up an initiative with an open construction gate'); + + await need('PUT /gate-policies/discovery', api.put('/gate-policies/discovery', { + mode: 'SIMPLE', requiresCoreVerdict: false, strategy: 'SINGLE', stages: 'parallel', + requiredEvidence: [], approvers: [], + })); + await need('PUT /gate-policies/construction (no matrix yet)', api.put('/gate-policies/construction', { + mode: 'SIMPLE', requiresCoreVerdict: false, strategy: 'SINGLE', stages: 'parallel', + requiredEvidence: [], approvers: [], coreSignalRules: [], + })); + + const prod = await need('POST /products', api.post('/products', { + code: productCode, name: 'Parity Probe', description: 'CP-10 parity probe.', + })); + const productId = idOf(prod); + await need('POST /products/{id}/owner', api.post(`/products/${productId}/owner`, { ownerIdentity: OWNER })); + + const opp = await need('POST /opportunities', api.post('/opportunities', { + code: code('OPP'), title: 'Parity probe', description: 'CP-10', source: 'stakeholder-request', + problem: 'n/a', expectedValue: 'n/a', sponsor: `sponsor-${productCode}`, + targetProductCode: productCode, priority: 'medium', + })); + const oppId = idOf(opp); + await need('POST /opportunities/{id}/triage', api.post(`/opportunities/${oppId}/triage`, { priority: 'high' })); + const initiativeId = idOf(await need('promote', api.post(`/opportunities/${oppId}/promote`))); + if (!initiativeId) throw new Error('no initiative id'); + + const sub = await need('POST /gate-submissions (construction)', api.post('/gate-submissions', { + initiativeId, phase: 'construction', requestedBy: `construction@${productCode}`, + })); + const subId = idOf(sub); + info(`construction gate ${subId} open on initiative ${initiativeId}`); + + // ── 2. A Core deposit, ATTACHED to that phase ──────────────────────────── + step('Deposit a Core verdict and attach it to the phase'); + + const dep = await deposit({ + schemaVersion: '1.0.0', + correlationId, + producer: { surface: 'cli', version: 'robosoft' }, + evaluatedAt: new Date(0).toISOString(), + overallVerdict: 'FAIL', + outcome: 'rejected', + rulesExecuted: [{ ruleId: 'ARCH-1', engine: 'opa', verdict: 'FAIL' }], + violations: [{ + ruleId: 'ARCH-1', tool: 'evolith', file: 'src/x.cs', severity: 'error', + message: 'capa cruzada', category: 'architecture', fingerprint: `fp-${RUN}`, + }], + accountableOwners: [], + blockingViolationCount: 1, + versions: { core: '1.0.0' }, + }); + + if (!check('POST /core-evaluation-transactions (machine key)', dep.ok, { + detail: `status=${dep.status}. A 401 means the key is not bound to tenant ${config.tenantId}.`, + })) { + throw new Error('the deposit was refused — cannot exercise the matrix'); + } + + const rows = (await need('GET /core-evaluation-transactions', api.get('/core-evaluation-transactions'))).body; + const row = (Array.isArray(rows) ? rows : []).find((r) => r?.correlationId === correlationId); + if (!check('the deposit is readable in the ledger', Boolean(row), { detail: `rows=${rows?.length}` })) { + throw new Error('no ledger row for this run'); + } + + const attached = await need('POST /core-evaluation-transactions/{id}/attachment', + api.post(`/core-evaluation-transactions/${row.id}/attachment`, { + initiativeId, phase: 'construction', artifactKind: 'architecture-review', artifactVersion: 1, + })); + check('the attachment names the phase and the version', + attached.body?.attachment?.phase === 'construction' && attached.body?.attachment?.artifactVersion === 1, { + detail: JSON.stringify(attached.body?.attachment ?? null), + }); + check('attaching did NOT rewrite the verdict', attached.body?.overallVerdict === 'FAIL', { + detail: `overallVerdict=${attached.body?.overallVerdict}`, + }); + + // ── 3. With NO matrix, the signal does not block ───────────────────────── + let sinMatriz = []; + step('With no tenant matrix, the same signal is advisory'); + { + const ev = await need('POST /gate-submissions/{id}/evaluate', api.post(`/gate-submissions/${subId}/evaluate`)); + const unmet = ev.body?.unmet ?? []; + check('no core-signal reason among the unmet', !unmet.some((u) => String(u).startsWith('core-signal:')), { + detail: `unmet=${JSON.stringify(unmet)}`, + }); + // NOT "the gate is approved": this gate has its own unrelated unmet reasons + // (an unresolved approval). What is asserted is that the DEPOSIT contributes + // none of them — conflating the two would make this check pass or fail for + // reasons that have nothing to do with the matrix. + sinMatriz = unmet; + info(`unmet without a matrix: ${JSON.stringify(unmet)}`); + } + + // ── 4. The tenant marks it blocking — the SAME deposit now blocks ──────── + step('The tenant marks `architecture` blocking — the same deposit now blocks'); + { + await need('PUT /gate-policies/construction (blocking matrix)', api.put('/gate-policies/construction', { + mode: 'SIMPLE', requiresCoreVerdict: false, strategy: 'SINGLE', stages: 'parallel', + requiredEvidence: [], approvers: [], + coreSignalRules: [{ signal: 'architecture', disposition: 'blocking' }], + })); + + const ev = await need('POST /gate-submissions/{id}/evaluate', api.post(`/gate-submissions/${subId}/evaluate`)); + const unmet = ev.body?.unmet ?? []; + + // THE load-bearing assertion of this robot. Nothing about the deposit changed + // between step 3 and here — only the tenant's configuration did. + check('the SAME signal now blocks the gate', unmet.includes('core-signal:architecture'), { + detail: `unmet=${JSON.stringify(unmet)}`, + }); + check('the gate is not APPROVED while a blocking signal stands', ev.body?.state !== 'APPROVED', { + detail: `state=${ev.body?.state}`, + }); + } + + // ── 5. Back to advisory — and it stops blocking again ──────────────────── + step('Set the same signal to advisory — it stops blocking'); + { + await need('PUT /gate-policies/construction (advisory matrix)', api.put('/gate-policies/construction', { + mode: 'SIMPLE', requiresCoreVerdict: false, strategy: 'SINGLE', stages: 'parallel', + requiredEvidence: [], approvers: [], + coreSignalRules: [{ signal: 'architecture', disposition: 'advisory' }], + })); + + const ev = await need('POST /gate-submissions/{id}/evaluate', api.post(`/gate-submissions/${subId}/evaluate`)); + const unmet = ev.body?.unmet ?? []; + check('advisory does NOT block', !unmet.includes('core-signal:architecture'), { + detail: `unmet=${JSON.stringify(unmet)}`, + }); + // The sharpest form of the property: with the signal advisory the gate is in + // EXACTLY the state it was in with no matrix at all. The deposit added + // nothing and removed nothing — only the configuration ever changed. + check('advisory leaves the gate exactly as it was with no matrix', + JSON.stringify([...unmet].sort()) === JSON.stringify([...sinMatriz].sort()), { + detail: `sin matriz=${JSON.stringify(sinMatriz)} advisory=${JSON.stringify(unmet)}`, + }); + } + + // ── 6. The journey is readable in the ledger (CP-16) ───────────────────── + step('The initiative ledger shows the deposit and its phase'); + { + const led = await need('GET /initiatives/{id}/ledger', + api.get(`/initiatives/${initiativeId}/ledger?phase=construction`)); + const entries = led.body?.entries ?? []; + check('the attached deposit appears in the ledger', + entries.some((e) => e.source === 'core-deposit' && e.phase === 'construction'), { + detail: `entries=${entries.length}`, + }); + check('the ledger classifies what blocked and what only advised', + entries.every((e) => typeof e.kind === 'string' && e.kind.length > 0), { + detail: `kinds=${JSON.stringify([...new Set(entries.map((e) => e.kind))])}`, + }); + } + + step('parity verified'); + info(`the same deposit blocked and did not block on tenant ${config.tenantId}, by configuration alone`); + }, +}; diff --git a/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs b/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs index b56c05a7..2d9a3fd7 100644 --- a/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs +++ b/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs @@ -43,6 +43,10 @@ public static IServiceCollection AddApplication(this IServiceCollection services // and the phase gates (discovery/design/construction/qa/release). services.AddScoped(); services.AddScoped(); + // CP-07 criterio 3 — los depositos del Core adjuntos, por la matriz de senales del tenant. + services.AddScoped(); + // CP-02 criterio 2 — el catalogo del Core con el overlay del tenant encima. + services.AddScoped(); // AR-05 / QA — la compuerta de calidad (`GT-471`). Se registra AQUI y no en // Infrastructure: la composicion suelo-de-dominio + criterios-del-tenant es una regla de // aplicacion, no un detalle de persistencia. diff --git a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/CoreArtifactSchemaCatalog.cs b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/CoreArtifactSchemaCatalog.cs new file mode 100644 index 00000000..426a4616 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/CoreArtifactSchemaCatalog.cs @@ -0,0 +1,128 @@ +namespace Tracker.Application.Governance.ArtifactFieldSchema; + +/// +/// Where an artifact's canonical shape comes from — or why it has none. Three states, not a +/// nullable string: «no schema because the Core publishes none» and «no schema because it is a +/// tool's own output» call for opposite answers from a consumer, and collapsing them into `null` +/// makes the difference unrecoverable at the point where it matters. +/// +public static class ArtifactSchemaProvenance +{ + /// Evolith Core publishes a canonical JSON Schema for this artifact. + public const string CoreSchema = "core-schema"; + + /// + /// The artifact IS a tool's native output (CI run record, coverage report). The Core declares + /// producedBy rather than a schema on purpose: a bespoke shape would restate what the + /// tool already publishes and rot the day the tool changes. + /// + public const string ToolOutput = "tool-output"; + + /// + /// Nothing upstream. The shape is whatever the tenant's ArtifactFieldSchema says, and + /// per T-056 nothing here contradicts it. + /// + public const string None = "none"; +} + +/// The canonical shape of an artifact, as far as the Core publishes one. +public sealed record ArtifactCanonicalSchemaDto +{ + /// + /// The schema's published $id, or null when there is none. It is an `$id` and not + /// a repository path deliberately: a path is a fact about where a file sits in one repository + /// at one moment, while the `$id` is the schema's own identity and survives the Core + /// reorganising its tree. Those paths were in fact broken until `evolith_arch32#378`. + /// + public string? SchemaId { get; init; } + + /// See . + public string Provenance { get; init; } = ArtifactSchemaProvenance.None; +} + +/// +/// The correspondence between a Tracker artifact kind and the canonical schema Evolith Core +/// publishes for it (GAP-020). +/// +/// THIS IS A WRITTEN DECISION, NOT A TRANSFORMATION. The two vocabularies agree for some +/// artifacts (prd, release-notes, test-summary-report) and diverge for others: +/// security-scan-result is the Core's *Security Scan Report*, rollback-plan is its +/// *Rollback Procedure*, observability-readiness is its *Observability Validation*. A +/// mapper that matched by slug would silently miss all three. Each entry below is a judgement +/// someone made and can be argued with; none of it is derived. +/// +/// The prose version, with the reasoning for every row, is +/// docs/artifacts/CORE_ARTIFACT_SCHEMAS.md. That document and this table are kept identical +/// by CoreArtifactSchemaCatalogTests, which parses the markdown and compares — because a +/// decision recorded in two places drifts, and this repository has spent the week finding out how +/// quietly. +/// +/// WHAT THIS DOES NOT DO. It does not make the Core the authority over what a tenant may +/// record. Per T-056 content validation is the tenant's configuration and not engine code: +/// the custom fields a tenant adds through ArtifactFieldSchema stay theirs, and this +/// reference is offered to whoever fills an artifact in — never enforced against them here. +/// +public static class CoreArtifactSchemaCatalog +{ + private const string Base = "https://evolith.dev/schema/"; + + /// + /// Tracker kind → published $id. Ten entries: the Core publishes fourteen schemas, but + /// four of them — Discovery Canvas, Ballpark Estimation, Integration Evidence and On-Call + /// Handoff — have no kind in and therefore nothing here to + /// hang off. They are listed in the markdown so a reader looking for them finds out they exist. + /// + private static readonly IReadOnlyDictionary SchemaIdByKind = + new Dictionary + { + ["prd"] = Base + "prd.schema.json", + ["technical-feasibility-canvas"] = Base + "technical-feasibility.schema.json", + ["build-versus-compose-analysis"] = Base + "build-vs-compose.schema.json", + ["bounded-context-map"] = Base + "bounded-context-map.schema.json", + ["definition-of-done-checklist"] = Base + "definition-of-done-checklist.schema.json", + ["test-summary-report"] = Base + "test-summary-report.schema.json", + ["security-scan-result"] = Base + "security-scan-report.schema.json", + ["release-notes"] = Base + "release-notes.schema.json", + ["observability-readiness"] = Base + "observability-validation.schema.json", + ["rollback-plan"] = Base + "rollback-rehearsal.schema.json", + }; + + /// + /// Kinds whose content IS a tool's own output. The Core's third such artifact — Pyramid + /// Distribution — has no Tracker kind. + /// + private static readonly IReadOnlySet ToolOutputKinds = + new HashSet { "ci-pipeline-result", "coverage-report" }; + + /// + /// Note on adr-registry: it is NOT wired to adr.schema.json even though the names + /// invite it. A registry is a *list* of ADRs and not an ADR, so the mapping would produce a + /// validation that fails against the real artifact — coverage on paper, a false negative in + /// practice. + /// + public static ArtifactCanonicalSchemaDto For(string artifactKind) + { + if (SchemaIdByKind.TryGetValue(artifactKind, out var id)) + { + return new ArtifactCanonicalSchemaDto + { + SchemaId = id, + Provenance = ArtifactSchemaProvenance.CoreSchema, + }; + } + + return new ArtifactCanonicalSchemaDto + { + SchemaId = null, + Provenance = ToolOutputKinds.Contains(artifactKind) + ? ArtifactSchemaProvenance.ToolOutput + : ArtifactSchemaProvenance.None, + }; + } + + /// The mapped kinds, for guards that need to compare the table against its source. + public static IReadOnlyDictionary Mappings => SchemaIdByKind; + + /// The tool-output kinds, same purpose. + public static IReadOnlySet ToolOutputs => ToolOutputKinds; +} diff --git a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/IPhaseArtifactProfileSource.cs b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/IPhaseArtifactProfileSource.cs index 878c3830..cbd72f85 100644 --- a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/IPhaseArtifactProfileSource.cs +++ b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/IPhaseArtifactProfileSource.cs @@ -51,6 +51,9 @@ public IReadOnlyList GetProfiles() => ArtifactKind = a.ArtifactKind, Label = a.Label, Required = a.Required, + // GAP-020: el perfil ya no dice sólo QUE hace falta un artefacto, sino + // dónde está su forma canónica — o por qué no la tiene. + CanonicalSchema = CoreArtifactSchemaCatalog.For(a.ArtifactKind), }) .ToList(), }) diff --git a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs index 88d3fd43..65fca03a 100644 --- a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs +++ b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs @@ -7,13 +7,14 @@ namespace Tracker.Application.Governance.ArtifactFieldSchema; /// the tenant configures custom fields per artifact (via ArtifactFieldSchema keyed by the /// artifact kind) and — for optional artifacts — their applicability. /// -/// GAP-020 — este catálogo dice que un `prd` es obligatorio y NO dice qué debe contener. La -/// forma canónica, para los 10 de 24 artefactos que la tienen aguas arriba, está enlazada en -/// docs/artifacts/CORE_ARTIFACT_SCHEMAS.md. No se enlaza desde aquí como dato porque los -/// nombres no coinciden mecánicamente (`security-scan-result` ⇄ Security Scan Report, -/// `rollback-plan` ⇄ Rollback Procedure) y la correspondencia es una decisión escrita, no una -/// transformación de cadena — y porque el `schemaRef` que publican los gates del Core es una ruta -/// relativa ROTA: resuelve a un directorio que no existe. +/// GAP-020 — este catálogo dice que un `prd` es obligatorio; QUÉ debe contener lo dice +/// ahora , que cuelga de cada artefacto el `$id` del esquema +/// canónico publicado por el Core, o la razón de que no lo tenga. La correspondencia sigue siendo una +/// DECISIÓN ESCRITA y no una transformación de cadena — `security-scan-result` ⇄ Security Scan Report, +/// `rollback-plan` ⇄ Rollback Procedure — y por eso vive en una tabla explícita contrastada contra +/// `docs/artifacts/CORE_ARTIFACT_SCHEMAS.md`. Se enlaza por `$id` y no por ruta: las rutas relativas +/// que publicaban las compuertas del Core estaban rotas hasta `evolith_arch32#378`, y una ruta es un +/// hecho sobre dónde está un fichero hoy, mientras que el `$id` es la identidad del esquema. /// public static class PhaseArtifactCatalog { @@ -78,6 +79,27 @@ public sealed class PhaseArtifactDto public string ArtifactKind { get; init; } = string.Empty; public string Label { get; init; } = string.Empty; public bool Required { get; init; } + + /// + /// The canonical shape the Core publishes for this artifact, or the reason there is none + /// (GAP-020). Until this field existed a consumer learned that a `prd` is required in discovery + /// and had no way to find out what a PRD is supposed to contain. See + /// — and note that it is a POINTER, not a constraint: + /// per T-056 the tenant's ArtifactFieldSchema stays the authority over content. + /// + public ArtifactCanonicalSchemaDto CanonicalSchema { get; init; } = new(); + /// + /// CP-02 — lo que ESTE tenant dijo de este artefacto (`required` / `optional` / + /// `not-applicable`), o `null` si no dijo nada y se sirve el estándar tal cual. + /// + public string? TenantApplicability { get; init; } + + /// + /// CP-02 — lo que decía el estándar del Core ANTES del overlay. Viaja aparte para que un + /// auditor pueda distinguir «el Core lo exige» de «este tenant lo exige». + /// + public bool? StandardRequired { get; init; } + } public sealed class PhaseArtifactProfileDto @@ -95,18 +117,21 @@ public sealed class PhaseArtifactProfileDto } /// Returns the Core-authoritative base artifacts per phase (read-only). -public sealed record GetPhaseArtifactProfilesQuery() : IQuery>; +public sealed record GetPhaseArtifactProfilesQuery(Guid TenantId) : IQuery>; internal sealed class GetPhaseArtifactProfilesQueryHandler : IQueryHandler> { // T-036 §5 / plan item 6: read through the Core-source seam, not the static catalog directly, // so the provisional stand-in can be swapped for a Core-sync source with no handler change. - private readonly IPhaseArtifactProfileSource _source; + // + // CP-02 — y ahora a través del catálogo TENANT-AWARE, que es la misma costura con el overlay + // del tenant encima. La fuente sigue sin recibir tenant: el estándar del Core es de todos. + private readonly ITenantPhaseArtifactCatalog _catalog; - public GetPhaseArtifactProfilesQueryHandler(IPhaseArtifactProfileSource source) => _source = source; + public GetPhaseArtifactProfilesQueryHandler(ITenantPhaseArtifactCatalog catalog) => _catalog = catalog; public Task> Handle( GetPhaseArtifactProfilesQuery request, CancellationToken cancellationToken) - => Task.FromResult(_source.GetProfiles()); + => _catalog.GetProfilesAsync(request.TenantId, cancellationToken); } diff --git a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/TenantPhaseArtifactCatalog.cs b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/TenantPhaseArtifactCatalog.cs new file mode 100644 index 00000000..aaeb4bb9 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/TenantPhaseArtifactCatalog.cs @@ -0,0 +1,110 @@ +using Tracker.Domain.Governance.GatePolicy; + +namespace Tracker.Application.Governance.ArtifactFieldSchema; + +/// +/// CP-02 criterio 2 — el catálogo de artefactos de una fase VISTO POR UN TENANT. +/// +/// Base + overlay, y la base no se toca. +/// sigue siendo tenant-agnóstico: sirve lo que el Core declara. Esta capa aplica encima lo que el +/// tenant dijo en su `GatePolicy`. Así «sin modificar el estándar base» es cierto en el código y +/// no sólo en la ficha — no hay ningún camino por el que la configuración de un tenant edite el +/// catálogo del Core. +/// +/// Marcar no es borrar. Un artefacto marcado `not-applicable` se sigue sirviendo, con +/// la marca puesta. Quitarlo de la lista dejaría a quien lea el catálogo sin saber que el estándar +/// lo pide y que este tenant lo descartó, que son dos hechos distintos y ambos auditables. +/// +public interface ITenantPhaseArtifactCatalog +{ + Task> GetProfilesAsync( + Guid tenantId, CancellationToken cancellationToken = default); + + Task GetPhaseAsync( + Guid tenantId, string phase, CancellationToken cancellationToken = default); +} + +internal sealed class TenantPhaseArtifactCatalog : ITenantPhaseArtifactCatalog +{ + private readonly IPhaseArtifactProfileSource _source; + private readonly IGatePolicyRepository _policies; + + public TenantPhaseArtifactCatalog(IPhaseArtifactProfileSource source, IGatePolicyRepository policies) + { + _source = source; + _policies = policies; + } + + public async Task> GetProfilesAsync( + Guid tenantId, CancellationToken cancellationToken = default) + { + var baseProfiles = _source.GetProfiles(); + var result = new List(baseProfiles.Count); + + foreach (var profile in baseProfiles) + { + var policy = await _policies.GetByTenantAndPhaseAsync(tenantId, profile.Phase, cancellationToken); + result.Add(Apply(profile, policy?.ArtifactApplicability)); + } + + return result; + } + + public async Task GetPhaseAsync( + Guid tenantId, string phase, CancellationToken cancellationToken = default) + { + var profile = _source.GetProfiles() + .FirstOrDefault(p => string.Equals(p.Phase, phase, StringComparison.OrdinalIgnoreCase)); + if (profile is null) return null; + + var policy = await _policies.GetByTenantAndPhaseAsync(tenantId, profile.Phase, cancellationToken); + return Apply(profile, policy?.ArtifactApplicability); + } + + /// + /// Aplica el overlay a UN perfil. + /// + /// Una regla que nombra un artefacto que el Core no declara se IGNORA. Añadir artefactos + /// propios del tenant es otra capacidad —y otra ficha—; aceptarla aquí por la puerta de atrás + /// haría que el catálogo dejara de ser el estándar del Core con una opinión encima y pasara a + /// ser dos catálogos mezclados sin poder distinguirlos. + /// + internal static PhaseArtifactProfileDto Apply( + PhaseArtifactProfileDto profile, + IReadOnlyList? overlay) + { + if (overlay is null || overlay.Count == 0) return profile; + + var byKind = overlay + .Where(r => !string.IsNullOrWhiteSpace(r.ArtifactKind)) + .GroupBy(r => r.ArtifactKind, StringComparer.OrdinalIgnoreCase) + // Dos reglas para el mismo artefacto: gana la ÚLTIMA escrita, que es la que un editor + // acaba de guardar. Ninguna de las dos es más específica que la otra, así que un + // criterio distinto sería arbitrario y sorprendería a quien acaba de editar. + .ToDictionary(g => g.Key, g => g.Last().Applicability, StringComparer.OrdinalIgnoreCase); + + return new PhaseArtifactProfileDto + { + Phase = profile.Phase, + Source = profile.Source, + Artifacts = profile.Artifacts.Select(a => + { + if (!byKind.TryGetValue(a.ArtifactKind, out var applicability)) return a; + + return new PhaseArtifactDto + { + ArtifactKind = a.ArtifactKind, + Label = a.Label, + Required = applicability == ArtifactApplicability.Required, + CanonicalSchema = a.CanonicalSchema, + // Se declara QUÉ dijo el tenant y qué decía el estándar, por separado. Servir + // sólo el resultado dejaría a un auditor sin poder distinguir «el Core lo + // exige» de «este tenant lo exige», que es la distinción que esta ficha existe + // para conservar. + TenantApplicability = applicability, + StandardRequired = a.Required, + }; + }).ToList(), + }; + } +} diff --git a/src/apps/tracker-api/Tracker.Application/Governance/Criteria/CoreSignalGateEvaluator.cs b/src/apps/tracker-api/Tracker.Application/Governance/Criteria/CoreSignalGateEvaluator.cs new file mode 100644 index 00000000..eca2fabd --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Governance/Criteria/CoreSignalGateEvaluator.cs @@ -0,0 +1,63 @@ +using Tracker.Domain.Governance.GatePolicy; +using Tracker.Domain.Integration.CoreEvaluationTransaction; + +namespace Tracker.Application.Governance.Criteria; + +/// +/// CP-07 criterio 3 — pasa los depósitos del Core ADJUNTOS a esta fase por la matriz de señales +/// del tenant (CP-06). +/// +/// Es la pieza que faltaba entre tres fichas. CP-05 conserva lo que el Core devuelve, +/// CP-06 construyó la matriz que decide qué bloquea y CP-07 permitió colgar un depósito de una +/// fase — y nada producía observaciones, así que la matriz existía sin entrada. Es el defecto de +/// «construido pero no cableado» que esta campaña lleva corrigiendo, esta vez en piezas propias. +/// +/// Devuelve null cuando el tenant no configuró matriz, exactamente como +/// con sus criterios: una compuerta sin matriz se decide como +/// siempre. Sin esto, aparecer en el tablero habría cambiado la conducta de cada compuerta ya +/// configurada sin que ningún tenant pidiera nada. +/// +public interface ICoreSignalGateEvaluator +{ + Task EvaluateAsync( + Guid tenantId, string phase, Guid initiativeId, CancellationToken cancellationToken = default); +} + +internal sealed class CoreSignalGateEvaluator : ICoreSignalGateEvaluator +{ + private readonly IGatePolicyRepository _policies; + private readonly ICoreEvaluationTransactionRepository _transactions; + + public CoreSignalGateEvaluator( + IGatePolicyRepository policies, + ICoreEvaluationTransactionRepository transactions) + { + _policies = policies; + _transactions = transactions; + } + + public async Task EvaluateAsync( + Guid tenantId, string phase, Guid initiativeId, CancellationToken cancellationToken = default) + { + var policy = await _policies.GetByTenantAndPhaseAsync(tenantId, phase, cancellationToken); + if (policy is null || policy.CoreSignalRules.Count == 0) + return null; + + var depositos = await _transactions.GetByTenantAsync(tenantId, cancellationToken); + + // El filtro es por INICIATIVA Y FASE, y ambas mitades importan: un depósito colgado de la + // fase de construcción de otra iniciativa no tiene nada que decir sobre esta compuerta. + var relevantes = depositos + .Where(d => d.Attachment is not null) + .Where(d => d.Attachment!.InitiativeId == initiativeId) + .Where(d => string.Equals(d.Attachment!.Phase, phase, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var observaciones = relevantes.SelectMany(CoreDepositSignals.From).ToList(); + + // Con matriz configurada y sin depósitos adjuntos SÍ se evalúa, con cero observaciones: el + // resultado es «nada bloqueó», que es cierto y queda en la traza. Devolver null aquí + // borraría de la auditoría el hecho de que la matriz estaba activa y no encontró nada. + return CoreSignalEvaluator.Evaluate(observaciones, policy.CoreSignalRules, DateTime.UtcNow); + } +} diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GatePolicy/Commands/UpsertGatePolicy/UpsertGatePolicyCommand.cs b/src/apps/tracker-api/Tracker.Application/Governance/GatePolicy/Commands/UpsertGatePolicy/UpsertGatePolicyCommand.cs index c8789aac..3fe3bb89 100644 --- a/src/apps/tracker-api/Tracker.Application/Governance/GatePolicy/Commands/UpsertGatePolicy/UpsertGatePolicyCommand.cs +++ b/src/apps/tracker-api/Tracker.Application/Governance/GatePolicy/Commands/UpsertGatePolicy/UpsertGatePolicyCommand.cs @@ -28,5 +28,21 @@ public sealed record UpsertGatePolicyCommand( IReadOnlyList Approvers, bool RequiresCoreVerdict, bool Active, - IReadOnlyList? EvaluationCriteria = null + IReadOnlyList? EvaluationCriteria = null, + /// CP-06 — qué hace este tenant con cada señal del Core en esta fase. + IReadOnlyList? CoreSignalRules = null, + /// CP-02 — overlay del tenant sobre el catálogo de artefactos del Core en esta fase. + IReadOnlyList? ArtifactApplicability = null ) : ICommand; + +/// CP-02 — una fila del overlay: qué artefacto y qué dice este tenant de él. +public sealed record UpsertGatePolicyArtifactApplicability(string ArtifactKind, string Applicability); + +/// +/// CP-06 — una fila de la matriz, en el vocabulario de entrada. +/// +/// MinSeverity nulo significa «cualquier severidad». Signal es cadena abierta +/// a propósito: el vocabulario de result kinds del Core crece, y un enum obligaría a desplegar el +/// Tracker para poder nombrar una señal nueva. +/// +public sealed record UpsertGatePolicyCoreSignal(string Signal, string Disposition, string? MinSeverity); diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GatePolicy/Commands/UpsertGatePolicy/UpsertGatePolicyCommandHandler.cs b/src/apps/tracker-api/Tracker.Application/Governance/GatePolicy/Commands/UpsertGatePolicy/UpsertGatePolicyCommandHandler.cs index 9c1f5cde..3472b4bf 100644 --- a/src/apps/tracker-api/Tracker.Application/Governance/GatePolicy/Commands/UpsertGatePolicy/UpsertGatePolicyCommandHandler.cs +++ b/src/apps/tracker-api/Tracker.Application/Governance/GatePolicy/Commands/UpsertGatePolicy/UpsertGatePolicyCommandHandler.cs @@ -38,6 +38,20 @@ public async Task> Handle( GateCriterionSpec.NormalizeSeverity(c.Severity))) .ToList(); + // CP-06 — la matriz del tenant. Una lista ausente se traduce a lista VACÍA, no a «deja lo + // que había»: un upsert que conservara reglas no enviadas haría imposible quitar un + // bloqueo, y quitarlo es justo la operación que un tenant necesita poder hacer deprisa. + var coreSignalRules = (request.CoreSignalRules ?? new List()) + .Select(r => new CoreSignalRule(r.Signal, r.Disposition, r.MinSeverity)) + .ToList(); + + // CP-02 — igual que la matriz de señales: una lista ausente se traduce a lista VACÍA, no a + // «deja lo que había». Un upsert que conservara marcas no enviadas haría imposible + // devolver un artefacto al estándar. + var artifactApplicability = (request.ArtifactApplicability ?? new List()) + .Select(r => new ArtifactApplicabilityRule(r.ArtifactKind, r.Applicability)) + .ToList(); + var approvalPolicy = new ApprovalPolicy(request.Strategy, request.Stages, approvers); var actor = request.ActorId.ToString(); @@ -48,7 +62,9 @@ public async Task> Handle( { var update = existing.Update( request.Mode, evidence, approvalPolicy, - request.RequiresCoreVerdict, request.Active, actor, criteria); + request.RequiresCoreVerdict, request.Active, actor, criteria, + exceptionPolicy: null, coreSignalRules: coreSignalRules, + artifactApplicability: artifactApplicability); if (update.IsFailure) return Result.Failure(update.Error); @@ -59,7 +75,9 @@ public async Task> Handle( var created = DomainGatePolicy.Create( request.TenantId, request.Phase, request.Mode, evidence, approvalPolicy, - request.RequiresCoreVerdict, actor, request.Active, criteria); + request.RequiresCoreVerdict, actor, request.Active, criteria, + exceptionPolicy: null, coreSignalRules: coreSignalRules, + artifactApplicability: artifactApplicability); if (created.IsFailure) return Result.Failure(created.Error); diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GateSubmission/Commands/EvaluateGateSubmission/EvaluateGateSubmissionCommandHandler.cs b/src/apps/tracker-api/Tracker.Application/Governance/GateSubmission/Commands/EvaluateGateSubmission/EvaluateGateSubmissionCommandHandler.cs index 092269ee..8282b5d1 100644 --- a/src/apps/tracker-api/Tracker.Application/Governance/GateSubmission/Commands/EvaluateGateSubmission/EvaluateGateSubmissionCommandHandler.cs +++ b/src/apps/tracker-api/Tracker.Application/Governance/GateSubmission/Commands/EvaluateGateSubmission/EvaluateGateSubmissionCommandHandler.cs @@ -12,8 +12,43 @@ namespace Tracker.Application.Governance.GateSubmission.Commands.EvaluateGateSub internal sealed class EvaluateGateSubmissionCommandHandler : ICommandHandler { + /// + /// CP-14 criterio 3 — congela como APROBADA la versión viva de cada artefacto de la fase. + /// + /// Se recorre lo que el expediente REALMENTE lleva, no lo que el gate exigía: aprobar + /// una versión de un artefacto que nadie entregó afirmaría que existe. + /// + /// Un expediente ausente no es un error. La compuerta puede decidirse sobre evidencia y + /// aprobaciones sin que haya expediente de fase, y fallar aquí convertiría una aprobación + /// válida en un error por algo que el modelo permite. + /// + private async Task SellarVersionesAprobadas( + Tracker.Domain.Governance.GateSubmission.GateSubmission submission, + CancellationToken cancellationToken) + { + var expediente = await _phaseArtifacts.GetByInitiativeAndPhaseAsync( + submission.InitiativeId, submission.Phase, cancellationToken); + + if (expediente is null || expediente.TenantId != submission.TenantId) return; + + var sellada = false; + foreach (var item in expediente.Items) + { + // `ApproveCurrentVersion` falla cuando el ítem no tiene versión viva — el caso de una + // edición sin autor, que a propósito no versiona. No es un error: es que no hay nada + // que sellar. + if (expediente.ApproveCurrentVersion(item.Type, item.ProductCode).IsSuccess) sellada = true; + } + + if (!sellada) return; + + await _phaseArtifacts.UpdateAsync(expediente, cancellationToken); + } + private readonly IGateSubmissionRepository _submissions; private readonly IGateCriteriaEvaluator _criteria; + private readonly ICoreSignalGateEvaluator _coreSignals; + private readonly Tracker.Domain.Sdlc.PhaseArtifact.IPhaseArtifactRepository _phaseArtifacts; private readonly IGateAuditWriter _audit; private readonly IPublisher _publisher; private readonly IUnitOfWork _unitOfWork; @@ -21,12 +56,16 @@ internal sealed class EvaluateGateSubmissionCommandHandler public EvaluateGateSubmissionCommandHandler( IGateSubmissionRepository submissions, IGateCriteriaEvaluator criteria, + ICoreSignalGateEvaluator coreSignals, + Tracker.Domain.Sdlc.PhaseArtifact.IPhaseArtifactRepository phaseArtifacts, IGateAuditWriter audit, IPublisher publisher, IUnitOfWork unitOfWork) { _submissions = submissions; _criteria = criteria; + _coreSignals = coreSignals; + _phaseArtifacts = phaseArtifacts; _audit = audit; _publisher = publisher; _unitOfWork = unitOfWork; @@ -46,11 +85,30 @@ public async Task> Handle( var criteriaOutcome = await _criteria.EvaluateInitiativeAsync( submission.TenantId, submission.Phase, submission.InitiativeId, cancellationToken); - var evaluation = submission.Evaluate(criteriaOutcome); + // CP-07 criterio 3 — los depósitos del Core ADJUNTOS a esta fase, pasados por la matriz + // del tenant (CP-06). Null cuando no hay matriz configurada: la compuerta se decide como + // siempre y añadir esto no cambia la conducta de ninguna ya configurada. + var signalOutcome = await _coreSignals.EvaluateAsync( + submission.TenantId, submission.Phase, submission.InitiativeId, cancellationToken); + + var evaluation = submission.Evaluate(criteriaOutcome, signalOutcome); var unmet = evaluation.Value; await _submissions.UpdateAsync(submission, cancellationToken); + // CP-14 criterio 3 — al APROBAR, se sella qué versiones se aprobaron. + // + // Sin esto la aprobación no dice nada sobre QUÉ se aprobó: la siguiente edición cambia el + // contenido y la aprobación queda apuntando al aire. Sellar aquí congela la versión viva + // de cada artefacto de la fase, y cualquier cambio posterior abre una nueva (CP-14). + // + // Sólo en APPROVED: un RETURNED deja el expediente en edición a propósito, y congelar + // versiones ahí impediría rehacerlo, que es justo lo que se le está pidiendo al autor. + if (submission.State == GateSubmissionState.Approved) + { + await SellarVersionesAprobadas(submission, cancellationToken); + } + var evidenceTrace = submission.Evidence .Where(e => e.IsAttached) .Select(e => new Dictionary diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs new file mode 100644 index 00000000..da24850f --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs @@ -0,0 +1,108 @@ +using MediatR; +using Tracker.Application.Governance.ArtifactFieldSchema.Commands.UpsertArtifactFieldSchema; +using Tracker.Application.Governance.GatePolicy.Commands.UpsertGatePolicy; + +namespace Tracker.Application.Governance.GovernancePackage; + +using Package = Domain.Governance.GovernancePackage.GovernancePackage; + +/// What an application did, item by item. Never a bare «ok». +public sealed record GovernancePackageApplyReport +{ + public string PackageName { get; init; } = string.Empty; + public string PackageVersion { get; init; } = string.Empty; + public IReadOnlyList AppliedGatePolicies { get; init; } = new List(); + public IReadOnlyList AppliedArtifactSchemas { get; init; } = new List(); + public IReadOnlyList Failures { get; init; } = new List(); + public bool FullyApplied => Failures.Count == 0; +} + +/// +/// GT-532 — aplica un paquete de gobernanza SOBRE EL TENANT DE LA SESIÓN. +/// +/// El destino no es un parámetro que el llamante elija: viene del contexto de sesión en la +/// capa de presentación. Un endpoint que aceptara «aplica este paquete al tenant X» sería una +/// forma de reescribir la gobernanza de otro cliente, y el paquete tampoco lleva dentro su tenant +/// de origen precisamente para que esto no se pueda hacer por accidente. +/// +/// Es idempotente porque reutiliza los upsert que ya existen, no porque compare +/// estados: aplicar dos veces el mismo paquete deja el mismo resultado. Esto también significa que +/// aplicar un paquete SOBRESCRIBE la configuración de las fases y tipos que nombra, y no toca las +/// demás. Es lo que hace que un paquete sea revisable —lo que dice es exactamente lo que cambia— +/// pero conviene decirlo, porque «aplicar» suena a fusionar y no lo es. +/// +/// No se detiene en el primer fallo. Un paquete parcialmente aplicado es un hecho que +/// hay que ver entero: pararse en el primero dejaría al tenant en un estado intermedio del que el +/// informe no dice nada. Se intenta todo y se informa de cada fallo con su fase o su tipo. +/// +public sealed record ApplyGovernancePackageCommand( + Guid TenantId, + Guid ActorId, + Package Package) : ICommand; + +internal sealed class ApplyGovernancePackageCommandHandler + : ICommandHandler +{ + private readonly IMediator _mediator; + + public ApplyGovernancePackageCommandHandler(IMediator mediator) => _mediator = mediator; + + public async Task> Handle( + ApplyGovernancePackageCommand request, CancellationToken ct) + { + var errores = request.Package.Validate(); + if (errores.Count > 0) + { + // Un paquete inválido se rechaza ENTERO antes de tocar nada. Aplicar «lo que se pueda» + // de un documento que no valida deja al tenant en un estado que nadie declaró. + return Result.Failure(string.Join("; ", errores)); + } + + var policies = new List(); + var schemas = new List(); + var failures = new List(); + + foreach (var p in request.Package.GatePolicies) + { + var result = await _mediator.Send(new UpsertGatePolicyCommand( + request.TenantId, + request.ActorId, + p.Phase, + p.Mode, + p.RequiredEvidence.Select(e => new UpsertGatePolicyEvidence(e.Type, e.Mandatory)).ToList(), + p.ApprovalStrategy, + p.ApprovalStages, + p.Approvers.Select(a => new UpsertGatePolicyApprover(a.RoleOrIdentity, a.Stage, a.Quorum)).ToList(), + p.RequiresCoreVerdict, + p.Active, + p.EvaluationCriteria.Select(c => new UpsertGatePolicyCriterion( + c.Id, c.Label, c.ArtifactType, c.FieldPath, c.Operator, + c.Expected.ToList(), c.Mandatory, c.Severity)).ToList()), ct); + + if (result.IsSuccess) policies.Add(p.Phase); + else failures.Add($"gate-policy:{p.Phase}: {result.Error}"); + } + + foreach (var s in request.Package.ArtifactFieldSchemas) + { + var result = await _mediator.Send(new UpsertArtifactFieldSchemaCommand( + request.TenantId, + request.ActorId, + s.ArtifactType, + s.CustomFields.Select(f => new UpsertCustomField( + f.Name, f.Label, f.DataType, f.EligibleValues.ToList(), f.Required)).ToList()), ct); + + if (result.IsSuccess) schemas.Add(s.ArtifactType); + else failures.Add($"artifact-schema:{s.ArtifactType}: {result.Error}"); + } + + return Result.Success(new GovernancePackageApplyReport + { + PackageName = request.Package.Name, + PackageVersion = request.Package.Version, + AppliedGatePolicies = policies, + AppliedArtifactSchemas = schemas, + Failures = failures, + }); + } +} diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs new file mode 100644 index 00000000..282449eb --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs @@ -0,0 +1,99 @@ +using Tracker.Domain.Governance.ArtifactFieldSchema; +using Tracker.Domain.Governance.GatePolicy; +using Tracker.Domain.Governance.GovernancePackage; + +namespace Tracker.Application.Governance.GovernancePackage; + +using Package = Domain.Governance.GovernancePackage.GovernancePackage; +// Alias explicitos: `GatePolicy` y `ArtifactFieldSchema` son tambien NOMBRES DE ESPACIO aqui. +using GatePolicyAggregate = Domain.Governance.GatePolicy.GatePolicy; +using ArtifactFieldSchemaAggregate = Domain.Governance.ArtifactFieldSchema.ArtifactFieldSchema; + +/// +/// GT-532 — exporta la configuración de gobernanza del tenant de la sesión como un paquete +/// portable. +/// +/// El tenant NO viaja en la petición: sale del contexto de sesión en la capa de +/// presentación. Aceptarlo como parámetro convertiría este endpoint en una forma de leer la +/// configuración de otro cliente, que es exactamente lo que el aislamiento del producto prohíbe. +/// +public sealed record ExportGovernancePackageQuery( + Guid TenantId, + string Name, + string Version, + string Description, + string ExportedFrom) : IQuery; + +internal sealed class ExportGovernancePackageQueryHandler + : IQueryHandler +{ + private readonly IGatePolicyRepository _policies; + private readonly IArtifactFieldSchemaRepository _schemas; + + public ExportGovernancePackageQueryHandler( + IGatePolicyRepository policies, IArtifactFieldSchemaRepository schemas) + { + _policies = policies; + _schemas = schemas; + } + + public async Task Handle(ExportGovernancePackageQuery request, CancellationToken ct) + { + var policies = await _policies.GetByTenantAsync(request.TenantId, ct); + var schemas = await _schemas.GetByTenantAsync(request.TenantId, ct); + + return new Package + { + Name = request.Name, + Version = request.Version, + Description = request.Description, + ExportedFrom = request.ExportedFrom, + ExportedAtUtc = DateTime.UtcNow, + // Ordenado por fase y por tipo, de forma estable: dos exportaciones del mismo estado + // deben producir el mismo documento, o nadie puede diferenciar dos paquetes ni revisar + // un cambio de gobernanza en un pull request. + GatePolicies = policies + .OrderBy(p => p.Phase, StringComparer.Ordinal) + .Select(ToPackaged) + .ToList(), + ArtifactFieldSchemas = schemas + .OrderBy(s => s.ArtifactType, StringComparer.Ordinal) + .Select(ToPackaged) + .ToList(), + }; + } + + private static PackagedGatePolicy ToPackaged(GatePolicyAggregate p) => new() + { + Phase = p.Phase, + Mode = p.Mode, + RequiredEvidence = p.RequiredEvidence + .OrderBy(e => e.Type, StringComparer.Ordinal) + .Select(e => new PackagedEvidence(e.Type, e.Mandatory)) + .ToList(), + ApprovalStrategy = p.ApprovalPolicy.Strategy, + ApprovalStages = p.ApprovalPolicy.Stages, + Approvers = p.ApprovalPolicy.Approvers + .OrderBy(a => a.Stage).ThenBy(a => a.RoleOrIdentity, StringComparer.Ordinal) + .Select(a => new PackagedApprover(a.RoleOrIdentity, a.Stage, a.Quorum)) + .ToList(), + RequiresCoreVerdict = p.RequiresCoreVerdict, + Active = p.Active, + EvaluationCriteria = p.EvaluationCriteria + .OrderBy(c => c.Id, StringComparer.Ordinal) + .Select(c => new PackagedCriterion( + c.Id, c.Label, c.ArtifactType, c.FieldPath, c.Operator, + c.Expected.ToList(), c.Mandatory, c.Severity)) + .ToList(), + }; + + private static PackagedArtifactFieldSchema ToPackaged(ArtifactFieldSchemaAggregate s) => new() + { + ArtifactType = s.ArtifactType, + CustomFields = s.CustomFields + .OrderBy(f => f.Name, StringComparer.Ordinal) + .Select(f => new PackagedCustomField( + f.Name, f.Label, f.DataType, f.EligibleValues.ToList(), f.Required)) + .ToList(), + }; +} diff --git a/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/DTOs/CoreEvaluationTransactionDto.cs b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/DTOs/CoreEvaluationTransactionDto.cs index 1f4357ce..97e84d1e 100644 --- a/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/DTOs/CoreEvaluationTransactionDto.cs +++ b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/DTOs/CoreEvaluationTransactionDto.cs @@ -44,6 +44,12 @@ public sealed class CoreEvaluationTransactionDto public int BlockingViolationCount { get; init; } public IReadOnlyDictionary Versions { get; init; } = new Dictionary(); + /// + /// CP-07 — dónde cuelga este depósito del expediente SDLC, o `null` si no se ha adjuntado. + /// Es un VÍNCULO: el veredicto de arriba es el que el Core depositó, sin copiar ni reinterpretar. + /// + public CoreEvaluationAttachment? Attachment { get; init; } + /// /// Proyección ÚNICA del agregado. Existe para que la lista y el detalle no puedan divergir: /// dos mapeos escritos a mano habrían dejado el motor fuera de uno de los dos, y el hueco sólo @@ -78,6 +84,7 @@ public static CoreEvaluationTransactionDto From( Violations = transaction.Violations, AccountableOwners = transaction.AccountableOwners, BlockingViolationCount = transaction.BlockingViolationCount, - Versions = transaction.Versions + Versions = transaction.Versions, + Attachment = transaction.Attachment, }; } diff --git a/src/apps/tracker-api/Tracker.Application/Metrics/ScorecardProjection.cs b/src/apps/tracker-api/Tracker.Application/Metrics/ScorecardProjection.cs index dd184c25..f03ca38e 100644 --- a/src/apps/tracker-api/Tracker.Application/Metrics/ScorecardProjection.cs +++ b/src/apps/tracker-api/Tracker.Application/Metrics/ScorecardProjection.cs @@ -51,7 +51,9 @@ internal sealed class ScorecardProjection( IPhaseProgressionRepository progressions, IPhaseArtifactRepository artifacts, ITechnicalBlueprintRepository blueprints, - IPhaseSlaPolicyRepository slas) : IScorecardProjection + IPhaseSlaPolicyRepository slas, + // CP-02 criterio 3 — el MISMO catálogo que gobierna el expediente y la evaluación. + Governance.ArtifactFieldSchema.ITenantPhaseArtifactCatalog catalogo) : IScorecardProjection { /// /// Las fases que el scorecard PINTA. Las tres genéricas sacan sus artefactos de @@ -99,7 +101,7 @@ internal sealed class ScorecardProjection( var items = fase == SdlcPhases.Design ? await ArtefactosDeDisenoAsync(initiativeId, ct) - : await ArtefactosGenericosAsync(initiativeId, fase, ct); + : await ArtefactosGenericosAsync(tenantId, initiativeId, fase, ct); if (FasesPintadas.Contains(fase) && !politicas.ContainsKey(fase)) advertencias.Add($"Metrics.NoSlaConfigured.{fase}"); @@ -133,11 +135,24 @@ internal sealed class ScorecardProjection( return new ScorecardDto(initiativeId, resultado.Score, fases, advertencias); } + /// + /// Los artefactos de una fase genérica: lo ENTREGADO más lo EXIGIDO que falta. + /// + /// El falso verde que esto cierra (CP-02 criterio 3). Antes los hechos salían sólo + /// de artefacto.Items, así que un artefacto que el estándar exige y nadie entregó era + /// INVISIBLE para el scorecard. Una fase con el expediente vacío no puntuaba mal: puntuaba + /// `null` — «nada exigible todavía» — mientras el catálogo del tenant pedía tres cosas. El + /// cuadro decía que no había nada que reprochar justo cuando faltaba todo. + /// + /// El catálogo es el TENANT-AWARE, el mismo que gobierna el expediente y el payload que + /// va al Core: un scorecard que puntuara contra el estándar pelado castigaría a un tenant por + /// no entregar lo que él mismo declaró no aplicable. + /// private async Task> ArtefactosGenericosAsync( - Guid initiativeId, string fase, CancellationToken ct) + Guid tenantId, Guid initiativeId, string fase, CancellationToken ct) { var artefacto = await artifacts.GetByInitiativeAndPhaseAsync(initiativeId, fase, ct); - return artefacto is null + var entregados = artefacto is null ? [] : artefacto.Items // GT-475 — se propaga la fecha de entrega por artefacto (null en las verticales de @@ -145,6 +160,20 @@ private async Task> ArtefactosGenericosAsync( // artefacto y no sólo de fase. .Select(i => new ArtifactFact(i.Type, i.IsComplete, ArtifactOrigin.Automatic, i.DeliveredAtUtc)) .ToList(); + + var perfil = await catalogo.GetPhaseAsync(tenantId, fase, ct); + if (perfil is null) return entregados; + + var yaEstan = entregados.Select(f => f.Type).ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Sólo lo `Required` tras el overlay. Lo advisory cuenta para completitud y pintarlo en + // rojo por no entregarse inventaría un incumplimiento sobre algo que nadie tenía que + // entregar — el mismo razonamiento que el ADR de integración de más abajo. + var faltantes = perfil.Artifacts + .Where(a => a.Required && !yaEstan.Contains(a.ArtifactKind)) + .Select(a => new ArtifactFact(a.ArtifactKind, IsComplete: false, ArtifactOrigin.Automatic, DeliveredAtUtc: null)); + + return [.. entregados, .. faltantes]; } /// diff --git a/src/apps/tracker-api/Tracker.Application/Sdlc/InitiativeLedger/GetInitiativeLedgerQuery.cs b/src/apps/tracker-api/Tracker.Application/Sdlc/InitiativeLedger/GetInitiativeLedgerQuery.cs new file mode 100644 index 00000000..4bc7a544 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Sdlc/InitiativeLedger/GetInitiativeLedgerQuery.cs @@ -0,0 +1,289 @@ +using Tracker.Domain.Audit.AuditEntry; +using Tracker.Domain.Governance.Criteria; +using Tracker.Domain.Governance.GatePolicy; +using Tracker.Domain.Governance.GateSubmission; +using Tracker.Domain.Integration.CoreEvaluationTransaction; +using Tracker.Domain.Sdlc.PhaseArtifact; + +namespace Tracker.Application.Sdlc.InitiativeLedger; + +/// +/// CP-16 — la bitácora SDLC de una iniciativa: qué pasó, quién participó y qué clase de hecho fue. +/// +/// Filtrable por fase y por artefacto (criterio 1). El gate se identifica en cada +/// entrada, así que filtrar por él es leer la columna, no una consulta aparte. +/// +public sealed record GetInitiativeLedgerQuery( + Guid TenantId, + Guid InitiativeId, + string? Phase = null, + string? ArtifactKind = null) : IQuery; + +/// +/// CP-16 — compone la bitácora DERIVÁNDOLA de los agregados que ya la contienen. +/// +/// Por qué una proyección y no un ledger propio. Un almacén paralelo tendría su propio +/// camino de escritura y podría divergir de los agregados que resume: la bitácora diría una cosa y +/// la entrega de compuerta otra, sin que nada lo señalara. Derivarla hace la divergencia +/// imposible por construcción, al precio de leer de tres sitios. +/// +/// No inventa hechos. Si un agregado no registró algo, aquí no aparece. Rellenar +/// huecos con suposiciones convertiría una bitácora de auditoría en una narración. +/// +internal sealed class GetInitiativeLedgerQueryHandler + : IQueryHandler +{ + private readonly IAuditEntryRepository _audit; + private readonly IGateSubmissionRepository _submissions; + private readonly ICoreEvaluationTransactionRepository _transactions; + private readonly Tracker.Domain.Sdlc.PhaseArtifact.IPhaseArtifactRepository _phaseArtifacts; + + public GetInitiativeLedgerQueryHandler( + IAuditEntryRepository audit, + IGateSubmissionRepository submissions, + ICoreEvaluationTransactionRepository transactions, + Tracker.Domain.Sdlc.PhaseArtifact.IPhaseArtifactRepository phaseArtifacts) + { + _audit = audit; + _submissions = submissions; + _transactions = transactions; + _phaseArtifacts = phaseArtifacts; + } + + public async Task Handle( + GetInitiativeLedgerQuery request, CancellationToken cancellationToken) + { + var entradas = new List(); + + var submissions = (await _submissions.GetByInitiativeAsync(request.InitiativeId, cancellationToken)) + // El tenant se comprueba en CADA fuente y no sólo a la entrada: una consulta por + // iniciativa que confiara en que el id es del tenant correcto sería una fuga con forma + // de listado. + .Where(s => s.TenantId == request.TenantId) + .ToList(); + + entradas.AddRange(await DeAuditoria(request, submissions, cancellationToken)); + entradas.AddRange(submissions.SelectMany(DeCompuerta)); + entradas.AddRange(await DeDepositos(request, cancellationToken)); + entradas.AddRange(await DeVersiones(request, submissions, cancellationToken)); + + var filtradas = entradas + .Where(e => request.Phase is null + || string.Equals(e.Phase, request.Phase, StringComparison.OrdinalIgnoreCase)) + .Where(e => request.ArtifactKind is null + || string.Equals(e.ArtifactKind, request.ArtifactKind, StringComparison.OrdinalIgnoreCase)) + .OrderBy(e => e.OccurredAtUtc) + .ToList(); + + return new InitiativeLedgerDto + { + InitiativeId = request.InitiativeId, + Phase = request.Phase, + ArtifactKind = request.ArtifactKind, + Entries = filtradas, + }; + } + + private async Task> DeAuditoria( + GetInitiativeLedgerQuery request, + IReadOnlyList submissions, + CancellationToken ct) + { + // La auditoría se indexa por entidad, así que la iniciativa y sus entregas se piden por + // separado. Es la consecuencia de derivar en vez de duplicar, y es preferible: el índice + // por entidad es el que hace útil la auditoría para todo lo demás. + var entries = new List( + await _audit.GetByEntityAsync("Initiative", request.InitiativeId.ToString(), ct)); + + foreach (var s in submissions) + { + entries.AddRange(await _audit.GetByEntityAsync("GateSubmission", s.Id.ToString(), ct)); + } + + var faseDeEntrega = submissions.ToDictionary(s => s.Id.ToString(), s => s.Phase, StringComparer.Ordinal); + + return entries + .Where(e => e.TenantId == request.TenantId) + .Select(e => new InitiativeLedgerEntryDto + { + OccurredAtUtc = e.TimestampUtc, + Source = "audit", + Action = e.Action, + Kind = ClasificarAccion(e.Action), + Phase = faseDeEntrega.TryGetValue(e.EntityId, out var fase) ? fase : null, + GateSubmissionId = e.EntityType == "GateSubmission" ? e.EntityId : null, + ActorType = e.ActorType, + ActorId = e.ActorId.ToString(), + AgentId = e.AgentId, + ModelId = e.ModelId, + CorrelationId = e.CorrelationId, + Detail = $"{e.EntityType} {e.Action}", + }); + } + + private static IEnumerable DeCompuerta(GateSubmission s) + { + // La evidencia ADJUNTA es un requisito cumplido, y es un hecho distinto de la decisión. + foreach (var slot in s.Evidence.Where(e => e.IsAttached)) + { + yield return new InitiativeLedgerEntryDto + { + OccurredAtUtc = s.RequestedAtUtc, + Source = "gate", + Action = "evidence.attached", + Kind = LedgerEventKind.RequirementMet, + Phase = s.Phase, + GateSubmissionId = s.Id.ToString(), + ArtifactKind = slot.RequirementType, + Detail = $"Evidencia «{slot.RequirementType}» adjunta.", + }; + } + + foreach (var paso in s.Approvals.Where(a => a.Status != ApprovalStepStatus.Pending)) + { + yield return new InitiativeLedgerEntryDto + { + OccurredAtUtc = s.DecidedAtUtc ?? s.RequestedAtUtc, + Source = "gate", + Action = $"approval.{paso.Status}", + Kind = paso.Status == ApprovalStepStatus.Approved + ? LedgerEventKind.RequirementMet + : LedgerEventKind.Blocked, + Phase = s.Phase, + GateSubmissionId = s.Id.ToString(), + ActorId = paso.Approver, + Detail = $"Aprobación de «{paso.Approver}»: {paso.Status}.", + }; + } + + // Los criterios del tenant (T-031) y las señales del Core (CP-06) se distinguen por CLASE, + // que es exactamente el criterio 4: un advisory y un bloqueo no pueden leerse igual. + foreach (var criterio in s.CriteriaEvaluation) + { + yield return new InitiativeLedgerEntryDto + { + OccurredAtUtc = criterio.EvaluatedAtUtc, + Source = "gate", + Action = $"criterion.{criterio.Status}", + Kind = Clasificar(criterio), + Phase = s.Phase, + GateSubmissionId = s.Id.ToString(), + ArtifactKind = criterio.ArtifactType, + Detail = criterio.Message, + }; + } + + foreach (var senal in s.CoreSignalEvaluation) + { + yield return new InitiativeLedgerEntryDto + { + OccurredAtUtc = s.DecidedAtUtc ?? s.RequestedAtUtc, + Source = "gate", + Action = $"core-signal.{senal.Disposition}", + Kind = senal.Blocks ? LedgerEventKind.Blocked : LedgerEventKind.Advisory, + Phase = s.Phase, + GateSubmissionId = s.Id.ToString(), + Detail = $"{senal.Signal}: {senal.Reason}", + }; + } + } + + private async Task> DeDepositos( + GetInitiativeLedgerQuery request, CancellationToken ct) + { + var depositos = await _transactions.GetByTenantAsync(request.TenantId, ct); + + // Sólo los ADJUNTOS: uno suelto en el ledger técnico no forma parte del recorrido de + // ninguna iniciativa, y meterlo aquí sería contar como historia de esta fase algo que + // nadie situó en ella. + return depositos + .Where(d => d.Attachment is not null && d.Attachment.InitiativeId == request.InitiativeId) + .Select(d => new InitiativeLedgerEntryDto + { + OccurredAtUtc = d.Attachment!.AttachedAtUtc, + Source = "core-deposit", + Action = "core-evaluation.attached", + // El veredicto del Core NO se reinterpreta como bloqueo: qué bloquea esta fase lo + // decide la matriz del tenant (CP-06), y sus disposiciones ya viajan arriba como + // entradas propias. Clasificarlo aquí duplicaría la decisión en dos sitios. + Kind = LedgerEventKind.Advisory, + Phase = d.Attachment.Phase, + GateSubmissionId = d.Attachment.GateSubmissionId?.ToString(), + ArtifactKind = d.Attachment.ArtifactKind, + ArtifactVersion = d.Attachment.ArtifactVersion, + ActorId = d.Attachment.AttachedBy, + ActorType = d.RequestedBy?.ActorType, + CorrelationId = d.CorrelationId, + Detail = $"Evaluación {d.OverallVerdict} de «{d.ProducerSurface}» adjunta a la fase {d.Attachment.Phase}.", + }); + } + + /// + /// CP-14 criterio 3 — cada versión de artefacto es un hecho del recorrido. + /// + /// Es lo que hace contestable «qué versión se aprobó»: una entrada por versión, con su + /// autor, su motivo y su número. Una versión APROBADA se clasifica como requisito cumplido; + /// las demás son registro — editar no es cumplir. + /// + /// Se leen las fases que la iniciativa realmente tiene abiertas, más la del filtro si se + /// pidió una. Recorrer todas las fases posibles produciría consultas por expedientes que no + /// existen. + /// + private async Task> DeVersiones( + GetInitiativeLedgerQuery request, + IReadOnlyList submissions, + CancellationToken ct) + { + var fases = submissions.Select(s => s.Phase).ToList(); + if (request.Phase is not null) fases.Add(request.Phase); + + var entradas = new List(); + + foreach (var fase in fases.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var expediente = await _phaseArtifacts.GetByInitiativeAndPhaseAsync(request.InitiativeId, fase, ct); + if (expediente is null || expediente.TenantId != request.TenantId) continue; + + entradas.AddRange(expediente.Versions.Select(v => new InitiativeLedgerEntryDto + { + OccurredAtUtc = v.AuthoredAtUtc, + Source = "artifact-version", + Action = $"artifact.version.{v.Status}", + Kind = v.Status == ArtifactVersionStatus.Approved + ? LedgerEventKind.RequirementMet + : LedgerEventKind.Record, + Phase = fase, + ArtifactKind = v.Type, + ArtifactVersion = v.Version, + ActorId = v.AuthoredBy, + Detail = v.ChangeReason ?? $"Versión {v.Version} de «{v.Type}».", + })); + } + + return entradas; + } + + private static string Clasificar(CriterionResult criterio) => criterio.Status switch + { + CriterionStatus.Met => LedgerEventKind.RequirementMet, + CriterionStatus.NotMet when criterio.Severity == CriterionSeverity.Blocking => LedgerEventKind.Blocked, + CriterionStatus.NotMet => LedgerEventKind.Advisory, + _ => LedgerEventKind.Record, + }; + + /// + /// Clasifica una acción de auditoría por su nombre. + /// + /// Lo que no encaja cae en y NO se fuerza a una de + /// las clases con significado: marcar como «bloqueo» algo que sólo se parece a un bloqueo por + /// el nombre sería inventar historia en una superficie de auditoría. + /// + private static string ClasificarAccion(string action) + { + var a = action.ToLowerInvariant(); + if (a.Contains("waiv") || a.Contains("exception")) return LedgerEventKind.ExceptionApproved; + if (a.Contains("reject") || a.Contains("block")) return LedgerEventKind.Blocked; + if (a.Contains("approve")) return LedgerEventKind.RequirementMet; + return LedgerEventKind.Record; + } +} diff --git a/src/apps/tracker-api/Tracker.Application/Sdlc/InitiativeLedger/InitiativeLedgerDtos.cs b/src/apps/tracker-api/Tracker.Application/Sdlc/InitiativeLedger/InitiativeLedgerDtos.cs new file mode 100644 index 00000000..37e0c6b9 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Sdlc/InitiativeLedger/InitiativeLedgerDtos.cs @@ -0,0 +1,79 @@ +namespace Tracker.Application.Sdlc.InitiativeLedger; + +/// +/// CP-16 criterio 4 — qué CLASE de hecho es una entrada de la bitácora. +/// +/// Sin esto, «el Core recomendó separar un módulo» y «el gate quedó bloqueado» se leen igual +/// en una lista cronológica, y quien audita no puede decir qué detuvo la fase. La distinción es +/// justo lo que esta ficha existe para conservar. +/// +public static class LedgerEventKind +{ + /// Se informó y no detuvo nada. + public const string Advisory = "advisory"; + + /// Un requisito que quedó cumplido (evidencia adjunta, criterio MET, aprobación dada). + public const string RequirementMet = "requirement-met"; + + /// Algo impidió avanzar. + public const string Blocked = "blocked"; + + /// Una excepción que alguien aprobó a sabiendas. + public const string ExceptionApproved = "exception-approved"; + + /// Un hecho del expediente que no clasifica en los anteriores: se creó, se editó, se envió. + public const string Record = "record"; +} + +/// +/// CP-16 — un hecho del recorrido de una iniciativa, en vocabulario de NEGOCIO. +/// +/// Es una proyección, no un segundo ledger. Cada entrada se deriva de un agregado que +/// ya existe —auditoría, entrega de compuerta, depósito del Core adjunto—; no hay camino de +/// escritura propio. Un almacén paralelo podría divergir de los agregados que resume, que es la +/// clase de defecto que este tablero lleva toda la campaña cerrando. +/// +public sealed class InitiativeLedgerEntryDto +{ + public required DateTime OccurredAtUtc { get; init; } + + /// De qué superficie sale este hecho: `audit`, `gate`, `core-deposit`. + public required string Source { get; init; } + + /// Qué pasó, en el vocabulario de quien lo produjo. + public required string Action { get; init; } + + /// Ver . + public required string Kind { get; init; } + + public string? Phase { get; init; } + public string? GateSubmissionId { get; init; } + public string? ArtifactKind { get; init; } + + /// + /// CP-14 criterio 3 — QUÉ VERSIÓN del artefacto. Sin ella, «la evaluación dice que el PRD está + /// mal» deja de ser contestable en cuanto alguien edita el PRD. + /// + public int? ArtifactVersion { get; init; } + + /// `human`, `agent`, `system`… tal y como lo declaró la superficie de origen. + public string? ActorType { get; init; } + public string? ActorId { get; init; } + public string? AgentId { get; init; } + public string? ModelId { get; init; } + + /// La clave que une este hecho con el rastro técnico que lo produjo. + public string? CorrelationId { get; init; } + + /// Texto para una persona. Nunca se interpreta. + public required string Detail { get; init; } +} + +/// CP-16 — la bitácora de una iniciativa, con lo que se filtró para producirla. +public sealed class InitiativeLedgerDto +{ + public required Guid InitiativeId { get; init; } + public string? Phase { get; init; } + public string? ArtifactKind { get; init; } + public required IReadOnlyList Entries { get; init; } +} diff --git a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Commands/SetPhaseArtifactItem/SetPhaseArtifactItemCommand.cs b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Commands/SetPhaseArtifactItem/SetPhaseArtifactItemCommand.cs index 8e406e24..98d8298c 100644 --- a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Commands/SetPhaseArtifactItem/SetPhaseArtifactItemCommand.cs +++ b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Commands/SetPhaseArtifactItem/SetPhaseArtifactItemCommand.cs @@ -14,5 +14,7 @@ public sealed record SetPhaseArtifactItemCommand( string? ProductCode, string? Ref, string? Content, - string? Status + string? Status, + /// CP-14 — por qué cambió, para la historia del artefacto. Opcional. + string? ChangeReason = null ) : ICommand; diff --git a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Commands/SetPhaseArtifactItem/SetPhaseArtifactItemCommandHandler.cs b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Commands/SetPhaseArtifactItem/SetPhaseArtifactItemCommandHandler.cs index dec773e0..b07bacfa 100644 --- a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Commands/SetPhaseArtifactItem/SetPhaseArtifactItemCommandHandler.cs +++ b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Commands/SetPhaseArtifactItem/SetPhaseArtifactItemCommandHandler.cs @@ -24,8 +24,12 @@ public async Task Handle( if (artifact is null || artifact.TenantId != request.TenantId) return Result.Failure("PhaseArtifact.NotFound"); + // CP-14 — el actor ya viajaba en el comando y no se usaba para nada; ahora es lo que + // convierte una edición en una versión trazable. var result = artifact.SetItem( - request.Type, request.ProductCode, request.Ref, request.Content, request.Status); + request.Type, request.ProductCode, request.Ref, request.Content, request.Status, + authoredBy: request.ActorId.ToString(), + changeReason: request.ChangeReason); if (result.IsFailure) return result; diff --git a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs index 61ce04a3..edb8dd8f 100644 --- a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs +++ b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs @@ -12,6 +12,51 @@ public sealed class ArtifactItemDto public string Status { get; init; } = string.Empty; } +/// +/// CP-02 — one artifact of the phase AS THE CORE DECLARES IT, next to whether this +/// record actually carries it. +/// +public sealed class CoreCatalogArtifactDto +{ + public string ArtifactKind { get; init; } = string.Empty; + public string Label { get; init; } = string.Empty; + + /// `binding` in the Core registry. Says what the STANDARD expects, not what this Tracker enforces. + public bool Required { get; init; } + + /// Whether an item of this kind exists on the record. Present, not valid — nothing here reads content. + public bool Present { get; init; } +} + +/// +/// CP-02 — the phase's artifact catalogue as the Core declares it, resolved through the +/// SAME seam the read-only `/phase-artifact-profiles` endpoint reads. +/// +/// Why this is REPORTED and never enforced. T-056: what a gate demands is +/// tenant configuration, not engine code. Comparing the record against the standard is +/// a fact; deciding that a missing artifact blocks a phase is an opinion, and it is not +/// ours to wire. So this block states both directions and stops there. +/// +/// Both directions, deliberately. `Artifacts` says what the Core declares +/// and whether the record carries it; says what the record +/// carries that the Core does not declare. Reporting only the first would make a record +/// full of off-standard items look complete. +/// +public sealed class PhaseCoreCatalogDto +{ + /// + /// `core-sync` once a sync has succeeded, `core-standin` while the mirror is being + /// served. A reader must be able to tell a synced answer from a mirrored one by + /// reading the payload rather than by assuming a sync happened. + /// + public string Source { get; init; } = string.Empty; + + public IReadOnlyList Artifacts { get; init; } = new List(); + + /// Item kinds on the record that the Core catalogue does not declare. + public IReadOnlyList UnknownItemKinds { get; init; } = new List(); +} + public sealed class PhaseArtifactDto { public Guid Id { get; init; } @@ -20,6 +65,13 @@ public sealed class PhaseArtifactDto public string Phase { get; init; } = string.Empty; public string Status { get; init; } = string.Empty; public IReadOnlyList Items { get; init; } = new List(); + + /// + /// Null when no catalogue was resolved for the phase — which is NOT the same as an + /// empty catalogue, and the difference matters: one means "the standard was not + /// available", the other means "the standard asks for nothing here". + /// + public PhaseCoreCatalogDto? CoreCatalog { get; init; } } public static class PhaseArtifactDtoMapper @@ -34,6 +86,54 @@ public static class PhaseArtifactDtoMapper Items = artifact.Items.Select(ToDto).ToList() }; + /// + /// CP-02 — the same record, contrasted against the Core's catalogue for its phase. + /// + /// Item kinds are matched case-insensitively: the catalogue writes slugs and the + /// record accepts whatever a caller sent, so a case difference would report a present + /// artifact as missing — a false gap on a governance surface, which is worse than no + /// comparison at all. + /// + public static PhaseArtifactDto ToDto( + DomainPhaseArtifact artifact, + string source, + IReadOnlyList<(string Kind, string Label, bool Required)> catalog) + { + var present = artifact.Items + .Select(i => i.Type) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var declared = catalog.Select(c => c.Kind).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return new PhaseArtifactDto + { + Id = artifact.Id, + TenantId = artifact.TenantId, + InitiativeId = artifact.InitiativeId, + Phase = artifact.Phase, + Status = artifact.Status, + Items = artifact.Items.Select(ToDto).ToList(), + CoreCatalog = new PhaseCoreCatalogDto + { + Source = source, + Artifacts = catalog + .Select(c => new CoreCatalogArtifactDto + { + ArtifactKind = c.Kind, + Label = c.Label, + Required = c.Required, + Present = present.Contains(c.Kind), + }) + .ToList(), + UnknownItemKinds = present + .Where(t => !declared.Contains(t)) + .OrderBy(t => t, StringComparer.OrdinalIgnoreCase) + .ToList(), + }, + }; + } + private static ArtifactItemDto ToDto(ArtifactItem i) => new() { Type = i.Type, diff --git a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs index 97f1a82b..49389651 100644 --- a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs +++ b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs @@ -1,16 +1,42 @@ using Tracker.Application.Sdlc.PhaseArtifact.DTOs; using Tracker.Domain.Sdlc.PhaseArtifact; +// `PhaseArtifactDto` is a live name in BOTH the governance catalogue and this module. The +// namespace is aliased rather than imported so the two never silently resolve to each other. +using ITenantPhaseArtifactCatalog = Tracker.Application.Governance.ArtifactFieldSchema.ITenantPhaseArtifactCatalog; + namespace Tracker.Application.Sdlc.PhaseArtifact.Queries.GetPhaseArtifact; +/// +/// CP-02 — the operative phase record, contrasted against the Core's catalogue. +/// +/// What this closes. The Tracker had TWO artifact vocabularies that never met: +/// `/phase-artifact-profiles`, read-only and derived from the Core, which nothing operational +/// consulted; and this record, which the user actually fills and which accepted any `type` +/// string whatsoever. A phase could therefore be completed entirely with items the Core does +/// not declare, and no surface would say so. That is CP-02's risk verbatim: the Tracker +/// looks like it governs the SDLC while governing a copy of the standard. +/// +/// It reports, it does not enforce. T-056: what a gate demands is tenant +/// configuration, not engine code. Nothing here rejects an item, changes a status, or blocks +/// a phase — the comparison is a fact the caller can act on under its own policy. +/// +/// Same seam, deliberately. It resolves through , +/// the very source the read-only endpoint reads. Two surfaces answering the same question from +/// two catalogues is the defect; a second copy here would have rebuilt it. +/// internal sealed class GetPhaseArtifactQueryHandler : IQueryHandler { private readonly IPhaseArtifactRepository _artifacts; + private readonly ITenantPhaseArtifactCatalog _profiles; - public GetPhaseArtifactQueryHandler(IPhaseArtifactRepository artifacts) + public GetPhaseArtifactQueryHandler( + IPhaseArtifactRepository artifacts, + ITenantPhaseArtifactCatalog profiles) { _artifacts = artifacts; + _profiles = profiles; } public async Task Handle( @@ -21,6 +47,20 @@ public GetPhaseArtifactQueryHandler(IPhaseArtifactRepository artifacts) if (artifact is null || artifact.TenantId != request.TenantId) return null; - return PhaseArtifactDtoMapper.ToDto(artifact); + // CP-02 — el catálogo del Core CON el overlay de este tenant. Contrastar el expediente + // contra el estándar pelado diría que falta un artefacto que el tenant declaró no + // aplicable, o daría por opcional uno que él exige. + var profile = await _profiles.GetPhaseAsync(request.TenantId, artifact.Phase, cancellationToken); + + // No profile for the phase is NOT an empty profile. Reporting "the standard asks for + // nothing here" when the truth is "the standard was not available" is the kind of + // confident emptiness a governance surface must never produce, so the block is omitted. + if (profile is null) + return PhaseArtifactDtoMapper.ToDto(artifact); + + return PhaseArtifactDtoMapper.ToDto( + artifact, + profile.Source, + profile.Artifacts.Select(a => (a.ArtifactKind, a.Label, a.Required)).ToList()); } } diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/CoreDepositSignals.cs b/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/CoreDepositSignals.cs new file mode 100644 index 00000000..fd1d9502 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/CoreDepositSignals.cs @@ -0,0 +1,94 @@ +using Tracker.Domain.Integration.CoreEvaluationTransaction; + +namespace Tracker.Domain.Governance.GatePolicy; + +/// +/// CP-07 criterio 3 — traduce un depósito ADJUNTO a las observaciones que la matriz del tenant +/// sabe evaluar. +/// +/// Es la pieza que faltaba entre tres fichas. CP-05 conserva lo que el Core devuelve, +/// CP-06 construyó la matriz que decide qué bloquea, y CP-07 permitió colgar un depósito de una +/// fase. Nada producía , así que la matriz existía sin +/// entrada — el defecto de «construido pero no cableado» que esta campaña lleva corrigiendo, esta +/// vez en una pieza mía. +/// +/// Traduce, no decide. No mira umbrales ni reinterpreta severidades: convierte +/// hechos del depósito en observaciones y deja que aplique las +/// reglas del tenant. Quién bloquea sigue siendo configuración suya (T-056). +/// +public static class CoreDepositSignals +{ + /// + /// Nombre de señal para un depósito que no dice de qué categoría es su hallazgo, y para el + /// veredicto del depósito en su conjunto. + /// + /// Es un nombre ESTABLE y documentado, no una cadena improvisada: un tenant tiene que + /// poder escribir una regla contra él, y una regla sólo vale si el nombre no cambia. + /// + public const string DepositSignal = "core-deposit"; + + /// + /// Las observaciones de un depósito adjunto. + /// + /// Sólo si está ADJUNTO. Un depósito suelto en el ledger no habla de ninguna + /// fase; dejarlo entrar haría que la evaluación de una compuerta dependiera de evidencia que + /// nadie situó ahí. + /// + /// Una observación por violación, más una por el veredicto. La segunda no es + /// redundante: un depósito puede fallar sin violaciones tipadas —el productor devolvió FAIL y + /// nada más— y sin ella ese caso sería invisible para la matriz. Con ella, un tenant puede + /// escribir «cualquier depósito fallido me bloquea» aunque no lleve detalle. + /// + /// La categoría se usa como señal cuando la hay. Es lo que permite a un tenant + /// decir «las violaciones de arquitectura que me depositen bloquean, las de estilo no». Sin + /// categoría cae en : inventarle una sería adivinar de qué habla. + /// + public static IReadOnlyList From(CoreEvaluationTransaction transaction) + { + if (transaction?.Attachment is null) return []; + + var observaciones = new List(); + + foreach (var violacion in transaction.Violations) + { + // Una violación CONGELADA es una que el tenant ya aceptó a sabiendas. Volver a + // observarla reabriría una decisión que alguien tomó, y el propio ledger ya la excluye + // de su conteo de bloqueantes; contarla aquí haría que las dos superficies del mismo + // producto discreparan sobre el mismo hecho. + if (violacion.Frozen) continue; + + var senal = string.IsNullOrWhiteSpace(violacion.Category) + ? DepositSignal + : violacion.Category!.Trim(); + + observaciones.Add(new CoreSignalObservation( + senal, + // La severidad viaja TAL CUAL. Normalizarla aquí a un vocabulario conocido + // escondería un valor que el productor sí emitió, y el evaluador ya sabe tratar + // una severidad que no reconoce sin fabricar un bloqueo. + string.IsNullOrWhiteSpace(violacion.Severity) ? null : violacion.Severity, + Detalle(violacion, transaction))); + } + + if (!string.IsNullOrWhiteSpace(transaction.OverallVerdict)) + { + observaciones.Add(new CoreSignalObservation( + DepositSignal, + Severity: null, + $"Depósito {transaction.OverallVerdict} de «{transaction.ProducerSurface ?? "desconocido"}» " + + $"adjunto a la fase {transaction.Attachment.Phase}.")); + } + + return observaciones; + } + + private static string Detalle(CoreEvaluationViolation violacion, CoreEvaluationTransaction transaction) + { + var donde = string.IsNullOrWhiteSpace(violacion.File) ? null : violacion.File; + var mensaje = string.IsNullOrWhiteSpace(violacion.Message) ? violacion.RuleId : violacion.Message; + + return donde is null + ? $"{mensaje} (regla {violacion.RuleId}, depósito {transaction.CorrelationId})" + : $"{mensaje} — {donde} (regla {violacion.RuleId}, depósito {transaction.CorrelationId})"; + } +} diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/CoreSignalEvaluator.cs b/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/CoreSignalEvaluator.cs new file mode 100644 index 00000000..5aae7da5 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/CoreSignalEvaluator.cs @@ -0,0 +1,127 @@ +namespace Tracker.Domain.Governance.GatePolicy; + +/// Una señal observada del Core, ya en el vocabulario del Tracker. +/// El result kind o dimensión, tal y como el Core lo nombra. +/// Severidad del hallazgo, o null si la señal no la lleva. +/// Texto para la persona que lee la decisión. Nunca se interpreta. +public sealed record CoreSignalObservation(string Signal, string? Severity, string Detail); + +/// +/// Qué se decidió sobre UNA señal, y por qué. Es la traza que el criterio 4 pide: sin la regla +/// aplicada al lado, «bloqueó» es una afirmación que nadie puede auditar después. +/// +public sealed record CoreSignalOutcome( + string Signal, + string? Severity, + string Disposition, + bool Blocks, + string Detail, + /// La regla que decidió, o null si decidió el default. + CoreSignalRule? AppliedRule, + string Reason); + +/// +/// El resultado completo, con la CONFIGURACIÓN APLICADA dentro. +/// +/// Guardar sólo el veredicto dejaría la decisión sin auditar: la matriz de un tenant cambia +/// con el tiempo, y sin la copia de las reglas vigentes en ese instante nadie puede reconstruir +/// por qué una fase avanzó. Por eso viaja con el resultado y no se +/// consulta por referencia. +/// +public sealed record CoreSignalEvaluationOutcome( + IReadOnlyList Results, + IReadOnlyList AppliedRules, + DateTime EvaluatedAtUtc) +{ + public IReadOnlyList Blocking => [.. Results.Where(r => r.Blocks)]; + + /// Lo que se informó sin detener nada. Es la otra mitad que el criterio 3 pide mostrar. + public IReadOnlyList AdvisoryOnly => + [.. Results.Where(r => !r.Blocks && r.Disposition == CoreSignalDisposition.Advisory)]; +} + +/// +/// CP-06 — convierte señales del Core en decisión de compuerta según la matriz del TENANT. +/// +/// Es puro y no decide nada por su cuenta. No conoce fases, ni el Core, ni el +/// repositorio: recibe observaciones y reglas y devuelve disposiciones. Todo lo que no esté en +/// las reglas cae en , que es advisory. +/// +public static class CoreSignalEvaluator +{ + public static CoreSignalEvaluationOutcome Evaluate( + IReadOnlyList observations, + IReadOnlyList rules, + DateTime evaluatedAtUtc) + { + var reglas = rules ?? []; + var resultados = (observations ?? []) + .Select(o => Decide(o, reglas)) + .ToList(); + + return new CoreSignalEvaluationOutcome(resultados, [.. reglas], evaluatedAtUtc); + } + + private static CoreSignalOutcome Decide( + CoreSignalObservation observacion, + IReadOnlyList reglas) + { + // Entre varias reglas para la misma señal gana la MÁS ESPECÍFICA — la que exige una + // severidad mínima por encima de la que no exige ninguna. Un orden por posición dejaría + // el resultado a merced de cómo el tenant ordenó su lista, que no es una decisión que + // nadie haya tomado conscientemente. + var candidatas = reglas + .Where(r => r.AppliesTo(observacion.Signal)) + .Where(r => Alcanza(observacion.Severity, r.MinSeverity)) + .OrderByDescending(r => r.MinSeverity is null ? -1 : CoreSignalSeverity.Rank(r.MinSeverity)) + .ToList(); + + var regla = candidatas.FirstOrDefault(); + + if (regla is null) + { + return new CoreSignalOutcome( + observacion.Signal, + observacion.Severity, + CoreSignalDisposition.Default, + Blocks: false, + observacion.Detail, + AppliedRule: null, + Reason: "Sin regla del tenant para esta señal: se informa y no bloquea."); + } + + var bloquea = regla.Disposition == CoreSignalDisposition.Blocking; + return new CoreSignalOutcome( + observacion.Signal, + observacion.Severity, + regla.Disposition, + bloquea, + observacion.Detail, + regla, + Reason: regla.MinSeverity is null + ? $"Regla del tenant para «{regla.Signal}»: {regla.Disposition}." + : $"Regla del tenant para «{regla.Signal}» desde severidad {regla.MinSeverity}: {regla.Disposition}."); + } + + /// + /// Si la severidad observada alcanza el mínimo de la regla. + /// + /// Los dos casos raros se resuelven hacia NO aplicar la regla, y por la misma razón en + /// ambos: una regla que no se aplica deja la señal en advisory, que es recuperable. + /// Una señal SIN severidad no puede satisfacer un mínimo — afirmarlo inventaría un dato que + /// el Core no mandó. Una severidad DESCONOCIDA tampoco se coacciona: subirla fabricaría + /// bloqueos y bajarla escondería hallazgos, así que se deja fuera de las reglas con mínimo y + /// sigue viéndose por el camino del default. + /// + private static bool Alcanza(string? observada, string? minima) + { + if (minima is null) return true; + if (observada is null) return false; + + var rangoObservado = CoreSignalSeverity.Rank(observada); + var rangoMinimo = CoreSignalSeverity.Rank(minima); + if (rangoObservado < 0 || rangoMinimo < 0) return false; + + return rangoObservado >= rangoMinimo; + } +} diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/CoreSignalPolicy.cs b/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/CoreSignalPolicy.cs new file mode 100644 index 00000000..a909b811 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/CoreSignalPolicy.cs @@ -0,0 +1,113 @@ +namespace Tracker.Domain.Governance.GatePolicy; + +/// +/// CP-06 — qué hace el Tracker con cada señal que el Core emite. +/// +/// El reparto de autoridad. El Core define el estándar y produce inteligencia; el +/// Tracker decide el avance de fase según la configuración del TENANT. Sin esta matriz la +/// frontera estaba borrosa: una señal técnica opcional podía bloquear indebidamente, o una +/// obligación contractual del tenant quedarse en simple recomendación. +/// +public static class CoreSignalDisposition +{ + /// Impide el avance de fase. Es la ÚNICA disposición que bloquea. + public const string Blocking = "blocking"; + + /// Se informa y se registra; nunca detiene la fase. + public const string Advisory = "advisory"; + + /// Se registra pero no se muestra en la superficie de decisión. + public const string Hidden = "hidden"; + + /// El tenant declara que esta señal no aplica a esta fase. Se registra el descarte. + public const string NotApplicable = "not-applicable"; + + public static readonly string[] All = [Blocking, Advisory, Hidden, NotApplicable]; + + public static bool IsValid(string? disposition) => disposition is not null && All.Contains(disposition); + + /// + /// LO QUE PASA CUANDO NADIE CONFIGURÓ NADA, y es la propiedad de seguridad de esta ficha. + /// + /// Una señal sin regla es advisory: se ve, se registra y no bloquea. El default + /// contrario convertiría cada dimensión nueva que el Core publique en un bloqueo que ningún + /// tenant pidió — el producto empezaría a opinar por su cuenta, que es justo lo que T-056 + /// prohíbe. Equivocarse hacia «no bloquea» es recuperable leyendo el informe; equivocarse + /// hacia «bloquea» para una fase en curso, no. + /// + public const string Default = Advisory; +} + +/// +/// Una regla de la matriz: qué señal, con qué severidad mínima, y qué hace el Tracker con ella. +/// +/// Signal es el nombre del result kind o de la dimensión tal y como el Core la +/// publica (gate, design, phaseArtifacts, qualitySignals, +/// architecture, …). Es una CADENA ABIERTA a propósito: el vocabulario del Core crece, y +/// un enum obligaría a desplegar el Tracker para poder nombrar una señal nueva. +/// +/// MinSeverity ausente significa «cualquier severidad». Presente, la regla sólo +/// aplica a hallazgos de esa severidad o superior, que es como un tenant dice «los `error` de +/// arquitectura me bloquean, los `warning` no». +/// +public sealed record CoreSignalRule( + string Signal, + string Disposition, + string? MinSeverity = null) +{ + public bool AppliesTo(string signal) => + string.Equals(Signal, signal, StringComparison.OrdinalIgnoreCase); +} + +/// Severidades ordenadas. El orden es lo que hace comparable un `MinSeverity`. +public static class CoreSignalSeverity +{ + public const string Info = "info"; + public const string Warning = "warning"; + public const string Error = "error"; + + private static readonly string[] Ranked = [Info, Warning, Error]; + + public static bool IsValid(string? severity) => + severity is not null && Ranked.Contains(severity, StringComparer.OrdinalIgnoreCase); + + /// + /// Posición en la escala, o -1 si no se reconoce. + /// + /// Una severidad desconocida NO se coacciona a la más alta ni a la más baja. Subirla + /// inventaría bloqueos; bajarla escondería hallazgos. Se trata aparte, en el evaluador. + /// + public static int Rank(string? severity) => + severity is null + ? -1 + : Array.FindIndex(Ranked, s => string.Equals(s, severity, StringComparison.OrdinalIgnoreCase)); +} + +/// +/// CP-02 criterio 2 — qué hace ESTE tenant con un artefacto que el Core declara para la fase. +/// +/// Es un overlay, y el estándar base no se toca. `IPhaseArtifactProfileSource` sigue +/// siendo tenant-agnóstico: sirve lo que el Core dice. La opinión del tenant se aplica encima, al +/// servir, así que «sin modificar el estándar base» es cierto en el código y no sólo en la ficha — +/// nadie puede editar el catálogo del Core desde la configuración de un tenant. +/// +public static class ArtifactApplicability +{ + /// El tenant lo exige, diga lo que diga el Core. + public const string Required = "required"; + + /// Cuenta para completitud y no se exige. + public const string Optional = "optional"; + + /// El tenant declara que no aplica a esta fase. Se sirve marcado, no se borra. + public const string NotApplicable = "not-applicable"; + + public static readonly string[] All = [Required, Optional, NotApplicable]; + + public static bool IsValid(string? value) => value is not null && All.Contains(value); +} + +/// +/// Una fila del overlay: qué artefacto del catálogo del Core y qué dice este tenant de él. +/// +public sealed record ArtifactApplicabilityRule(string ArtifactKind, string Applicability); diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/GatePolicy.cs b/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/GatePolicy.cs index 1835088d..39d961b8 100644 --- a/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/GatePolicy.cs +++ b/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/GatePolicy.cs @@ -27,6 +27,8 @@ private GatePolicy(GatePolicyProps props) : base(props) { } /// public ExceptionPolicy ExceptionPolicy => Props.ExceptionPolicy; public bool RequiresCoreVerdict => Props.RequiresCoreVerdict; + public IReadOnlyList CoreSignalRules => Props.CoreSignalRules; + public IReadOnlyList ArtifactApplicability => Props.ArtifactApplicability; public bool Active => Props.Active; public IReadOnlyList EvaluationCriteria => Props.EvaluationCriteria; @@ -40,7 +42,9 @@ public static Result Create( string createdBy, bool active = true, IReadOnlyList? evaluationCriteria = null, - ExceptionPolicy? exceptionPolicy = null) + ExceptionPolicy? exceptionPolicy = null, + IReadOnlyList? coreSignalRules = null, + IReadOnlyList? artifactApplicability = null) { if (tenantId == Guid.Empty) return Result.Failure("GatePolicy.TenantRequired"); @@ -53,6 +57,14 @@ public static Result Create( if (!ApprovalStages.IsValid(approvalPolicy.Stages)) return Result.Failure("GatePolicy.InvalidStages"); + var applicabilityValidation = ValidateArtifactApplicability(artifactApplicability); + if (applicabilityValidation.IsFailure) + return Result.Failure(applicabilityValidation.Error); + + var signalValidation = ValidateCoreSignalRules(coreSignalRules); + if (signalValidation.IsFailure) + return Result.Failure(signalValidation.Error); + var criteriaValidation = ValidateCriteria(evaluationCriteria); if (criteriaValidation.IsFailure) return Result.Failure(criteriaValidation.Error); @@ -71,6 +83,8 @@ public static Result Create( // Sin politica explicita se queda vacia: no se hereda la lista de aprobadores, porque // eso reproduciria exactamente el problema que CD-21 cierra. ExceptionPolicy = exceptionPolicy ?? ExceptionPolicy.Empty(), + CoreSignalRules = coreSignalRules?.ToList() ?? new List(), + ArtifactApplicability = artifactApplicability?.ToList() ?? new List(), Audit = new AuditProps(createdBy, DateTime.UtcNow) }; @@ -86,7 +100,9 @@ public Result Update( bool active, string updatedBy, IReadOnlyList? evaluationCriteria = null, - ExceptionPolicy? exceptionPolicy = null) + ExceptionPolicy? exceptionPolicy = null, + IReadOnlyList? coreSignalRules = null, + IReadOnlyList? artifactApplicability = null) { if (!GateMode.IsValid(mode)) return Result.Failure("GatePolicy.InvalidMode"); @@ -95,6 +111,14 @@ public Result Update( if (!ApprovalStages.IsValid(approvalPolicy.Stages)) return Result.Failure("GatePolicy.InvalidStages"); + var applicabilityValidation = ValidateArtifactApplicability(artifactApplicability); + if (applicabilityValidation.IsFailure) + return Result.Failure(applicabilityValidation.Error); + + var signalValidation = ValidateCoreSignalRules(coreSignalRules); + if (signalValidation.IsFailure) + return Result.Failure(signalValidation.Error); + var criteriaValidation = ValidateCriteria(evaluationCriteria); if (criteriaValidation.IsFailure) return criteriaValidation; @@ -108,12 +132,54 @@ public Result Update( Active = active, EvaluationCriteria = evaluationCriteria?.ToList() ?? new List(), ExceptionPolicy = exceptionPolicy ?? ExceptionPolicy.Empty(), + CoreSignalRules = coreSignalRules?.ToList() ?? new List(), + ArtifactApplicability = artifactApplicability?.ToList() ?? new List(), Audit = Props.Audit.WithUpdatedBy(updatedBy) }); TrackingState.MarkAsDirty(); return Result.Success(); } + /// + /// CP-06 — una regla con una disposición o severidad que nadie reconoce NO se almacena. + /// + /// Guardarla y luego ignorarla en el evaluador daría un tenant que cree haber + /// configurado un bloqueo y una compuerta que no lo aplica: la configuración diría una cosa y + /// la decisión otra, sin que nada lo señalara. Fallar aquí es ruidoso y por eso correcto. + /// + /// + /// CP-02 — una aplicabilidad que nadie reconoce NO se almacena, por la misma razón que en la + /// matriz de señales: guardarla e ignorarla luego daría un tenant que cree haber marcado un + /// artefacto como no aplicable y un catálogo que se lo sigue exigiendo. + /// + private static Result ValidateArtifactApplicability(IReadOnlyList? rules) + { + foreach (var rule in rules ?? []) + { + if (string.IsNullOrWhiteSpace(rule.ArtifactKind)) + return Result.Failure("GatePolicy.ArtifactKindRequired"); + if (!Governance.GatePolicy.ArtifactApplicability.IsValid(rule.Applicability)) + return Result.Failure("GatePolicy.InvalidArtifactApplicability"); + } + + return Result.Success(); + } + + private static Result ValidateCoreSignalRules(IReadOnlyList? rules) + { + foreach (var rule in rules ?? []) + { + if (string.IsNullOrWhiteSpace(rule.Signal)) + return Result.Failure("GatePolicy.CoreSignalRequired"); + if (!CoreSignalDisposition.IsValid(rule.Disposition)) + return Result.Failure("GatePolicy.InvalidCoreSignalDisposition"); + if (rule.MinSeverity is not null && !CoreSignalSeverity.IsValid(rule.MinSeverity)) + return Result.Failure("GatePolicy.InvalidCoreSignalSeverity"); + } + + return Result.Success(); + } + private static Result ValidateCriteria(IReadOnlyList? criteria) { if (criteria is null) diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/GatePolicyProps.cs b/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/GatePolicyProps.cs index bcd8df52..063ca0a3 100644 --- a/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/GatePolicyProps.cs +++ b/src/apps/tracker-api/Tracker.Domain/Governance/GatePolicy/GatePolicyProps.cs @@ -27,11 +27,28 @@ public sealed record GatePolicyProps : IProps /// public List EvaluationCriteria { get; init; } = new(); + /// + /// CP-06 — qué hace este tenant con cada señal del Core en ESTA fase. + /// + /// Vacía significa «nada configurado», y entonces todo es advisory por defecto. No + /// significa «nada bloquea porque el tenant lo decidió»: la diferencia importa cuando alguien + /// audita por qué una fase avanzó. + /// + public List CoreSignalRules { get; init; } = new(); + + /// + /// CP-02 criterio 2 — overlay del tenant sobre el catálogo de artefactos del Core en ESTA fase. + /// Vacío = se sirve el estándar tal cual. + /// + public List ArtifactApplicability { get; init; } = new(); + /// Copy hook required by the Core shared-kernel IProps : ICloneable. Explicit /// interface impl because records cannot declare a member literally named Clone (CS8859). object ICloneable.Clone() => this with { RequiredEvidence = new List(RequiredEvidence), - EvaluationCriteria = new List(EvaluationCriteria) + EvaluationCriteria = new List(EvaluationCriteria), + CoreSignalRules = new List(CoreSignalRules), + ArtifactApplicability = new List(ArtifactApplicability) }; } diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/GateSubmission/GateSubmission.cs b/src/apps/tracker-api/Tracker.Domain/Governance/GateSubmission/GateSubmission.cs index 12b1249b..d1882184 100644 --- a/src/apps/tracker-api/Tracker.Domain/Governance/GateSubmission/GateSubmission.cs +++ b/src/apps/tracker-api/Tracker.Domain/Governance/GateSubmission/GateSubmission.cs @@ -25,6 +25,12 @@ private GateSubmission(GateSubmissionProps props) : base(props) { } public IReadOnlyList Approvals => Props.Approvals; public CoreVerdict? CoreVerdict => Props.CoreVerdict; public bool RequiresCoreVerdict => Props.RequiresCoreVerdict; + + /// CP-06 — qué se decidió sobre cada señal del Core en esta entrega. + public IReadOnlyList CoreSignalEvaluation => Props.CoreSignalEvaluation; + + /// CP-06 — las reglas del tenant vigentes cuando se decidió. + public IReadOnlyList AppliedCoreSignalRules => Props.AppliedCoreSignalRules; public string State => Props.State; public DateTime? DecidedAtUtc => Props.DecidedAtUtc; public IReadOnlyList CriteriaEvaluation => Props.CriteriaEvaluation; @@ -227,22 +233,38 @@ public Result RecordCoreVerdict( /// persisted. When null, the gate is decided on evidence + approvals + verdict only (a gate /// with no configured criteria behaves exactly as before). /// - public Result> Evaluate(CriteriaEvaluationOutcome? criteria = null) + /// + /// CP-06 — la matriz de señales del Core del TENANT, ya evaluada. Cuando se suministra, sus + /// disposiciones `blocking` se pliegan en las razones de incumplimiento y la traza completa + /// —con las reglas vigentes en ese instante— se persiste. Cuando es `null` la compuerta se + /// decide exactamente como antes: una compuerta sin matriz configurada no cambia de conducta. + /// + public Result> Evaluate( + CriteriaEvaluationOutcome? criteria = null, + CoreSignalEvaluationOutcome? coreSignals = null) { var criteriaResults = criteria is not null ? criteria.Results.ToList() : Props.CriteriaEvaluation; var criteriaUnmet = CriteriaUnmet(criteria); + var signalResults = coreSignals is not null + ? coreSignals.Results.ToList() + : Props.CoreSignalEvaluation; + var signalUnmet = CoreSignalsUnmet(coreSignals); + var appliedRules = coreSignals is not null + ? coreSignals.AppliedRules.ToList() + : Props.AppliedCoreSignalRules; if (IsTerminal()) { // Already decided (e.g. an approval rejection). Report the standing reasons. return Result>.Success( - UnmetReasons().Concat(criteriaUnmet).ToList()); + UnmetReasons().Concat(criteriaUnmet).Concat(signalUnmet).ToList()); } var unmet = UnmetReasons(); unmet.AddRange(criteriaUnmet); + unmet.AddRange(signalUnmet); if (Props.Approvals.Any(a => a.Status == ApprovalStepStatus.Rejected)) { @@ -250,7 +272,9 @@ public Result> Evaluate(CriteriaEvaluationOutcome? criteri { State = GateSubmissionState.Rejected, DecidedAtUtc = DateTime.UtcNow, - CriteriaEvaluation = criteriaResults + CriteriaEvaluation = criteriaResults, + CoreSignalEvaluation = signalResults, + AppliedCoreSignalRules = appliedRules }); TrackingState.MarkAsDirty(); return Result>.Success(unmet); @@ -262,7 +286,9 @@ public Result> Evaluate(CriteriaEvaluationOutcome? criteri { State = GateSubmissionState.Approved, DecidedAtUtc = DateTime.UtcNow, - CriteriaEvaluation = criteriaResults + CriteriaEvaluation = criteriaResults, + CoreSignalEvaluation = signalResults, + AppliedCoreSignalRules = appliedRules }); TrackingState.MarkAsDirty(); return Result>.Success(unmet); @@ -274,12 +300,26 @@ public Result> Evaluate(CriteriaEvaluationOutcome? criteri { State = GateSubmissionState.Returned, DecidedAtUtc = null, - CriteriaEvaluation = criteriaResults + CriteriaEvaluation = criteriaResults, + CoreSignalEvaluation = signalResults, + AppliedCoreSignalRules = appliedRules }); TrackingState.MarkAsDirty(); return Result>.Success(unmet); } + /// + /// CP-06 — sólo las disposiciones `blocking` se convierten en razón de incumplimiento. + /// + /// `advisory`, `hidden` y `not-applicable` se registran en la traza y NO detienen la + /// fase. Que una señal aparezca en el informe y que impida avanzar son dos hechos distintos, + /// y fundirlos es exactamente el defecto que esta ficha describe. + /// + private static List CoreSignalsUnmet(CoreSignalEvaluationOutcome? signals) + => signals is null + ? [] + : [.. signals.Blocking.Select(r => $"core-signal:{r.Signal}")]; + /// Blocking criteria folded into unmet reasons: unconfigured or blocking results. private static List CriteriaUnmet(CriteriaEvaluationOutcome? criteria) { diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/GateSubmission/GateSubmissionProps.cs b/src/apps/tracker-api/Tracker.Domain/Governance/GateSubmission/GateSubmissionProps.cs index 21fd4b7b..40bb29c4 100644 --- a/src/apps/tracker-api/Tracker.Domain/Governance/GateSubmission/GateSubmissionProps.cs +++ b/src/apps/tracker-api/Tracker.Domain/Governance/GateSubmission/GateSubmissionProps.cs @@ -16,6 +16,18 @@ public sealed record GateSubmissionProps : IProps public List Approvals { get; init; } = new(); public CoreVerdict? CoreVerdict { get; init; } public bool RequiresCoreVerdict { get; init; } + + /// CP-06 — traza por señal del Core: qué se decidió sobre cada una y por qué. + public List CoreSignalEvaluation { get; init; } = new(); + + /// + /// CP-06 — LAS REGLAS VIGENTES EN EL INSTANTE DE LA DECISIÓN, copiadas y no referenciadas. + /// + /// La matriz de un tenant cambia con el tiempo. Sin esta copia, reconstruir por qué una + /// fase avanzó exigiría creer que la configuración de hoy es la de entonces, que es + /// exactamente lo que una auditoría no puede asumir. + /// + public List AppliedCoreSignalRules { get; init; } = new(); public string State { get; init; } = GateSubmissionState.Draft; public DateTime? DecidedAtUtc { get; init; } @@ -31,6 +43,8 @@ object ICloneable.Clone() => this with { Evidence = new List(Evidence), Approvals = new List(Approvals), - CriteriaEvaluation = new List(CriteriaEvaluation) + CriteriaEvaluation = new List(CriteriaEvaluation), + CoreSignalEvaluation = new List(CoreSignalEvaluation), + AppliedCoreSignalRules = new List(AppliedCoreSignalRules) }; } diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs b/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs new file mode 100644 index 00000000..fbf408d9 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs @@ -0,0 +1,141 @@ +namespace Tracker.Domain.Governance.GovernancePackage; + +/// +/// GT-532 — un PAQUETE DE GOBERNANZA: el conjunto de decisiones de gobierno de un tenant, con +/// nombre y versión, portable como documento. +/// +/// El problema que resuelve, en las palabras de la ficha: «la gobernanza no se puede +/// empaquetar por cliente». Hoy un tenant la configura pieza a pieza —una `GatePolicy` por fase, +/// un `ArtifactFieldSchema` por tipo de artefacto— y no hay forma de tomar esa configuración +/// entera, versionarla, moverla a otro tenant o revisarla como una unidad. Eso convierte «damos de +/// alta un cliente con nuestro estándar de gobierno» en un trabajo manual que nadie puede auditar. +/// +/// ES UN DOCUMENTO, NO UN AGREGADO PERSISTIDO, y la decisión es deliberada. Un paquete +/// no tiene ciclo de vida propio: se exporta desde un tenant y se aplica sobre otro. Guardarlo en +/// una tabla añadiría una tercera copia de unos datos que ya viven en `GatePolicy` y +/// `ArtifactFieldSchema` —y este repositorio ha pasado la semana arreglando exactamente eso: dos +/// copias de un catálogo que divergen sin que nada lo note. Sin tabla no hay migración, no hay +/// deriva posible y el paquete es portable por construcción. +/// +/// NO LLEVA `TenantId` DENTRO, y esto no es un olvido. Un paquete que arrastrara el +/// tenant de origen al destino sería precisamente la fuga de aislamiento que el producto prohíbe. +/// La procedencia se conserva como texto informativo (), nunca como una +/// clave que alguien pueda usar para escribir en otro sitio. El destino de una aplicación es +/// SIEMPRE el tenant de la sesión que la ejecuta. +/// +/// Lo que este tipo NO hace: traer contenido. El producto entrega el MECANISMO de +/// empaquetar; no publica paquetes propios con nuestras opiniones sobre qué debe exigir una +/// compuerta. Según `T-056` la validación de contenido es configuración del tenant y no código del +/// motor, así que un «paquete ISO 27001» de fábrica sería exactamente la capa que ese ADR rechaza. +/// El repositorio no contiene ni debe contener paquetes de ejemplo aplicables. +/// +public sealed record GovernancePackage +{ + /// Nombre legible del paquete. Identidad para una persona, nunca para una máquina. + public required string Name { get; init; } + + /// + /// Versión del paquete, decidida por quien lo exporta. No se valida como semver a propósito: + /// imponer un esquema de versionado a la configuración de un cliente es exactamente el tipo de + /// opinión que `T-056` deja fuera del motor. + /// + public required string Version { get; init; } + + public string Description { get; init; } = string.Empty; + + /// + /// De dónde salió, como TEXTO informativo. Nunca un identificador utilizable: ver la nota sobre + /// aislamiento en la documentación del tipo. + /// + public string ExportedFrom { get; init; } = string.Empty; + + public DateTime ExportedAtUtc { get; init; } + + public IReadOnlyList GatePolicies { get; init; } = new List(); + + public IReadOnlyList ArtifactFieldSchemas { get; init; } = + new List(); + + /// + /// Un paquete vacío no es un paquete. Aplicarlo no haría nada y el informe diría «0 cambios», + /// que es indistinguible de un error de exportación — la misma confusión entre «no había nada» + /// y «no se leyó nada» que este repositorio persigue en sus guardas. + /// + public bool IsEmpty => GatePolicies.Count == 0 && ArtifactFieldSchemas.Count == 0; + + public IReadOnlyList Validate() + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(Name)) errors.Add("GovernancePackage.NameRequired"); + if (string.IsNullOrWhiteSpace(Version)) errors.Add("GovernancePackage.VersionRequired"); + if (IsEmpty) errors.Add("GovernancePackage.Empty"); + + // Dos políticas para la misma fase harían que el resultado dependiera del orden de + // aplicación, que es una forma silenciosa de no ser determinista. + var fases = GatePolicies.Select(p => p.Phase).ToList(); + foreach (var dup in fases.GroupBy(f => f).Where(g => g.Count() > 1).Select(g => g.Key)) + { + errors.Add($"GovernancePackage.DuplicatePhase:{dup}"); + } + + var tipos = ArtifactFieldSchemas.Select(s => s.ArtifactType).ToList(); + foreach (var dup in tipos.GroupBy(t => t).Where(g => g.Count() > 1).Select(g => g.Key)) + { + errors.Add($"GovernancePackage.DuplicateArtifactType:{dup}"); + } + + foreach (var p in GatePolicies.Where(p => string.IsNullOrWhiteSpace(p.Phase))) + { + errors.Add("GovernancePackage.PolicyPhaseRequired"); + } + + foreach (var s in ArtifactFieldSchemas.Where(s => string.IsNullOrWhiteSpace(s.ArtifactType))) + { + errors.Add("GovernancePackage.SchemaArtifactTypeRequired"); + } + + return errors; + } +} + +/// Una política de compuerta dentro de un paquete, sin tenant y sin identidad de fila. +public sealed record PackagedGatePolicy +{ + public required string Phase { get; init; } + public required string Mode { get; init; } + public IReadOnlyList RequiredEvidence { get; init; } = new List(); + public string ApprovalStrategy { get; init; } = string.Empty; + public string ApprovalStages { get; init; } = string.Empty; + public IReadOnlyList Approvers { get; init; } = new List(); + public bool RequiresCoreVerdict { get; init; } + public bool Active { get; init; } = true; + public IReadOnlyList EvaluationCriteria { get; init; } = new List(); +} + +public sealed record PackagedEvidence(string Type, bool Mandatory); + +public sealed record PackagedApprover(string RoleOrIdentity, int Stage, int Quorum); + +public sealed record PackagedCriterion( + string Id, + string Label, + string ArtifactType, + string FieldPath, + string Operator, + IReadOnlyList Expected, + bool Mandatory, + string Severity); + +public sealed record PackagedArtifactFieldSchema +{ + public required string ArtifactType { get; init; } + public IReadOnlyList CustomFields { get; init; } = new List(); +} + +public sealed record PackagedCustomField( + string Name, + string Label, + string DataType, + IReadOnlyList EligibleValues, + bool Required); diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationDeposit.cs b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationDeposit.cs index cdb0d5c0..d834f23b 100644 Binary files a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationDeposit.cs and b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationDeposit.cs differ diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransaction.cs b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransaction.cs index 935595d5..a4f235d0 100644 --- a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransaction.cs +++ b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransaction.cs @@ -222,6 +222,76 @@ private static string ProducerActor(CoreEvaluationDeposit deposit) ? $"core:{deposit.ProducerSurface}:{r.ActorId}" : $"core:{deposit.ProducerSurface}"; + /// CP-07 — dónde cuelga este depósito, o `null` si no se ha adjuntado. + public CoreEvaluationAttachment? Attachment => Props.Attachment; + + /// + /// CP-07 — adjunta este depósito a una iniciativa y fase del expediente SDLC. + /// + /// NO copia ni reinterpreta el veredicto. `OverallVerdict`, `Violations` y todo + /// lo demás quedan exactamente como llegaron: adjuntar afirma DÓNDE encaja una evaluación, no + /// qué dice. Un método que además ajustara el veredicto convertiría un vínculo en una segunda + /// opinión, y el ledger dejaría de ser lo que el productor depositó. + /// + /// El tenant no se pasa: se comprueba. Adjuntar es la operación que cruza dos + /// agregados, así que es exactamente donde se colaría una fuga entre clientes. El llamante + /// declara de qué tenant es la iniciativa y aquí se contrasta contra el de la fila; si no + /// coinciden, falla en vez de adjuntar. + /// + public Result AttachTo( + Guid tenantId, + Guid initiativeId, + string phase, + string attachedBy, + string? artifactKind = null, + Guid? gateSubmissionId = null, + int? artifactVersion = null) + { + if (tenantId != Props.TenantId) + return Result.Failure("CoreEvaluationTransaction.TenantMismatch"); + if (initiativeId == Guid.Empty) + return Result.Failure("CoreEvaluationTransaction.InitiativeRequired"); + if (string.IsNullOrWhiteSpace(phase)) + return Result.Failure("CoreEvaluationTransaction.PhaseRequired"); + if (string.IsNullOrWhiteSpace(attachedBy)) + return Result.Failure("CoreEvaluationTransaction.ActorRequired"); + + SetProps(Props with + { + Attachment = new CoreEvaluationAttachment + { + InitiativeId = initiativeId, + Phase = phase.Trim(), + // Vacío se guarda como AUSENTE, no como cadena vacía: «no se dijo de qué artefacto + // habla» y «habla del artefacto llamado ""» no son lo mismo. + ArtifactKind = string.IsNullOrWhiteSpace(artifactKind) ? null : artifactKind.Trim(), + // Una versión sólo tiene sentido junto al artefacto del que es versión. Sola + // afirmaría «la versión 3» sin decir de qué, que no es una afirmación. + ArtifactVersion = string.IsNullOrWhiteSpace(artifactKind) ? null : artifactVersion, + GateSubmissionId = gateSubmissionId == Guid.Empty ? null : gateSubmissionId, + AttachedBy = attachedBy, + AttachedAtUtc = DateTime.UtcNow, + }, + }); + TrackingState.MarkAsDirty(); + return Result.Success(); + } + + /// + /// CP-07 — deshace el vínculo. Adjuntar mal es un error humano corriente y tiene que ser + /// reversible; lo que NO se puede deshacer es el depósito en sí, que es el registro de lo que + /// el Core dijo. + /// + public Result Detach(Guid tenantId) + { + if (tenantId != Props.TenantId) + return Result.Failure("CoreEvaluationTransaction.TenantMismatch"); + + SetProps(Props with { Attachment = null }); + TrackingState.MarkAsDirty(); + return Result.Success(); + } + public Result Complete(Dictionary responseData) { if (Props.Status != "pending") @@ -237,6 +307,60 @@ public Result Complete(Dictionary responseData) return Result.Success(); } + /// + /// CP-05 — completa una evaluación PEDIDA POR EL TRACKER conservando además los hechos + /// canónicos que el Core devolvió. + /// + /// Qué arregla. Las columnas correlation_id, schema_version, + /// overall_verdict, outcome, rules_executed, evaluated_at y + /// versions YA EXISTÍAN, pero sólo las llenaba el camino de ingesta: una fila nacida en + /// el gateway las dejaba vacías aunque la respuesta del Core las trajera. Una columna que + /// existe y está vacía para media población es peor que no tenerla, porque quien consulta no + /// distingue «no lo hubo» de «este camino no lo guardó». + /// + /// Por qué no se reutiliza . Es la forma de la + /// INGESTA: exige Violations con huella y dueño responsable. El gateway no los recibe, y + /// rellenarlos aquí sería justo lo que esa forma prohíbe por escrito — inventar una acusación. + /// Se registra lo que este camino de verdad tiene, y nada más. + /// + /// producer_surface queda marcado, así que una fila del gateway y una depositada + /// por la CLI son distinguibles en la propia tabla en vez de por conjetura. + /// + public Result CompleteFromCore( + Dictionary responseData, + string producerSurface, + string? correlationId, + string? schemaVersion, + string? overallVerdict, + string? outcome, + DateTime? evaluatedAtUtc, + IReadOnlyList rulesExecuted, + IReadOnlyDictionary versions) + { + if (Props.Status != "pending") + return Result.Failure("CoreEvaluationTransaction.NotPending"); + + SetProps(Props with + { + Status = "completed", + CompletedAtUtc = DateTime.UtcNow, + ResponseData = responseData, + ProducerSurface = producerSurface, + // Ausente se guarda como ausente. Un `??` con un valor de relleno haría indistinguible + // «el Core no lo mandó» de «el Core mandó esto», que es la confusión que estas + // columnas existen para evitar. + CorrelationId = correlationId ?? Props.CorrelationId, + SchemaVersion = schemaVersion, + OverallVerdict = overallVerdict, + Outcome = outcome, + EvaluatedAtUtc = evaluatedAtUtc, + RulesExecuted = [.. rulesExecuted], + Versions = new Dictionary(versions), + }); + TrackingState.MarkAsDirty(); + return Result.Success(); + } + public Result Fail(string errorCode, string errorMessage) { if (Props.Status != "pending") diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransactionProps.cs b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransactionProps.cs index 2c268b60..4018a1c8 100644 --- a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransactionProps.cs +++ b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransactionProps.cs @@ -45,6 +45,9 @@ public sealed record CoreEvaluationTransactionProps : IProps public int BlockingViolationCount { get; init; } public IReadOnlyDictionary Versions { get; init; } = new Dictionary(); + /// CP-07 — dónde cuelga este depósito del expediente SDLC. Ausente = sin adjuntar. + public CoreEvaluationAttachment? Attachment { get; init; } + /// Copy hook required by the Core shared-kernel IProps : ICloneable. Explicit /// interface impl because records cannot declare a member literally named Clone (CS8859). object ICloneable.Clone() => this with diff --git a/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/ArtifactChecksum.cs b/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/ArtifactChecksum.cs new file mode 100644 index 00000000..825def15 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/ArtifactChecksum.cs @@ -0,0 +1,23 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Tracker.Domain.Sdlc.PhaseArtifact; + +/// +/// CP-14 — la huella del CONTENIDO CANÓNICO de una versión. +/// +/// El cuerpo cuando lo hay, y si no la referencia. Se prefija con cuál de los dos se usó +/// para que un `ref` y un contenido con el mismo texto no produzcan la misma huella: son cosas +/// distintas y confundirlas diría que dos versiones llevan lo mismo cuando no. +/// +public static class ArtifactChecksum +{ + public static string Of(string? @ref, string? content) + { + var canonical = !string.IsNullOrWhiteSpace(content) + ? $"content:{content}" + : $"ref:{@ref ?? string.Empty}"; + + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); + } +} diff --git a/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifact.cs b/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifact.cs index f7992ebd..b9606086 100644 --- a/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifact.cs +++ b/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifact.cs @@ -57,12 +57,16 @@ public static Result CreateForPhase( /// Items are keyed by type + product code (case-insensitive) so a per-product evidence type can carry /// one item per product. /// + /// CP-14 — quién editó. Sin autor no hay trazabilidad y la versión no se crea. + /// CP-14 — por qué. Opcional: ausente es ausente, no una cadena vacía. public Result SetItem( string type, string? productCode, string? @ref, string? content, - string? status = null) + string? status = null, + string? authoredBy = null, + string? changeReason = null) { if (Props.Status == PhaseArtifactStatus.Submitted) return Result.Failure("PhaseArtifact.Submitted"); @@ -98,7 +102,119 @@ public Result SetItem( else updated.Add(candidate); - SetProps(Props with { Items = updated }); + var versions = NextVersions(candidate, authoredBy, changeReason); + + SetProps(Props with { Items = updated, Versions = versions }); + TrackingState.MarkAsDirty(); + return Result.Success(); + } + + /// CP-14 — la historia completa de este expediente. Nunca se borra una versión. + public IReadOnlyList Versions => Props.Versions; + + /// Las versiones de UN ítem, de la más antigua a la más reciente. + public IReadOnlyList VersionsOf(string type, string? productCode) => + [.. Props.Versions + .Where(v => string.Equals(v.Type, type, StringComparison.OrdinalIgnoreCase) + && string.Equals(NormalizeCode(v.ProductCode), NormalizeCode(productCode), StringComparison.OrdinalIgnoreCase)) + .OrderBy(v => v.Version)]; + + /// + /// CP-14 — la siguiente lista de versiones tras una edición. + /// + /// Sin autor no se versiona. Una versión sin autor no cumple el criterio que la + /// justifica —«autor, fecha y checksum»— y guardarla a medias daría una historia que parece + /// trazable y no lo es. El expediente se actualiza igual; lo que no ocurre es fabricar una + /// entrada de historia falsa. + /// + /// Un guardado que no cambia nada no crea versión. Se compara por checksum: pulsar + /// guardar dos veces con el mismo contenido llenaría la historia de ruido y haría más difícil + /// encontrar el cambio real, que es justo lo que la historia existe para permitir. + /// + /// Una versión APROBADA no se modifica en sitio (criterio 4): pasa a `superseded` + /// y la edición abre una nueva. Así queda registrado qué se aprobó exactamente, en vez de que + /// el contenido aprobado cambie bajo los pies de quien lo aprobó. + /// + private List NextVersions(ArtifactItem item, string? authoredBy, string? changeReason) + { + var versions = new List(Props.Versions); + if (string.IsNullOrWhiteSpace(authoredBy)) return versions; + + var checksum = ArtifactChecksum.Of(item.Ref, item.Content); + var mias = versions + .Where(v => string.Equals(v.Type, item.Type, StringComparison.OrdinalIgnoreCase) + && string.Equals(NormalizeCode(v.ProductCode), NormalizeCode(item.ProductCode), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var viva = mias.FirstOrDefault(v => v.Status == ArtifactVersionStatus.Current) + ?? mias.OrderByDescending(v => v.Version).FirstOrDefault(); + + if (viva is not null && viva.Checksum == checksum) return versions; + + for (var i = 0; i < versions.Count; i++) + { + if (mias.Contains(versions[i]) && versions[i].Status != ArtifactVersionStatus.Superseded) + versions[i] = versions[i] with { Status = ArtifactVersionStatus.Superseded }; + } + + versions.Add(new ArtifactVersion( + item.Type, + NormalizeCode(item.ProductCode), + (mias.Count == 0 ? 0 : mias.Max(v => v.Version)) + 1, + item.Ref, + item.Content, + checksum, + authoredBy!, + DateTime.UtcNow, + string.IsNullOrWhiteSpace(changeReason) ? null : changeReason, + ArtifactVersionStatus.Current)); + + return versions; + } + + /// + /// CP-14 criterio 2 — restaura una versión previa ABRIENDO UNA NUEVA con su contenido. + /// + /// No se reactiva la versión antigua ni se borra lo que había en medio: restaurar es un + /// cambio, y un cambio deja rastro. Reescribir la historia para que parezca que nunca se + /// editó es exactamente lo que un expediente auditable no puede hacer. + /// + public Result RestoreVersion(string type, string? productCode, int version, string restoredBy) + { + if (Props.Status == PhaseArtifactStatus.Submitted) + return Result.Failure("PhaseArtifact.Submitted"); + if (string.IsNullOrWhiteSpace(restoredBy)) + return Result.Failure("PhaseArtifact.ActorRequired"); + + var objetivo = VersionsOf(type, productCode).FirstOrDefault(v => v.Version == version); + if (objetivo is null) + return Result.Failure("PhaseArtifact.VersionNotFound"); + + return SetItem( + type, productCode, objetivo.Ref, objetivo.Content, + status: null, + authoredBy: restoredBy, + changeReason: $"Restaurada la versión {version}."); + } + + /// + /// CP-14 criterio 4 — marca como APROBADA la versión viva de un ítem. + /// + /// Una versión aprobada queda inmutable: la siguiente edición la pasa a `superseded` y + /// abre otra, en vez de cambiar lo que alguien aprobó. + /// + public Result ApproveCurrentVersion(string type, string? productCode) + { + var versions = new List(Props.Versions); + var indice = versions.FindIndex(v => + string.Equals(v.Type, type, StringComparison.OrdinalIgnoreCase) + && string.Equals(NormalizeCode(v.ProductCode), NormalizeCode(productCode), StringComparison.OrdinalIgnoreCase) + && v.Status == ArtifactVersionStatus.Current); + + if (indice < 0) return Result.Failure("PhaseArtifact.NoCurrentVersion"); + + versions[indice] = versions[indice] with { Status = ArtifactVersionStatus.Approved }; + SetProps(Props with { Versions = versions }); TrackingState.MarkAsDirty(); return Result.Success(); } diff --git a/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifactProps.cs b/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifactProps.cs index 02267f5c..54dbad87 100644 --- a/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifactProps.cs +++ b/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifactProps.cs @@ -14,10 +14,14 @@ public sealed record PhaseArtifactProps : IProps /// The evidence items authored into this artifact (jsonb collection). public List Items { get; init; } = new(); + /// CP-14 — historia completa de cada ítem. Nunca se borra una versión. + public List Versions { get; init; } = new(); + /// Copy hook required by the Core shared-kernel IProps : ICloneable. Explicit /// interface impl because records cannot declare a member literally named Clone (CS8859). object ICloneable.Clone() => this with { - Items = new List(Items) + Items = new List(Items), + Versions = new List(Versions) }; } diff --git a/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifactValues.cs b/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifactValues.cs index 0fd6a2e3..60399b60 100644 --- a/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifactValues.cs +++ b/src/apps/tracker-api/Tracker.Domain/Sdlc/PhaseArtifact/PhaseArtifactValues.cs @@ -40,3 +40,45 @@ public sealed record ArtifactItem( /// public DateTime? DeliveredAtUtc { get; init; } } + +/// +/// CP-14 — una versión de un artefacto SDLC. +/// +/// Inmutable por construcción. Una versión no se edita nunca: editar el artefacto +/// crea la siguiente. Es lo que hace que «qué versión se envió al Core» y «cuál se aprobó» sean +/// preguntas contestables — con edición en sitio, ambas respuestas cambian bajo los pies de quien +/// las hizo. +/// +/// Checksum es SHA-256 del CONTENIDO CANÓNICO: el cuerpo cuando lo hay, y si no la +/// referencia. Dos versiones con el mismo checksum llevan lo mismo, y eso es lo que permite decir +/// «esto es exactamente lo que se evaluó» sin volver a leer el documento. +/// +public sealed record ArtifactVersion( + string Type, + string? ProductCode, + int Version, + string? Ref, + string? Content, + string Checksum, + string AuthoredBy, + DateTime AuthoredAtUtc, + string? ChangeReason, + string Status); + +/// Estado de una versión dentro del ciclo del artefacto. +public static class ArtifactVersionStatus +{ + /// La versión viva: la que una edición sustituye. + public const string Current = "current"; + + /// Sustituida por una posterior. Se conserva entera; nunca se borra. + public const string Superseded = "superseded"; + + /// + /// Aprobada en una compuerta. NO se modifica en sitio: cualquier cambio abre una versión + /// nueva, y ésta pasa a `superseded` conservando el registro de qué se aprobó. + /// + public const string Approved = "approved"; + + public static bool IsValid(string? s) => s is Current or Superseded or Approved; +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Governance/CoreSyncPhaseArtifactProfileSource.cs b/src/apps/tracker-api/Tracker.Infrastructure/Governance/CoreSyncPhaseArtifactProfileSource.cs new file mode 100644 index 00000000..b850275a --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Governance/CoreSyncPhaseArtifactProfileSource.cs @@ -0,0 +1,185 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Tracker.Application.Governance.ArtifactFieldSchema; + +namespace Tracker.Infrastructure.Governance; + +/// +/// GAP-004 — the `core-sync` half of the seam, finally buildable. +/// +/// `StandInPhaseArtifactProfileSource` exists because the Core had nothing to consume: it +/// held two hand-maintained gate corpora plus a third slug vocabulary and no single answer to +/// endorse. `evolith_arch32` GT-650 finished the ADR-0125 migration on 2026-08-03 and published +/// `GET /api/v1/phases/artifacts`, so the mirror can stop being hand-made. +/// +/// IT CACHES, AND THAT IS NOT AN OPTIMISATION. `GetProfiles()` is synchronous and sits +/// on the evaluation path; making it await an HTTP call would put a network round trip — and a +/// network failure — inside a governance decision. The catalogue changes when the Core ships, not +/// per request, so it is fetched out of band and served from memory. +/// +/// A FAILED SYNC FALLS BACK AND SAYS SO. It never serves an empty catalogue: a phase +/// with zero required artifacts reads as «nothing is required here», which is the most dangerous +/// thing a governance surface can say by accident. When the Core is unreachable this serves the +/// stand-in and stamps it `core-standin`, so a consumer can tell a synced answer from a mirrored +/// one by reading the payload rather than by trusting that a sync happened. +/// +public sealed class CoreSyncPhaseArtifactProfileSource : IPhaseArtifactProfileSource +{ + /// + /// Core phase name → Tracker phase name. A WRITTEN DECISION, not a transformation: the Core + /// says `quality` and `deployment` where the Tracker says `qa` and `release`, and a mapper that + /// matched by string would drop both silently. `intake` exists in the Core vocabulary with no + /// required artifact, and the Tracker keeps its own `intake` phase, so it maps to itself. + /// + private static readonly IReadOnlyDictionary PhaseFromCore = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["intake"] = "intake", + ["discovery"] = "discovery", + ["design"] = "design", + ["construction"] = "construction", + ["quality"] = "qa", + ["deployment"] = "release", + }; + + private readonly IPhaseArtifactProfileSource _standIn; + private readonly ILogger _logger; + + private volatile IReadOnlyList? _synced; + + public CoreSyncPhaseArtifactProfileSource( + IPhaseArtifactProfileSource standIn, + ILogger logger) + { + _standIn = standIn; + _logger = logger; + } + + /// + /// `core-sync` only once a sync actually succeeded. Reporting it before then would be the + /// claim this field exists to make checkable. + /// + public string Source => + _synced is null + ? PhaseArtifactProfileSourceKind.StandIn + : PhaseArtifactProfileSourceKind.CoreSync; + + public IReadOnlyList GetProfiles() => _synced ?? _standIn.GetProfiles(); + + /// + /// Replaces the served profiles from a Core artifact-registry payload. Returns false and keeps + /// whatever was being served when the payload cannot be used — a bad sync must never be able to + /// empty the catalogue. + /// + public bool Apply(JsonElement registry) + { + try + { + if (!registry.TryGetProperty("artifacts", out var artifacts) + || artifacts.ValueKind != JsonValueKind.Array) + { + _logger.LogWarning( + "Core artifact registry has no `artifacts` array; keeping the previous profiles."); + return false; + } + + var byPhase = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var a in artifacts.EnumerateArray()) + { + var id = Text(a, "id"); + var label = Text(a, "label"); + if (string.IsNullOrWhiteSpace(id)) continue; + + // `binding` is what a gate requires; `advisory` counts towards completeness only. + // Mapping binding → Required keeps the Tracker's meaning of the word intact. + var required = string.Equals(Text(a, "classification"), "binding", StringComparison.OrdinalIgnoreCase); + var schema = CanonicalSchemaFrom(a); + + if (!a.TryGetProperty("phases", out var phases) || phases.ValueKind != JsonValueKind.Array) continue; + + foreach (var p in phases.EnumerateArray()) + { + var corePhase = p.GetString(); + if (corePhase is null || !PhaseFromCore.TryGetValue(corePhase, out var trackerPhase)) + { + // An unmapped phase is DROPPED and logged, never guessed at. Guessing would + // file an artifact under a phase nobody evaluates, where it is invisible. + _logger.LogWarning( + "Core phase {Phase} has no Tracker equivalent; artifact {Artifact} skipped for it.", + corePhase, id); + continue; + } + + if (!byPhase.TryGetValue(trackerPhase, out var bucket)) + { + bucket = new List(); + byPhase[trackerPhase] = bucket; + } + + bucket.Add(new PhaseArtifactDto + { + ArtifactKind = id, + Label = string.IsNullOrWhiteSpace(label) ? id : label, + Required = required, + CanonicalSchema = schema, + }); + } + } + + if (byPhase.Count == 0) + { + _logger.LogWarning( + "Core artifact registry produced zero phases; keeping the previous profiles. " + + "An empty catalogue would read as `nothing is required`."); + return false; + } + + _synced = PhaseArtifactCatalog.Phases + .Select(phase => new PhaseArtifactProfileDto + { + Phase = phase, + Source = PhaseArtifactProfileSourceKind.CoreSync, + Artifacts = byPhase.TryGetValue(phase, out var list) + ? list.OrderBy(x => x.ArtifactKind, StringComparer.Ordinal).ToList() + : new List(), + }) + .ToList(); + + _logger.LogInformation( + "Synced {Count} artifact(s) from the Core registry across {Phases} phase(s).", + byPhase.Values.Sum(v => v.Count), byPhase.Count); + return true; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + _logger.LogWarning(ex, "Could not read the Core artifact registry; keeping the previous profiles."); + return false; + } + } + + private static ArtifactCanonicalSchemaDto CanonicalSchemaFrom(JsonElement a) + { + var schemaId = Text(a, "schemaId"); + if (!string.IsNullOrWhiteSpace(schemaId)) + { + return new ArtifactCanonicalSchemaDto + { + SchemaId = schemaId, + Provenance = ArtifactSchemaProvenance.CoreSchema, + }; + } + + // `tool-output` and `none` are DIFFERENT absences and the Core says which: one is a + // deliberate decision, the other a gap. Collapsing them here would throw that away. + var toolOutput = a.TryGetProperty("producedBy", out var pb) && pb.ValueKind == JsonValueKind.Object; + return new ArtifactCanonicalSchemaDto + { + SchemaId = null, + Provenance = toolOutput ? ArtifactSchemaProvenance.ToolOutput : ArtifactSchemaProvenance.None, + }; + } + + private static string? Text(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803143404_AddCoreSignalMatrix.Designer.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803143404_AddCoreSignalMatrix.Designer.cs new file mode 100644 index 00000000..9a689148 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803143404_AddCoreSignalMatrix.Designer.cs @@ -0,0 +1,3948 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tracker.Infrastructure.Persistence; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + [DbContext(typeof(TrackerDbContext))] + [Migration("20260803143404_AddCoreSignalMatrix")] + partial class AddCoreSignalMatrix + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("tracker_governance") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Artifacts.Entities.EvidenceRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdapterVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("adapter_version"); + + b.Property("ArtifactId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_type"); + + b.Property("Classification") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("classification"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentHash") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("content_hash"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Determinism") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("determinism"); + + b.Property("Dimension") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("dimension"); + + b.Property("EvidenceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("evidence_type"); + + b.Property("Immutable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("immutable"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Producer") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("producer"); + + b.Property("ReferencesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("references"); + + b.Property("RetentionPolicyRef") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("retention_policy_ref"); + + b.Property("SourceProvider") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("source_provider"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("SubmittedBy") + .HasColumnType("uuid") + .HasColumnName("submitted_by"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Classification"); + + b.HasIndex("Dimension"); + + b.HasIndex("TenantId"); + + b.HasIndex("ArtifactType", "ArtifactId"); + + b.ToTable("evidence_records", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Audit.Entities.AuditEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("ActorType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("actor_type"); + + b.Property("AgentId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("agent_id"); + + b.Property("ChangesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("session_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ActorId"); + + b.HasIndex("ActorType"); + + b.HasIndex("TenantId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_entries", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Design.Entities.TechnicalBlueprintRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IntegrationAdrRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("integration_adr_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("technical_blueprints", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.InitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalComplete") + .HasColumnType("boolean") + .HasColumnName("approval_complete"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedBy") + .HasColumnType("uuid") + .HasColumnName("approved_by"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConstituentsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("constituents"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("origin"); + + b.Property("OriginRef") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("origin_ref"); + + b.Property("ProductScopeJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("product_scope"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("RequiredApproversJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_approvers"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SupersededByInitiativeId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_initiative_id"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TypeFrozen") + .HasColumnType("boolean") + .HasColumnName("type_frozen"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code"); + + b.ToTable("initiatives", "tracker_governance", t => + { + t.HasCheckConstraint("ck_initiatives_status", "status IN ('draft', 'submitted', 'approved', 'rejected', 'consolidated')"); + }); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.OpportunityRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("ContextJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("context"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("opportunities", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CatalogReleaseRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("catalog_release", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CountryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DefaultCurrencyCode") + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("default_currency_code"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character(2)") + .HasColumnName("iso_alpha2") + .IsFixedLength(); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("iso_alpha3") + .IsFixedLength(); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumericCode") + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("numeric_code") + .IsFixedLength(); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IsoAlpha2") + .IsUnique(); + + b.HasIndex("IsoAlpha3") + .IsUnique(); + + b.ToTable("country", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CurrencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MinorUnits") + .HasColumnType("smallint") + .HasColumnName("minor_units"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("symbol"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currency", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.ExchangeRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("base_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("QuoteCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("quote_code"); + + b.Property("Rate") + .HasColumnType("numeric(20,10)") + .HasColumnName("rate"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("BaseCode", "QuoteCode", "ValidFrom") + .HasDatabaseName("ix_exchange_rate_pair"); + + b.ToTable("exchange_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LanguageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("language", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocaleProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DateFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("date_format"); + + b.Property("FirstDayOfWeek") + .HasColumnType("smallint") + .HasColumnName("first_day_of_week"); + + b.Property("Name") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumberFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("number_format"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CountryId"); + + b.ToTable("locale_profile", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scheme"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("LocationId", "Scheme") + .IsUnique(); + + b.HasIndex("Scheme", "Value") + .IsUnique(); + + b.ToTable("location_code", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.HasKey("LocationId", "Locale"); + + b.ToTable("location_i18n", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationLevelRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("Ordinal") + .HasColumnType("smallint") + .HasColumnName("ordinal"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Ordinal") + .IsUnique(); + + b.ToTable("location_level", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CatalogReleaseId") + .HasColumnType("uuid") + .HasColumnName("catalog_release_id"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LevelId") + .HasColumnType("uuid") + .HasColumnName("level_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("SupersedesId") + .HasColumnType("uuid") + .HasColumnName("supersedes_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_location_parent"); + + b.HasIndex("Path") + .HasDatabaseName("ix_location_path"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist"); + + b.HasIndex("CountryId", "ParentId") + .HasDatabaseName("ix_location_country_parent"); + + b.ToTable("location", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("OperationType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation_type"); + + b.Property("Rate") + .HasColumnType("numeric(6,4)") + .HasColumnName("rate"); + + b.Property("TaxSchemeId") + .HasColumnType("uuid") + .HasColumnName("tax_scheme_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("TaxSchemeId", "LocationId", "ValidFrom") + .HasDatabaseName("ix_tax_rate_scheme_location"); + + b.ToTable("tax_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxSchemeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Code") + .IsUnique(); + + b.ToTable("tax_scheme", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TimeZoneRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("country_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IanaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("iana_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.HasIndex("IanaId") + .IsUnique(); + + b.ToTable("time_zone", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ArtifactFieldSchemaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("artifact_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ArtifactType") + .IsUnique(); + + b.ToTable("artifact_field_schemas", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.EvidenceEdgeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AssertedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("asserted_by"); + + b.Property("EdgeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("edge_type"); + + b.Property("FromId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("from_id"); + + b.Property("FromKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("from_kind"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Provenance") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("provenance"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("to_id"); + + b.Property("ToKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("to_kind"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_from"); + + b.HasIndex("TenantId", "ToKind", "ToId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_to"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType", "ToKind", "ToId", "OccurredAtUtc") + .IsUnique() + .HasDatabaseName("ux_evidence_edges_identity"); + + b.ToTable("evidence_edges", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ExceptionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CriterionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("criterion_id"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at_utc"); + + b.Property("DecidedBy") + .HasColumnType("uuid") + .HasColumnName("decided_by"); + + b.Property("DecisionNotes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("decision_notes"); + + b.Property("ExceptionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("exception_type"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("Justification") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("justification"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("RequestedBy") + .HasColumnType("uuid") + .HasColumnName("requested_by"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_exception_requests_tenant_status"); + + b.HasIndex("TenantId", "SdlcExecutionId", "Phase") + .HasDatabaseName("ix_exception_requests_tenant_execution_phase"); + + b.ToTable("exception_requests", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("approval_ids"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DecisionBy") + .HasColumnType("uuid") + .HasColumnName("decision_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationIdsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evaluation_ids"); + + b.Property("EvidenceSnapshotRef") + .HasColumnType("text") + .HasColumnName("evidence_snapshot_ref"); + + b.Property("ExceptionIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("exception_ids"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("PolicySnapshotRef") + .HasColumnType("text") + .HasColumnName("policy_snapshot_ref"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rationale"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Phase"); + + b.HasIndex("SdlcExecutionId"); + + b.HasIndex("TenantId"); + + b.ToTable("gate_decisions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GatePolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("ApprovalPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approval_policy"); + + b.Property("CoreSignalRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("core_signal_rules"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationCriteriaJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evaluation_criteria"); + + b.Property("ExceptionPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("exception_policy"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequiredEvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_evidence"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("gate_policies", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateSubmissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AppliedCoreSignalRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("applied_core_signal_rules"); + + b.Property("ApprovalsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approvals"); + + b.Property("CoreSignalEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("core_signal_evaluation"); + + b.Property("CoreVerdictJson") + .HasColumnType("jsonb") + .HasColumnName("core_verdict"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CriteriaEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("criteria_evaluation"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("requested_by"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("state"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase"); + + b.ToTable("gate_submissions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.PhaseGateStateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid") + .HasColumnName("evaluated_by"); + + b.Property("GateStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gate_status"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phase"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase"); + + b.HasIndex("TenantId", "SdlcExecutionId"); + + b.ToTable("phase_gate_states", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.RuntimeApprovalRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Approver") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("approver"); + + b.Property("CorrelationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("ExecutionMode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("execution_mode"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("InitiativeId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("initiative_id"); + + b.Property("Intent") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("intent"); + + b.Property("ProductId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("product_id"); + + b.Property("Reason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reason"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at_utc"); + + b.Property("RequestedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("requested_by"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at_utc"); + + b.Property("SkillId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("skill_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubjectConfidence") + .HasColumnType("double precision") + .HasColumnName("subject_confidence"); + + b.Property("SubjectKind") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("subject_kind"); + + b.Property("SubjectPayloadJson") + .HasColumnType("jsonb") + .HasColumnName("subject_payload"); + + b.Property("SubjectRef") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("subject_ref"); + + b.Property("SubjectSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("subject_summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CorrelationId") + .IsUnique() + .HasDatabaseName("ix_runtime_approvals_tenant_correlation") + .HasFilter("correlation_id IS NOT NULL"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_runtime_approvals_tenant_status"); + + b.HasIndex("TenantId", "SubjectKind", "SubjectRef") + .HasDatabaseName("ix_runtime_approvals_tenant_subject") + .HasFilter("subject_kind IS NOT NULL"); + + b.ToTable("runtime_approvals", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.SDLCExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BlockedReason") + .HasColumnType("text") + .HasColumnName("blocked_reason"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ProductCode"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "InitiativeId") + .HasDatabaseName("ix_sdlc_executions_tenant_initiative"); + + b.ToTable("sdlc_executions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeDeadLetterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("intake_dead_letter", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("Status", "NextAttemptAtUtc"); + + b.ToTable("intake_outbox", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.PpmEndpointRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("none") + .HasColumnName("auth_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FieldBindingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("field_bindings"); + + b.Property("HealthState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("health_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sync_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasColumnType("text") + .HasColumnName("secret_ref"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("TelemetryUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("telemetry_url"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ppm_endpoints", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicBaselineRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AnchoredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("anchored_at"); + + b.Property("Budget") + .IsRequired() + .HasColumnType("text") + .HasColumnName("budget"); + + b.Property("ExpectedRoi") + .IsRequired() + .HasColumnType("text") + .HasColumnName("expected_roi"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("RiskLevel") + .IsRequired() + .HasColumnType("text") + .HasColumnName("risk_level"); + + b.Property("StrategicInitiativeId") + .HasColumnType("uuid") + .HasColumnName("strategic_initiative_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("StrategicInitiativeId"); + + b.HasIndex("StrategicInitiativeId", "Version") + .IsUnique(); + + b.ToTable("strategic_baselines", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicInitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("FeasibilityJson") + .HasColumnType("jsonb") + .HasColumnName("feasibility"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("IntakeState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("intake_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("ProductCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ProductName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("product_name"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IntakeState"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ExternalId") + .IsUnique(); + + b.ToTable("strategic_initiatives", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.CoreEvaluationTransactionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountableOwnersJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("accountable_owners"); + + b.Property("BlockingViolationCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("blocking_violation_count"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CorrelationId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("ErrorCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("error_code"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("operation_id"); + + b.Property("Outcome") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("OverallVerdict") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("overall_verdict"); + + b.Property("ProducerSurface") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("producer_surface"); + + b.Property("ProducerVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("producer_version"); + + b.Property("RepositoryRevision") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("repository_revision"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedByJson") + .HasColumnType("jsonb") + .HasColumnName("requested_by"); + + b.Property("ResponseDataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_data"); + + b.Property("RulesExecutedJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("rules_executed"); + + b.Property("SchemaVersion") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("schema_version"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("VersionsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("versions"); + + b.Property("ViolationsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("violations"); + + b.HasKey("Id"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.HasIndex("TenantId", "CorrelationId") + .IsUnique(); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("core_evaluation_transactions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.ProviderConnectionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AclDirection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("acl_direction"); + + b.Property("AclFieldOwnership") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("acl_field_ownership"); + + b.Property("AclScopesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("acl_scopes"); + + b.Property("Capability") + .IsRequired() + .HasColumnType("text") + .HasColumnName("capability"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at_utc"); + + b.Property("Port") + .IsRequired() + .HasColumnType("text") + .HasColumnName("port"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("secret_ref"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SyncCursor") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("sync_cursor"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Capability"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("provider_connections", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.PublicationOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("capability"); + + b.Property("CheckIdentifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("check_identifier"); + + b.Property("ComponentIdentifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("component_identifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("last_error"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("state"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("State", "NextAttemptAtUtc"); + + b.HasIndex("AggregateId", "ComponentIdentifier", "CheckIdentifier") + .IsUnique() + .HasFilter("state = 'pending'"); + + b.ToTable("publication_outbox", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Metrics.PhaseSlaPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("SlaDays") + .HasColumnType("integer") + .HasColumnName("sla_days"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("phase_sla_policies", "tracker_metrics"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Products.Entities.ProductRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalRefsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("external_refs"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("OwnerIdentity") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("owner_identity"); + + b.Property("RepositoryAccessJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("repository_access"); + + b.Property("RepositoryUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Topology") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("topology"); + + b.Property("TopologySetJson") + .HasColumnType("jsonb") + .HasColumnName("topology_set"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("products", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.DefectRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceRefsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evidence_refs"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("Resolution") + .HasColumnType("text") + .HasColumnName("resolution"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TestCycleId") + .HasColumnType("uuid") + .HasColumnName("test_cycle_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TestCycleId"); + + b.HasIndex("ProductId", "Status"); + + b.ToTable("defects", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.TestCycleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("closed_at"); + + b.Property("ClosedBlocked") + .HasColumnType("integer") + .HasColumnName("closed_blocked"); + + b.Property("ClosedFailed") + .HasColumnType("integer") + .HasColumnName("closed_failed"); + + b.Property("ClosedPassed") + .HasColumnType("integer") + .HasColumnName("closed_passed"); + + b.Property("ClosureNote") + .HasColumnType("text") + .HasColumnName("closure_note"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Status"); + + b.ToTable("test_cycles", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.TestExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("evidence_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("outcome"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TestCaseRef") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("test_case_ref"); + + b.Property("TestCycleId") + .HasColumnType("uuid") + .HasColumnName("test_cycle_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TestCycleId", "Outcome"); + + b.ToTable("test_executions", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseArtifactRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ItemsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("items"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase") + .IsUnique(); + + b.ToTable("phase_artifacts", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseProgressionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("HistoryJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("history"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("phase_progressions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc2.DeploymentRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Environment") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("environment"); + + b.Property("EvidenceRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("evidence_ref"); + + b.Property("FinishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("RollbackReason") + .HasColumnType("text") + .HasColumnName("rollback_reason"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Environment", "Outcome", "FinishedAtUtc"); + + b.ToTable("deployment_records", "tracker_release"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc2.PeerReviewRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ChangeRef") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("change_ref"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reviewed_at"); + + b.Property("ReviewerId") + .HasColumnType("uuid") + .HasColumnName("reviewer_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ChangeRef"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Outcome"); + + b.ToTable("peer_reviews", "tracker_construction"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantIntelligenceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AgentRegistrationsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("agent_registrations"); + + b.Property("CorpusRefsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("corpus_refs"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelRoutingJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("model_routing"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("tenant_intelligence", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("contact_email"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("display_name"); + + b.Property("GeoCatalogRelease") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("geo_catalog_release"); + + b.Property("GeoCountryId") + .HasColumnType("uuid") + .HasColumnName("geo_country_id"); + + b.Property("GeoDisplayPath") + .HasColumnType("text") + .HasColumnName("geo_display_path"); + + b.Property("GeoUbigeo") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("geo_ubigeo"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LocalizationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("localization"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("tier"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("tenants", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany("Codes") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Navigation("Codes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803143404_AddCoreSignalMatrix.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803143404_AddCoreSignalMatrix.cs new file mode 100644 index 00000000..918d41f5 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803143404_AddCoreSignalMatrix.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + /// + public partial class AddCoreSignalMatrix : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "applied_core_signal_rules", + schema: "tracker_governance", + table: "gate_submissions", + type: "jsonb", + nullable: false, + defaultValue: "[]"); + + migrationBuilder.AddColumn( + name: "core_signal_evaluation", + schema: "tracker_governance", + table: "gate_submissions", + type: "jsonb", + nullable: false, + defaultValue: "[]"); + + migrationBuilder.AddColumn( + name: "core_signal_rules", + schema: "tracker_governance", + table: "gate_policies", + type: "jsonb", + nullable: false, + defaultValue: "[]"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "applied_core_signal_rules", + schema: "tracker_governance", + table: "gate_submissions"); + + migrationBuilder.DropColumn( + name: "core_signal_evaluation", + schema: "tracker_governance", + table: "gate_submissions"); + + migrationBuilder.DropColumn( + name: "core_signal_rules", + schema: "tracker_governance", + table: "gate_policies"); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803161417_AddCoreEvaluationAttachment.Designer.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803161417_AddCoreEvaluationAttachment.Designer.cs new file mode 100644 index 00000000..fa63d2b0 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803161417_AddCoreEvaluationAttachment.Designer.cs @@ -0,0 +1,3952 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tracker.Infrastructure.Persistence; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + [DbContext(typeof(TrackerDbContext))] + [Migration("20260803161417_AddCoreEvaluationAttachment")] + partial class AddCoreEvaluationAttachment + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("tracker_governance") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Artifacts.Entities.EvidenceRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdapterVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("adapter_version"); + + b.Property("ArtifactId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_type"); + + b.Property("Classification") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("classification"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentHash") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("content_hash"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Determinism") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("determinism"); + + b.Property("Dimension") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("dimension"); + + b.Property("EvidenceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("evidence_type"); + + b.Property("Immutable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("immutable"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Producer") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("producer"); + + b.Property("ReferencesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("references"); + + b.Property("RetentionPolicyRef") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("retention_policy_ref"); + + b.Property("SourceProvider") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("source_provider"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("SubmittedBy") + .HasColumnType("uuid") + .HasColumnName("submitted_by"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Classification"); + + b.HasIndex("Dimension"); + + b.HasIndex("TenantId"); + + b.HasIndex("ArtifactType", "ArtifactId"); + + b.ToTable("evidence_records", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Audit.Entities.AuditEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("ActorType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("actor_type"); + + b.Property("AgentId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("agent_id"); + + b.Property("ChangesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("session_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ActorId"); + + b.HasIndex("ActorType"); + + b.HasIndex("TenantId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_entries", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Design.Entities.TechnicalBlueprintRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IntegrationAdrRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("integration_adr_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("technical_blueprints", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.InitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalComplete") + .HasColumnType("boolean") + .HasColumnName("approval_complete"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedBy") + .HasColumnType("uuid") + .HasColumnName("approved_by"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConstituentsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("constituents"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("origin"); + + b.Property("OriginRef") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("origin_ref"); + + b.Property("ProductScopeJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("product_scope"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("RequiredApproversJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_approvers"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SupersededByInitiativeId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_initiative_id"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TypeFrozen") + .HasColumnType("boolean") + .HasColumnName("type_frozen"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code"); + + b.ToTable("initiatives", "tracker_governance", t => + { + t.HasCheckConstraint("ck_initiatives_status", "status IN ('draft', 'submitted', 'approved', 'rejected', 'consolidated')"); + }); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.OpportunityRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("ContextJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("context"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("opportunities", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CatalogReleaseRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("catalog_release", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CountryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DefaultCurrencyCode") + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("default_currency_code"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character(2)") + .HasColumnName("iso_alpha2") + .IsFixedLength(); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("iso_alpha3") + .IsFixedLength(); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumericCode") + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("numeric_code") + .IsFixedLength(); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IsoAlpha2") + .IsUnique(); + + b.HasIndex("IsoAlpha3") + .IsUnique(); + + b.ToTable("country", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CurrencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MinorUnits") + .HasColumnType("smallint") + .HasColumnName("minor_units"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("symbol"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currency", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.ExchangeRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("base_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("QuoteCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("quote_code"); + + b.Property("Rate") + .HasColumnType("numeric(20,10)") + .HasColumnName("rate"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("BaseCode", "QuoteCode", "ValidFrom") + .HasDatabaseName("ix_exchange_rate_pair"); + + b.ToTable("exchange_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LanguageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("language", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocaleProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DateFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("date_format"); + + b.Property("FirstDayOfWeek") + .HasColumnType("smallint") + .HasColumnName("first_day_of_week"); + + b.Property("Name") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumberFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("number_format"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CountryId"); + + b.ToTable("locale_profile", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scheme"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("LocationId", "Scheme") + .IsUnique(); + + b.HasIndex("Scheme", "Value") + .IsUnique(); + + b.ToTable("location_code", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.HasKey("LocationId", "Locale"); + + b.ToTable("location_i18n", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationLevelRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("Ordinal") + .HasColumnType("smallint") + .HasColumnName("ordinal"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Ordinal") + .IsUnique(); + + b.ToTable("location_level", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CatalogReleaseId") + .HasColumnType("uuid") + .HasColumnName("catalog_release_id"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LevelId") + .HasColumnType("uuid") + .HasColumnName("level_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("SupersedesId") + .HasColumnType("uuid") + .HasColumnName("supersedes_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_location_parent"); + + b.HasIndex("Path") + .HasDatabaseName("ix_location_path"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist"); + + b.HasIndex("CountryId", "ParentId") + .HasDatabaseName("ix_location_country_parent"); + + b.ToTable("location", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("OperationType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation_type"); + + b.Property("Rate") + .HasColumnType("numeric(6,4)") + .HasColumnName("rate"); + + b.Property("TaxSchemeId") + .HasColumnType("uuid") + .HasColumnName("tax_scheme_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("TaxSchemeId", "LocationId", "ValidFrom") + .HasDatabaseName("ix_tax_rate_scheme_location"); + + b.ToTable("tax_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxSchemeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Code") + .IsUnique(); + + b.ToTable("tax_scheme", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TimeZoneRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("country_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IanaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("iana_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.HasIndex("IanaId") + .IsUnique(); + + b.ToTable("time_zone", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ArtifactFieldSchemaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("artifact_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ArtifactType") + .IsUnique(); + + b.ToTable("artifact_field_schemas", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.EvidenceEdgeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AssertedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("asserted_by"); + + b.Property("EdgeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("edge_type"); + + b.Property("FromId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("from_id"); + + b.Property("FromKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("from_kind"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Provenance") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("provenance"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("to_id"); + + b.Property("ToKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("to_kind"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_from"); + + b.HasIndex("TenantId", "ToKind", "ToId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_to"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType", "ToKind", "ToId", "OccurredAtUtc") + .IsUnique() + .HasDatabaseName("ux_evidence_edges_identity"); + + b.ToTable("evidence_edges", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ExceptionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CriterionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("criterion_id"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at_utc"); + + b.Property("DecidedBy") + .HasColumnType("uuid") + .HasColumnName("decided_by"); + + b.Property("DecisionNotes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("decision_notes"); + + b.Property("ExceptionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("exception_type"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("Justification") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("justification"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("RequestedBy") + .HasColumnType("uuid") + .HasColumnName("requested_by"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_exception_requests_tenant_status"); + + b.HasIndex("TenantId", "SdlcExecutionId", "Phase") + .HasDatabaseName("ix_exception_requests_tenant_execution_phase"); + + b.ToTable("exception_requests", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("approval_ids"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DecisionBy") + .HasColumnType("uuid") + .HasColumnName("decision_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationIdsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evaluation_ids"); + + b.Property("EvidenceSnapshotRef") + .HasColumnType("text") + .HasColumnName("evidence_snapshot_ref"); + + b.Property("ExceptionIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("exception_ids"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("PolicySnapshotRef") + .HasColumnType("text") + .HasColumnName("policy_snapshot_ref"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rationale"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Phase"); + + b.HasIndex("SdlcExecutionId"); + + b.HasIndex("TenantId"); + + b.ToTable("gate_decisions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GatePolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("ApprovalPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approval_policy"); + + b.Property("CoreSignalRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("core_signal_rules"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationCriteriaJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evaluation_criteria"); + + b.Property("ExceptionPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("exception_policy"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequiredEvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_evidence"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("gate_policies", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateSubmissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AppliedCoreSignalRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("applied_core_signal_rules"); + + b.Property("ApprovalsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approvals"); + + b.Property("CoreSignalEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("core_signal_evaluation"); + + b.Property("CoreVerdictJson") + .HasColumnType("jsonb") + .HasColumnName("core_verdict"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CriteriaEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("criteria_evaluation"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("requested_by"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("state"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase"); + + b.ToTable("gate_submissions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.PhaseGateStateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid") + .HasColumnName("evaluated_by"); + + b.Property("GateStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gate_status"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phase"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase"); + + b.HasIndex("TenantId", "SdlcExecutionId"); + + b.ToTable("phase_gate_states", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.RuntimeApprovalRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Approver") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("approver"); + + b.Property("CorrelationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("ExecutionMode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("execution_mode"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("InitiativeId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("initiative_id"); + + b.Property("Intent") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("intent"); + + b.Property("ProductId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("product_id"); + + b.Property("Reason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reason"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at_utc"); + + b.Property("RequestedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("requested_by"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at_utc"); + + b.Property("SkillId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("skill_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubjectConfidence") + .HasColumnType("double precision") + .HasColumnName("subject_confidence"); + + b.Property("SubjectKind") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("subject_kind"); + + b.Property("SubjectPayloadJson") + .HasColumnType("jsonb") + .HasColumnName("subject_payload"); + + b.Property("SubjectRef") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("subject_ref"); + + b.Property("SubjectSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("subject_summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CorrelationId") + .IsUnique() + .HasDatabaseName("ix_runtime_approvals_tenant_correlation") + .HasFilter("correlation_id IS NOT NULL"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_runtime_approvals_tenant_status"); + + b.HasIndex("TenantId", "SubjectKind", "SubjectRef") + .HasDatabaseName("ix_runtime_approvals_tenant_subject") + .HasFilter("subject_kind IS NOT NULL"); + + b.ToTable("runtime_approvals", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.SDLCExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BlockedReason") + .HasColumnType("text") + .HasColumnName("blocked_reason"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ProductCode"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "InitiativeId") + .HasDatabaseName("ix_sdlc_executions_tenant_initiative"); + + b.ToTable("sdlc_executions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeDeadLetterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("intake_dead_letter", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("Status", "NextAttemptAtUtc"); + + b.ToTable("intake_outbox", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.PpmEndpointRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("none") + .HasColumnName("auth_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FieldBindingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("field_bindings"); + + b.Property("HealthState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("health_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sync_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasColumnType("text") + .HasColumnName("secret_ref"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("TelemetryUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("telemetry_url"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ppm_endpoints", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicBaselineRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AnchoredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("anchored_at"); + + b.Property("Budget") + .IsRequired() + .HasColumnType("text") + .HasColumnName("budget"); + + b.Property("ExpectedRoi") + .IsRequired() + .HasColumnType("text") + .HasColumnName("expected_roi"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("RiskLevel") + .IsRequired() + .HasColumnType("text") + .HasColumnName("risk_level"); + + b.Property("StrategicInitiativeId") + .HasColumnType("uuid") + .HasColumnName("strategic_initiative_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("StrategicInitiativeId"); + + b.HasIndex("StrategicInitiativeId", "Version") + .IsUnique(); + + b.ToTable("strategic_baselines", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicInitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("FeasibilityJson") + .HasColumnType("jsonb") + .HasColumnName("feasibility"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("IntakeState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("intake_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("ProductCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ProductName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("product_name"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IntakeState"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ExternalId") + .IsUnique(); + + b.ToTable("strategic_initiatives", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.CoreEvaluationTransactionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountableOwnersJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("accountable_owners"); + + b.Property("AttachmentJson") + .HasColumnType("jsonb") + .HasColumnName("attachment"); + + b.Property("BlockingViolationCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("blocking_violation_count"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CorrelationId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("ErrorCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("error_code"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("operation_id"); + + b.Property("Outcome") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("OverallVerdict") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("overall_verdict"); + + b.Property("ProducerSurface") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("producer_surface"); + + b.Property("ProducerVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("producer_version"); + + b.Property("RepositoryRevision") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("repository_revision"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedByJson") + .HasColumnType("jsonb") + .HasColumnName("requested_by"); + + b.Property("ResponseDataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_data"); + + b.Property("RulesExecutedJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("rules_executed"); + + b.Property("SchemaVersion") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("schema_version"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("VersionsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("versions"); + + b.Property("ViolationsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("violations"); + + b.HasKey("Id"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.HasIndex("TenantId", "CorrelationId") + .IsUnique(); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("core_evaluation_transactions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.ProviderConnectionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AclDirection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("acl_direction"); + + b.Property("AclFieldOwnership") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("acl_field_ownership"); + + b.Property("AclScopesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("acl_scopes"); + + b.Property("Capability") + .IsRequired() + .HasColumnType("text") + .HasColumnName("capability"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at_utc"); + + b.Property("Port") + .IsRequired() + .HasColumnType("text") + .HasColumnName("port"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("secret_ref"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SyncCursor") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("sync_cursor"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Capability"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("provider_connections", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.PublicationOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("capability"); + + b.Property("CheckIdentifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("check_identifier"); + + b.Property("ComponentIdentifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("component_identifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("last_error"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("state"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("State", "NextAttemptAtUtc"); + + b.HasIndex("AggregateId", "ComponentIdentifier", "CheckIdentifier") + .IsUnique() + .HasFilter("state = 'pending'"); + + b.ToTable("publication_outbox", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Metrics.PhaseSlaPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("SlaDays") + .HasColumnType("integer") + .HasColumnName("sla_days"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("phase_sla_policies", "tracker_metrics"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Products.Entities.ProductRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalRefsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("external_refs"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("OwnerIdentity") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("owner_identity"); + + b.Property("RepositoryAccessJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("repository_access"); + + b.Property("RepositoryUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Topology") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("topology"); + + b.Property("TopologySetJson") + .HasColumnType("jsonb") + .HasColumnName("topology_set"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("products", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.DefectRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceRefsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evidence_refs"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("Resolution") + .HasColumnType("text") + .HasColumnName("resolution"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TestCycleId") + .HasColumnType("uuid") + .HasColumnName("test_cycle_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TestCycleId"); + + b.HasIndex("ProductId", "Status"); + + b.ToTable("defects", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.TestCycleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("closed_at"); + + b.Property("ClosedBlocked") + .HasColumnType("integer") + .HasColumnName("closed_blocked"); + + b.Property("ClosedFailed") + .HasColumnType("integer") + .HasColumnName("closed_failed"); + + b.Property("ClosedPassed") + .HasColumnType("integer") + .HasColumnName("closed_passed"); + + b.Property("ClosureNote") + .HasColumnType("text") + .HasColumnName("closure_note"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Status"); + + b.ToTable("test_cycles", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.TestExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("evidence_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("outcome"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TestCaseRef") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("test_case_ref"); + + b.Property("TestCycleId") + .HasColumnType("uuid") + .HasColumnName("test_cycle_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TestCycleId", "Outcome"); + + b.ToTable("test_executions", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseArtifactRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ItemsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("items"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase") + .IsUnique(); + + b.ToTable("phase_artifacts", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseProgressionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("HistoryJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("history"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("phase_progressions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc2.DeploymentRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Environment") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("environment"); + + b.Property("EvidenceRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("evidence_ref"); + + b.Property("FinishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("RollbackReason") + .HasColumnType("text") + .HasColumnName("rollback_reason"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Environment", "Outcome", "FinishedAtUtc"); + + b.ToTable("deployment_records", "tracker_release"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc2.PeerReviewRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ChangeRef") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("change_ref"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reviewed_at"); + + b.Property("ReviewerId") + .HasColumnType("uuid") + .HasColumnName("reviewer_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ChangeRef"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Outcome"); + + b.ToTable("peer_reviews", "tracker_construction"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantIntelligenceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AgentRegistrationsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("agent_registrations"); + + b.Property("CorpusRefsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("corpus_refs"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelRoutingJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("model_routing"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("tenant_intelligence", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("contact_email"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("display_name"); + + b.Property("GeoCatalogRelease") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("geo_catalog_release"); + + b.Property("GeoCountryId") + .HasColumnType("uuid") + .HasColumnName("geo_country_id"); + + b.Property("GeoDisplayPath") + .HasColumnType("text") + .HasColumnName("geo_display_path"); + + b.Property("GeoUbigeo") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("geo_ubigeo"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LocalizationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("localization"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("tier"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("tenants", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany("Codes") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Navigation("Codes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803161417_AddCoreEvaluationAttachment.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803161417_AddCoreEvaluationAttachment.cs new file mode 100644 index 00000000..a088d3a2 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803161417_AddCoreEvaluationAttachment.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + /// + public partial class AddCoreEvaluationAttachment : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "attachment", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "jsonb", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "attachment", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803164206_AddArtifactApplicabilityOverlay.Designer.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803164206_AddArtifactApplicabilityOverlay.Designer.cs new file mode 100644 index 00000000..1a312427 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803164206_AddArtifactApplicabilityOverlay.Designer.cs @@ -0,0 +1,3959 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tracker.Infrastructure.Persistence; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + [DbContext(typeof(TrackerDbContext))] + [Migration("20260803164206_AddArtifactApplicabilityOverlay")] + partial class AddArtifactApplicabilityOverlay + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("tracker_governance") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Artifacts.Entities.EvidenceRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdapterVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("adapter_version"); + + b.Property("ArtifactId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_type"); + + b.Property("Classification") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("classification"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentHash") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("content_hash"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Determinism") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("determinism"); + + b.Property("Dimension") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("dimension"); + + b.Property("EvidenceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("evidence_type"); + + b.Property("Immutable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("immutable"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Producer") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("producer"); + + b.Property("ReferencesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("references"); + + b.Property("RetentionPolicyRef") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("retention_policy_ref"); + + b.Property("SourceProvider") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("source_provider"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("SubmittedBy") + .HasColumnType("uuid") + .HasColumnName("submitted_by"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Classification"); + + b.HasIndex("Dimension"); + + b.HasIndex("TenantId"); + + b.HasIndex("ArtifactType", "ArtifactId"); + + b.ToTable("evidence_records", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Audit.Entities.AuditEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("ActorType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("actor_type"); + + b.Property("AgentId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("agent_id"); + + b.Property("ChangesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("session_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ActorId"); + + b.HasIndex("ActorType"); + + b.HasIndex("TenantId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_entries", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Design.Entities.TechnicalBlueprintRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IntegrationAdrRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("integration_adr_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("technical_blueprints", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.InitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalComplete") + .HasColumnType("boolean") + .HasColumnName("approval_complete"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedBy") + .HasColumnType("uuid") + .HasColumnName("approved_by"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConstituentsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("constituents"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("origin"); + + b.Property("OriginRef") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("origin_ref"); + + b.Property("ProductScopeJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("product_scope"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("RequiredApproversJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_approvers"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SupersededByInitiativeId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_initiative_id"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TypeFrozen") + .HasColumnType("boolean") + .HasColumnName("type_frozen"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code"); + + b.ToTable("initiatives", "tracker_governance", t => + { + t.HasCheckConstraint("ck_initiatives_status", "status IN ('draft', 'submitted', 'approved', 'rejected', 'consolidated')"); + }); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.OpportunityRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("ContextJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("context"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("opportunities", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CatalogReleaseRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("catalog_release", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CountryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DefaultCurrencyCode") + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("default_currency_code"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character(2)") + .HasColumnName("iso_alpha2") + .IsFixedLength(); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("iso_alpha3") + .IsFixedLength(); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumericCode") + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("numeric_code") + .IsFixedLength(); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IsoAlpha2") + .IsUnique(); + + b.HasIndex("IsoAlpha3") + .IsUnique(); + + b.ToTable("country", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CurrencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MinorUnits") + .HasColumnType("smallint") + .HasColumnName("minor_units"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("symbol"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currency", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.ExchangeRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("base_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("QuoteCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("quote_code"); + + b.Property("Rate") + .HasColumnType("numeric(20,10)") + .HasColumnName("rate"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("BaseCode", "QuoteCode", "ValidFrom") + .HasDatabaseName("ix_exchange_rate_pair"); + + b.ToTable("exchange_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LanguageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("language", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocaleProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DateFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("date_format"); + + b.Property("FirstDayOfWeek") + .HasColumnType("smallint") + .HasColumnName("first_day_of_week"); + + b.Property("Name") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumberFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("number_format"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CountryId"); + + b.ToTable("locale_profile", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scheme"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("LocationId", "Scheme") + .IsUnique(); + + b.HasIndex("Scheme", "Value") + .IsUnique(); + + b.ToTable("location_code", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.HasKey("LocationId", "Locale"); + + b.ToTable("location_i18n", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationLevelRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("Ordinal") + .HasColumnType("smallint") + .HasColumnName("ordinal"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Ordinal") + .IsUnique(); + + b.ToTable("location_level", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CatalogReleaseId") + .HasColumnType("uuid") + .HasColumnName("catalog_release_id"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LevelId") + .HasColumnType("uuid") + .HasColumnName("level_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("SupersedesId") + .HasColumnType("uuid") + .HasColumnName("supersedes_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_location_parent"); + + b.HasIndex("Path") + .HasDatabaseName("ix_location_path"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist"); + + b.HasIndex("CountryId", "ParentId") + .HasDatabaseName("ix_location_country_parent"); + + b.ToTable("location", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("OperationType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation_type"); + + b.Property("Rate") + .HasColumnType("numeric(6,4)") + .HasColumnName("rate"); + + b.Property("TaxSchemeId") + .HasColumnType("uuid") + .HasColumnName("tax_scheme_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("TaxSchemeId", "LocationId", "ValidFrom") + .HasDatabaseName("ix_tax_rate_scheme_location"); + + b.ToTable("tax_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxSchemeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Code") + .IsUnique(); + + b.ToTable("tax_scheme", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TimeZoneRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("country_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IanaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("iana_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.HasIndex("IanaId") + .IsUnique(); + + b.ToTable("time_zone", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ArtifactFieldSchemaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("artifact_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ArtifactType") + .IsUnique(); + + b.ToTable("artifact_field_schemas", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.EvidenceEdgeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AssertedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("asserted_by"); + + b.Property("EdgeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("edge_type"); + + b.Property("FromId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("from_id"); + + b.Property("FromKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("from_kind"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Provenance") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("provenance"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("to_id"); + + b.Property("ToKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("to_kind"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_from"); + + b.HasIndex("TenantId", "ToKind", "ToId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_to"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType", "ToKind", "ToId", "OccurredAtUtc") + .IsUnique() + .HasDatabaseName("ux_evidence_edges_identity"); + + b.ToTable("evidence_edges", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ExceptionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CriterionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("criterion_id"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at_utc"); + + b.Property("DecidedBy") + .HasColumnType("uuid") + .HasColumnName("decided_by"); + + b.Property("DecisionNotes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("decision_notes"); + + b.Property("ExceptionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("exception_type"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("Justification") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("justification"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("RequestedBy") + .HasColumnType("uuid") + .HasColumnName("requested_by"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_exception_requests_tenant_status"); + + b.HasIndex("TenantId", "SdlcExecutionId", "Phase") + .HasDatabaseName("ix_exception_requests_tenant_execution_phase"); + + b.ToTable("exception_requests", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("approval_ids"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DecisionBy") + .HasColumnType("uuid") + .HasColumnName("decision_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationIdsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evaluation_ids"); + + b.Property("EvidenceSnapshotRef") + .HasColumnType("text") + .HasColumnName("evidence_snapshot_ref"); + + b.Property("ExceptionIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("exception_ids"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("PolicySnapshotRef") + .HasColumnType("text") + .HasColumnName("policy_snapshot_ref"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rationale"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Phase"); + + b.HasIndex("SdlcExecutionId"); + + b.HasIndex("TenantId"); + + b.ToTable("gate_decisions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GatePolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("ApprovalPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approval_policy"); + + b.Property("ArtifactApplicabilityJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("artifact_applicability"); + + b.Property("CoreSignalRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("core_signal_rules"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationCriteriaJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evaluation_criteria"); + + b.Property("ExceptionPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("exception_policy"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequiredEvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_evidence"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("gate_policies", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateSubmissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AppliedCoreSignalRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("applied_core_signal_rules"); + + b.Property("ApprovalsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approvals"); + + b.Property("CoreSignalEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("core_signal_evaluation"); + + b.Property("CoreVerdictJson") + .HasColumnType("jsonb") + .HasColumnName("core_verdict"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CriteriaEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("criteria_evaluation"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("requested_by"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("state"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase"); + + b.ToTable("gate_submissions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.PhaseGateStateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid") + .HasColumnName("evaluated_by"); + + b.Property("GateStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gate_status"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phase"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase"); + + b.HasIndex("TenantId", "SdlcExecutionId"); + + b.ToTable("phase_gate_states", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.RuntimeApprovalRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Approver") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("approver"); + + b.Property("CorrelationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("ExecutionMode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("execution_mode"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("InitiativeId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("initiative_id"); + + b.Property("Intent") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("intent"); + + b.Property("ProductId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("product_id"); + + b.Property("Reason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reason"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at_utc"); + + b.Property("RequestedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("requested_by"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at_utc"); + + b.Property("SkillId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("skill_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubjectConfidence") + .HasColumnType("double precision") + .HasColumnName("subject_confidence"); + + b.Property("SubjectKind") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("subject_kind"); + + b.Property("SubjectPayloadJson") + .HasColumnType("jsonb") + .HasColumnName("subject_payload"); + + b.Property("SubjectRef") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("subject_ref"); + + b.Property("SubjectSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("subject_summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CorrelationId") + .IsUnique() + .HasDatabaseName("ix_runtime_approvals_tenant_correlation") + .HasFilter("correlation_id IS NOT NULL"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_runtime_approvals_tenant_status"); + + b.HasIndex("TenantId", "SubjectKind", "SubjectRef") + .HasDatabaseName("ix_runtime_approvals_tenant_subject") + .HasFilter("subject_kind IS NOT NULL"); + + b.ToTable("runtime_approvals", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.SDLCExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BlockedReason") + .HasColumnType("text") + .HasColumnName("blocked_reason"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ProductCode"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "InitiativeId") + .HasDatabaseName("ix_sdlc_executions_tenant_initiative"); + + b.ToTable("sdlc_executions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeDeadLetterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("intake_dead_letter", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("Status", "NextAttemptAtUtc"); + + b.ToTable("intake_outbox", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.PpmEndpointRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("none") + .HasColumnName("auth_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FieldBindingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("field_bindings"); + + b.Property("HealthState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("health_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sync_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasColumnType("text") + .HasColumnName("secret_ref"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("TelemetryUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("telemetry_url"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ppm_endpoints", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicBaselineRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AnchoredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("anchored_at"); + + b.Property("Budget") + .IsRequired() + .HasColumnType("text") + .HasColumnName("budget"); + + b.Property("ExpectedRoi") + .IsRequired() + .HasColumnType("text") + .HasColumnName("expected_roi"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("RiskLevel") + .IsRequired() + .HasColumnType("text") + .HasColumnName("risk_level"); + + b.Property("StrategicInitiativeId") + .HasColumnType("uuid") + .HasColumnName("strategic_initiative_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("StrategicInitiativeId"); + + b.HasIndex("StrategicInitiativeId", "Version") + .IsUnique(); + + b.ToTable("strategic_baselines", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicInitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("FeasibilityJson") + .HasColumnType("jsonb") + .HasColumnName("feasibility"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("IntakeState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("intake_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("ProductCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ProductName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("product_name"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IntakeState"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ExternalId") + .IsUnique(); + + b.ToTable("strategic_initiatives", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.CoreEvaluationTransactionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountableOwnersJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("accountable_owners"); + + b.Property("AttachmentJson") + .HasColumnType("jsonb") + .HasColumnName("attachment"); + + b.Property("BlockingViolationCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("blocking_violation_count"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CorrelationId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("ErrorCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("error_code"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("operation_id"); + + b.Property("Outcome") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("OverallVerdict") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("overall_verdict"); + + b.Property("ProducerSurface") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("producer_surface"); + + b.Property("ProducerVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("producer_version"); + + b.Property("RepositoryRevision") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("repository_revision"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedByJson") + .HasColumnType("jsonb") + .HasColumnName("requested_by"); + + b.Property("ResponseDataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_data"); + + b.Property("RulesExecutedJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("rules_executed"); + + b.Property("SchemaVersion") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("schema_version"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("VersionsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("versions"); + + b.Property("ViolationsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("violations"); + + b.HasKey("Id"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.HasIndex("TenantId", "CorrelationId") + .IsUnique(); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("core_evaluation_transactions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.ProviderConnectionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AclDirection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("acl_direction"); + + b.Property("AclFieldOwnership") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("acl_field_ownership"); + + b.Property("AclScopesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("acl_scopes"); + + b.Property("Capability") + .IsRequired() + .HasColumnType("text") + .HasColumnName("capability"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at_utc"); + + b.Property("Port") + .IsRequired() + .HasColumnType("text") + .HasColumnName("port"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("secret_ref"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SyncCursor") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("sync_cursor"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Capability"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("provider_connections", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.PublicationOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("capability"); + + b.Property("CheckIdentifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("check_identifier"); + + b.Property("ComponentIdentifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("component_identifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("last_error"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("state"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("State", "NextAttemptAtUtc"); + + b.HasIndex("AggregateId", "ComponentIdentifier", "CheckIdentifier") + .IsUnique() + .HasFilter("state = 'pending'"); + + b.ToTable("publication_outbox", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Metrics.PhaseSlaPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("SlaDays") + .HasColumnType("integer") + .HasColumnName("sla_days"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("phase_sla_policies", "tracker_metrics"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Products.Entities.ProductRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalRefsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("external_refs"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("OwnerIdentity") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("owner_identity"); + + b.Property("RepositoryAccessJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("repository_access"); + + b.Property("RepositoryUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Topology") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("topology"); + + b.Property("TopologySetJson") + .HasColumnType("jsonb") + .HasColumnName("topology_set"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("products", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.DefectRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceRefsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evidence_refs"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("Resolution") + .HasColumnType("text") + .HasColumnName("resolution"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TestCycleId") + .HasColumnType("uuid") + .HasColumnName("test_cycle_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TestCycleId"); + + b.HasIndex("ProductId", "Status"); + + b.ToTable("defects", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.TestCycleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("closed_at"); + + b.Property("ClosedBlocked") + .HasColumnType("integer") + .HasColumnName("closed_blocked"); + + b.Property("ClosedFailed") + .HasColumnType("integer") + .HasColumnName("closed_failed"); + + b.Property("ClosedPassed") + .HasColumnType("integer") + .HasColumnName("closed_passed"); + + b.Property("ClosureNote") + .HasColumnType("text") + .HasColumnName("closure_note"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Status"); + + b.ToTable("test_cycles", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.TestExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("evidence_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("outcome"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TestCaseRef") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("test_case_ref"); + + b.Property("TestCycleId") + .HasColumnType("uuid") + .HasColumnName("test_cycle_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TestCycleId", "Outcome"); + + b.ToTable("test_executions", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseArtifactRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ItemsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("items"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase") + .IsUnique(); + + b.ToTable("phase_artifacts", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseProgressionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("HistoryJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("history"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("phase_progressions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc2.DeploymentRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Environment") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("environment"); + + b.Property("EvidenceRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("evidence_ref"); + + b.Property("FinishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("RollbackReason") + .HasColumnType("text") + .HasColumnName("rollback_reason"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Environment", "Outcome", "FinishedAtUtc"); + + b.ToTable("deployment_records", "tracker_release"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc2.PeerReviewRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ChangeRef") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("change_ref"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reviewed_at"); + + b.Property("ReviewerId") + .HasColumnType("uuid") + .HasColumnName("reviewer_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ChangeRef"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Outcome"); + + b.ToTable("peer_reviews", "tracker_construction"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantIntelligenceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AgentRegistrationsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("agent_registrations"); + + b.Property("CorpusRefsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("corpus_refs"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelRoutingJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("model_routing"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("tenant_intelligence", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("contact_email"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("display_name"); + + b.Property("GeoCatalogRelease") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("geo_catalog_release"); + + b.Property("GeoCountryId") + .HasColumnType("uuid") + .HasColumnName("geo_country_id"); + + b.Property("GeoDisplayPath") + .HasColumnType("text") + .HasColumnName("geo_display_path"); + + b.Property("GeoUbigeo") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("geo_ubigeo"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LocalizationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("localization"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("tier"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("tenants", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany("Codes") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Navigation("Codes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803164206_AddArtifactApplicabilityOverlay.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803164206_AddArtifactApplicabilityOverlay.cs new file mode 100644 index 00000000..fef76c62 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803164206_AddArtifactApplicabilityOverlay.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + /// + public partial class AddArtifactApplicabilityOverlay : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "artifact_applicability", + schema: "tracker_governance", + table: "gate_policies", + type: "jsonb", + nullable: false, + defaultValue: "[]"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "artifact_applicability", + schema: "tracker_governance", + table: "gate_policies"); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803171143_AddArtifactVersions.Designer.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803171143_AddArtifactVersions.Designer.cs new file mode 100644 index 00000000..6edc7771 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803171143_AddArtifactVersions.Designer.cs @@ -0,0 +1,3966 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tracker.Infrastructure.Persistence; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + [DbContext(typeof(TrackerDbContext))] + [Migration("20260803171143_AddArtifactVersions")] + partial class AddArtifactVersions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("tracker_governance") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Artifacts.Entities.EvidenceRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdapterVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("adapter_version"); + + b.Property("ArtifactId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_type"); + + b.Property("Classification") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("classification"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentHash") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("content_hash"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Determinism") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("determinism"); + + b.Property("Dimension") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("dimension"); + + b.Property("EvidenceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("evidence_type"); + + b.Property("Immutable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("immutable"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Producer") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("producer"); + + b.Property("ReferencesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("references"); + + b.Property("RetentionPolicyRef") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("retention_policy_ref"); + + b.Property("SourceProvider") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("source_provider"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("SubmittedBy") + .HasColumnType("uuid") + .HasColumnName("submitted_by"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Classification"); + + b.HasIndex("Dimension"); + + b.HasIndex("TenantId"); + + b.HasIndex("ArtifactType", "ArtifactId"); + + b.ToTable("evidence_records", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Audit.Entities.AuditEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("ActorType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("actor_type"); + + b.Property("AgentId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("agent_id"); + + b.Property("ChangesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("session_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ActorId"); + + b.HasIndex("ActorType"); + + b.HasIndex("TenantId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_entries", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Design.Entities.TechnicalBlueprintRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IntegrationAdrRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("integration_adr_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("technical_blueprints", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.InitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalComplete") + .HasColumnType("boolean") + .HasColumnName("approval_complete"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedBy") + .HasColumnType("uuid") + .HasColumnName("approved_by"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConstituentsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("constituents"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("origin"); + + b.Property("OriginRef") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("origin_ref"); + + b.Property("ProductScopeJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("product_scope"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("RequiredApproversJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_approvers"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SupersededByInitiativeId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_initiative_id"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TypeFrozen") + .HasColumnType("boolean") + .HasColumnName("type_frozen"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code"); + + b.ToTable("initiatives", "tracker_governance", t => + { + t.HasCheckConstraint("ck_initiatives_status", "status IN ('draft', 'submitted', 'approved', 'rejected', 'consolidated')"); + }); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.OpportunityRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("ContextJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("context"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("opportunities", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CatalogReleaseRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("catalog_release", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CountryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DefaultCurrencyCode") + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("default_currency_code"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character(2)") + .HasColumnName("iso_alpha2") + .IsFixedLength(); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("iso_alpha3") + .IsFixedLength(); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumericCode") + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("numeric_code") + .IsFixedLength(); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IsoAlpha2") + .IsUnique(); + + b.HasIndex("IsoAlpha3") + .IsUnique(); + + b.ToTable("country", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CurrencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MinorUnits") + .HasColumnType("smallint") + .HasColumnName("minor_units"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("symbol"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currency", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.ExchangeRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("base_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("QuoteCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("quote_code"); + + b.Property("Rate") + .HasColumnType("numeric(20,10)") + .HasColumnName("rate"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("BaseCode", "QuoteCode", "ValidFrom") + .HasDatabaseName("ix_exchange_rate_pair"); + + b.ToTable("exchange_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LanguageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("language", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocaleProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DateFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("date_format"); + + b.Property("FirstDayOfWeek") + .HasColumnType("smallint") + .HasColumnName("first_day_of_week"); + + b.Property("Name") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumberFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("number_format"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CountryId"); + + b.ToTable("locale_profile", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scheme"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("LocationId", "Scheme") + .IsUnique(); + + b.HasIndex("Scheme", "Value") + .IsUnique(); + + b.ToTable("location_code", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.HasKey("LocationId", "Locale"); + + b.ToTable("location_i18n", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationLevelRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("Ordinal") + .HasColumnType("smallint") + .HasColumnName("ordinal"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Ordinal") + .IsUnique(); + + b.ToTable("location_level", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CatalogReleaseId") + .HasColumnType("uuid") + .HasColumnName("catalog_release_id"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LevelId") + .HasColumnType("uuid") + .HasColumnName("level_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("SupersedesId") + .HasColumnType("uuid") + .HasColumnName("supersedes_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_location_parent"); + + b.HasIndex("Path") + .HasDatabaseName("ix_location_path"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist"); + + b.HasIndex("CountryId", "ParentId") + .HasDatabaseName("ix_location_country_parent"); + + b.ToTable("location", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("OperationType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation_type"); + + b.Property("Rate") + .HasColumnType("numeric(6,4)") + .HasColumnName("rate"); + + b.Property("TaxSchemeId") + .HasColumnType("uuid") + .HasColumnName("tax_scheme_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("TaxSchemeId", "LocationId", "ValidFrom") + .HasDatabaseName("ix_tax_rate_scheme_location"); + + b.ToTable("tax_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxSchemeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Code") + .IsUnique(); + + b.ToTable("tax_scheme", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TimeZoneRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("country_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IanaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("iana_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.HasIndex("IanaId") + .IsUnique(); + + b.ToTable("time_zone", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ArtifactFieldSchemaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("artifact_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ArtifactType") + .IsUnique(); + + b.ToTable("artifact_field_schemas", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.EvidenceEdgeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AssertedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("asserted_by"); + + b.Property("EdgeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("edge_type"); + + b.Property("FromId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("from_id"); + + b.Property("FromKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("from_kind"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Provenance") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("provenance"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("to_id"); + + b.Property("ToKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("to_kind"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_from"); + + b.HasIndex("TenantId", "ToKind", "ToId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_to"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType", "ToKind", "ToId", "OccurredAtUtc") + .IsUnique() + .HasDatabaseName("ux_evidence_edges_identity"); + + b.ToTable("evidence_edges", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ExceptionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CriterionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("criterion_id"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at_utc"); + + b.Property("DecidedBy") + .HasColumnType("uuid") + .HasColumnName("decided_by"); + + b.Property("DecisionNotes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("decision_notes"); + + b.Property("ExceptionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("exception_type"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("Justification") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("justification"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("RequestedBy") + .HasColumnType("uuid") + .HasColumnName("requested_by"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_exception_requests_tenant_status"); + + b.HasIndex("TenantId", "SdlcExecutionId", "Phase") + .HasDatabaseName("ix_exception_requests_tenant_execution_phase"); + + b.ToTable("exception_requests", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("approval_ids"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DecisionBy") + .HasColumnType("uuid") + .HasColumnName("decision_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationIdsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evaluation_ids"); + + b.Property("EvidenceSnapshotRef") + .HasColumnType("text") + .HasColumnName("evidence_snapshot_ref"); + + b.Property("ExceptionIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("exception_ids"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("PolicySnapshotRef") + .HasColumnType("text") + .HasColumnName("policy_snapshot_ref"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rationale"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Phase"); + + b.HasIndex("SdlcExecutionId"); + + b.HasIndex("TenantId"); + + b.ToTable("gate_decisions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GatePolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("ApprovalPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approval_policy"); + + b.Property("ArtifactApplicabilityJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("artifact_applicability"); + + b.Property("CoreSignalRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("core_signal_rules"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationCriteriaJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evaluation_criteria"); + + b.Property("ExceptionPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("exception_policy"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequiredEvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_evidence"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("gate_policies", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateSubmissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AppliedCoreSignalRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("applied_core_signal_rules"); + + b.Property("ApprovalsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approvals"); + + b.Property("CoreSignalEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("core_signal_evaluation"); + + b.Property("CoreVerdictJson") + .HasColumnType("jsonb") + .HasColumnName("core_verdict"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CriteriaEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("criteria_evaluation"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("requested_by"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("state"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase"); + + b.ToTable("gate_submissions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.PhaseGateStateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid") + .HasColumnName("evaluated_by"); + + b.Property("GateStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gate_status"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phase"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase"); + + b.HasIndex("TenantId", "SdlcExecutionId"); + + b.ToTable("phase_gate_states", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.RuntimeApprovalRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Approver") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("approver"); + + b.Property("CorrelationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("ExecutionMode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("execution_mode"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("InitiativeId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("initiative_id"); + + b.Property("Intent") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("intent"); + + b.Property("ProductId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("product_id"); + + b.Property("Reason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reason"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at_utc"); + + b.Property("RequestedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("requested_by"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at_utc"); + + b.Property("SkillId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("skill_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubjectConfidence") + .HasColumnType("double precision") + .HasColumnName("subject_confidence"); + + b.Property("SubjectKind") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("subject_kind"); + + b.Property("SubjectPayloadJson") + .HasColumnType("jsonb") + .HasColumnName("subject_payload"); + + b.Property("SubjectRef") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("subject_ref"); + + b.Property("SubjectSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("subject_summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CorrelationId") + .IsUnique() + .HasDatabaseName("ix_runtime_approvals_tenant_correlation") + .HasFilter("correlation_id IS NOT NULL"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_runtime_approvals_tenant_status"); + + b.HasIndex("TenantId", "SubjectKind", "SubjectRef") + .HasDatabaseName("ix_runtime_approvals_tenant_subject") + .HasFilter("subject_kind IS NOT NULL"); + + b.ToTable("runtime_approvals", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.SDLCExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BlockedReason") + .HasColumnType("text") + .HasColumnName("blocked_reason"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ProductCode"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "InitiativeId") + .HasDatabaseName("ix_sdlc_executions_tenant_initiative"); + + b.ToTable("sdlc_executions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeDeadLetterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("intake_dead_letter", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("Status", "NextAttemptAtUtc"); + + b.ToTable("intake_outbox", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.PpmEndpointRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("none") + .HasColumnName("auth_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FieldBindingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("field_bindings"); + + b.Property("HealthState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("health_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sync_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasColumnType("text") + .HasColumnName("secret_ref"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("TelemetryUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("telemetry_url"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ppm_endpoints", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicBaselineRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AnchoredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("anchored_at"); + + b.Property("Budget") + .IsRequired() + .HasColumnType("text") + .HasColumnName("budget"); + + b.Property("ExpectedRoi") + .IsRequired() + .HasColumnType("text") + .HasColumnName("expected_roi"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("RiskLevel") + .IsRequired() + .HasColumnType("text") + .HasColumnName("risk_level"); + + b.Property("StrategicInitiativeId") + .HasColumnType("uuid") + .HasColumnName("strategic_initiative_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("StrategicInitiativeId"); + + b.HasIndex("StrategicInitiativeId", "Version") + .IsUnique(); + + b.ToTable("strategic_baselines", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicInitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("FeasibilityJson") + .HasColumnType("jsonb") + .HasColumnName("feasibility"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("IntakeState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("intake_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("ProductCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ProductName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("product_name"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IntakeState"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ExternalId") + .IsUnique(); + + b.ToTable("strategic_initiatives", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.CoreEvaluationTransactionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountableOwnersJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("accountable_owners"); + + b.Property("AttachmentJson") + .HasColumnType("jsonb") + .HasColumnName("attachment"); + + b.Property("BlockingViolationCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("blocking_violation_count"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CorrelationId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("ErrorCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("error_code"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("operation_id"); + + b.Property("Outcome") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("OverallVerdict") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("overall_verdict"); + + b.Property("ProducerSurface") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("producer_surface"); + + b.Property("ProducerVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("producer_version"); + + b.Property("RepositoryRevision") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("repository_revision"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedByJson") + .HasColumnType("jsonb") + .HasColumnName("requested_by"); + + b.Property("ResponseDataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_data"); + + b.Property("RulesExecutedJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("rules_executed"); + + b.Property("SchemaVersion") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("schema_version"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("VersionsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("versions"); + + b.Property("ViolationsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("violations"); + + b.HasKey("Id"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.HasIndex("TenantId", "CorrelationId") + .IsUnique(); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("core_evaluation_transactions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.ProviderConnectionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AclDirection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("acl_direction"); + + b.Property("AclFieldOwnership") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("acl_field_ownership"); + + b.Property("AclScopesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("acl_scopes"); + + b.Property("Capability") + .IsRequired() + .HasColumnType("text") + .HasColumnName("capability"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at_utc"); + + b.Property("Port") + .IsRequired() + .HasColumnType("text") + .HasColumnName("port"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("secret_ref"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SyncCursor") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("sync_cursor"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Capability"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("provider_connections", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.PublicationOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("capability"); + + b.Property("CheckIdentifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("check_identifier"); + + b.Property("ComponentIdentifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("component_identifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("last_error"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("state"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("State", "NextAttemptAtUtc"); + + b.HasIndex("AggregateId", "ComponentIdentifier", "CheckIdentifier") + .IsUnique() + .HasFilter("state = 'pending'"); + + b.ToTable("publication_outbox", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Metrics.PhaseSlaPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("SlaDays") + .HasColumnType("integer") + .HasColumnName("sla_days"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("phase_sla_policies", "tracker_metrics"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Products.Entities.ProductRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalRefsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("external_refs"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("OwnerIdentity") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("owner_identity"); + + b.Property("RepositoryAccessJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("repository_access"); + + b.Property("RepositoryUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Topology") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("topology"); + + b.Property("TopologySetJson") + .HasColumnType("jsonb") + .HasColumnName("topology_set"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("products", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.DefectRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceRefsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evidence_refs"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("Resolution") + .HasColumnType("text") + .HasColumnName("resolution"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TestCycleId") + .HasColumnType("uuid") + .HasColumnName("test_cycle_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TestCycleId"); + + b.HasIndex("ProductId", "Status"); + + b.ToTable("defects", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.TestCycleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("closed_at"); + + b.Property("ClosedBlocked") + .HasColumnType("integer") + .HasColumnName("closed_blocked"); + + b.Property("ClosedFailed") + .HasColumnType("integer") + .HasColumnName("closed_failed"); + + b.Property("ClosedPassed") + .HasColumnType("integer") + .HasColumnName("closed_passed"); + + b.Property("ClosureNote") + .HasColumnType("text") + .HasColumnName("closure_note"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Status"); + + b.ToTable("test_cycles", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.TestExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("evidence_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("outcome"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TestCaseRef") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("test_case_ref"); + + b.Property("TestCycleId") + .HasColumnType("uuid") + .HasColumnName("test_cycle_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TestCycleId", "Outcome"); + + b.ToTable("test_executions", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseArtifactRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ItemsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("items"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("VersionsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("versions"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase") + .IsUnique(); + + b.ToTable("phase_artifacts", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseProgressionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("HistoryJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("history"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("phase_progressions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc2.DeploymentRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Environment") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("environment"); + + b.Property("EvidenceRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("evidence_ref"); + + b.Property("FinishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("RollbackReason") + .HasColumnType("text") + .HasColumnName("rollback_reason"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Environment", "Outcome", "FinishedAtUtc"); + + b.ToTable("deployment_records", "tracker_release"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc2.PeerReviewRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ChangeRef") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("change_ref"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reviewed_at"); + + b.Property("ReviewerId") + .HasColumnType("uuid") + .HasColumnName("reviewer_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ChangeRef"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Outcome"); + + b.ToTable("peer_reviews", "tracker_construction"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantIntelligenceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AgentRegistrationsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("agent_registrations"); + + b.Property("CorpusRefsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("corpus_refs"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelRoutingJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("model_routing"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("tenant_intelligence", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("contact_email"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("display_name"); + + b.Property("GeoCatalogRelease") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("geo_catalog_release"); + + b.Property("GeoCountryId") + .HasColumnType("uuid") + .HasColumnName("geo_country_id"); + + b.Property("GeoDisplayPath") + .HasColumnType("text") + .HasColumnName("geo_display_path"); + + b.Property("GeoUbigeo") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("geo_ubigeo"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LocalizationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("localization"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("tier"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("tenants", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany("Codes") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Navigation("Codes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803171143_AddArtifactVersions.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803171143_AddArtifactVersions.cs new file mode 100644 index 00000000..113f0029 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260803171143_AddArtifactVersions.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + /// + public partial class AddArtifactVersions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "versions", + schema: "tracker_governance", + table: "phase_artifacts", + type: "jsonb", + nullable: false, + defaultValue: "[]"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "versions", + schema: "tracker_governance", + table: "phase_artifacts"); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs index 6989fbe4..83074701 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs @@ -1704,6 +1704,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("jsonb") .HasColumnName("approval_policy"); + b.Property("ArtifactApplicabilityJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("artifact_applicability"); + + b.Property("CoreSignalRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("core_signal_rules"); + b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -1786,11 +1800,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("id"); + b.Property("AppliedCoreSignalRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("applied_core_signal_rules"); + b.Property("ApprovalsJson") .IsRequired() .HasColumnType("jsonb") .HasColumnName("approvals"); + b.Property("CoreSignalEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("core_signal_evaluation"); + b.Property("CoreVerdictJson") .HasColumnType("jsonb") .HasColumnName("core_verdict"); @@ -2608,6 +2636,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDefaultValue("[]") .HasColumnName("accountable_owners"); + b.Property("AttachmentJson") + .HasColumnType("jsonb") + .HasColumnName("attachment"); + b.Property("BlockingViolationCount") .ValueGeneratedOnAdd() .HasColumnType("integer") @@ -3459,6 +3491,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("updated_by"); + b.Property("VersionsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("versions"); + b.HasKey("Id"); b.HasIndex("TenantId"); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/GatePolicyRecordConfiguration.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/GatePolicyRecordConfiguration.cs index c9866920..8712a0ee 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/GatePolicyRecordConfiguration.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/GatePolicyRecordConfiguration.cs @@ -17,6 +17,8 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.ApprovalPolicyJson).HasColumnName("approval_policy").HasColumnType("jsonb"); builder.Property(x => x.ExceptionPolicyJson).HasColumnName("exception_policy").HasColumnType("jsonb"); builder.Property(x => x.EvaluationCriteriaJson).HasColumnName("evaluation_criteria").HasColumnType("jsonb").HasDefaultValue("[]"); + builder.Property(x => x.CoreSignalRulesJson).HasColumnName("core_signal_rules").HasColumnType("jsonb").HasDefaultValue("[]"); + builder.Property(x => x.ArtifactApplicabilityJson).HasColumnName("artifact_applicability").HasColumnType("jsonb").HasDefaultValue("[]"); builder.Property(x => x.RequiresCoreVerdict).HasColumnName("requires_core_verdict").IsRequired(); builder.Property(x => x.Active).HasColumnName("active").IsRequired(); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/GateSubmissionRecordConfiguration.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/GateSubmissionRecordConfiguration.cs index 365a6936..94b9f6ab 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/GateSubmissionRecordConfiguration.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/GateSubmissionRecordConfiguration.cs @@ -19,6 +19,8 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.EvidenceJson).HasColumnName("evidence").HasColumnType("jsonb"); builder.Property(x => x.ApprovalsJson).HasColumnName("approvals").HasColumnType("jsonb"); builder.Property(x => x.CriteriaEvaluationJson).HasColumnName("criteria_evaluation").HasColumnType("jsonb").HasDefaultValue("[]"); + builder.Property(x => x.CoreSignalEvaluationJson).HasColumnName("core_signal_evaluation").HasColumnType("jsonb").HasDefaultValue("[]"); + builder.Property(x => x.AppliedCoreSignalRulesJson).HasColumnName("applied_core_signal_rules").HasColumnType("jsonb").HasDefaultValue("[]"); builder.Property(x => x.CoreVerdictJson).HasColumnName("core_verdict").HasColumnType("jsonb"); builder.Property(x => x.RequiresCoreVerdict).HasColumnName("requires_core_verdict").IsRequired(); builder.Property(x => x.State).HasColumnName("state").IsRequired().HasMaxLength(16); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/GatePolicyRecord.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/GatePolicyRecord.cs index d22b3841..6c35df31 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/GatePolicyRecord.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/GatePolicyRecord.cs @@ -17,6 +17,12 @@ public sealed class GatePolicyRecord : IAuditableRecord, ITenantScopedRecord /// a la persistencia y volvia siempre vacia, dejando la exencion imposible. public string ExceptionPolicyJson { get; set; } = "{}"; public string EvaluationCriteriaJson { get; set; } = "[]"; + + /// CP-06 — la matriz de señales del Core de este tenant para esta fase (jsonb). + public string CoreSignalRulesJson { get; set; } = "[]"; + + /// CP-02 — overlay del tenant sobre el catálogo de artefactos del Core (jsonb). + public string ArtifactApplicabilityJson { get; set; } = "[]"; public bool RequiresCoreVerdict { get; set; } public bool Active { get; set; } diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/GateSubmissionRecord.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/GateSubmissionRecord.cs index 6b665803..171477ba 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/GateSubmissionRecord.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/GateSubmissionRecord.cs @@ -17,6 +17,12 @@ public sealed class GateSubmissionRecord : IAuditableRecord, ITenantScopedRecord public string EvidenceJson { get; set; } = "[]"; public string ApprovalsJson { get; set; } = "[]"; public string CriteriaEvaluationJson { get; set; } = "[]"; + + /// CP-06 — qué se decidió sobre cada señal del Core (jsonb). + public string CoreSignalEvaluationJson { get; set; } = "[]"; + + /// CP-06 — las reglas vigentes CUANDO se decidió, copiadas (jsonb). + public string AppliedCoreSignalRulesJson { get; set; } = "[]"; public string? CoreVerdictJson { get; set; } public bool RequiresCoreVerdict { get; set; } public string State { get; set; } = string.Empty; diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlGatePolicyRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlGatePolicyRepository.cs index 691fad7b..f9403b26 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlGatePolicyRepository.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlGatePolicyRepository.cs @@ -52,6 +52,8 @@ public async Task UpdateAsync(GatePolicy aggregate, CancellationToken cancellati record.ApprovalPolicyJson = JsonSerializer.Serialize(aggregate.ApprovalPolicy, JsonOptions); record.ExceptionPolicyJson = JsonSerializer.Serialize(aggregate.ExceptionPolicy, JsonOptions); record.EvaluationCriteriaJson = JsonSerializer.Serialize(aggregate.EvaluationCriteria, JsonOptions); + record.CoreSignalRulesJson = JsonSerializer.Serialize(aggregate.CoreSignalRules, JsonOptions); + record.ArtifactApplicabilityJson = JsonSerializer.Serialize(aggregate.ArtifactApplicability, JsonOptions); record.RequiresCoreVerdict = aggregate.RequiresCoreVerdict; record.Active = aggregate.Active; record.UpdatedBy = aggregate.Props.Audit.UpdatedBy; @@ -72,6 +74,8 @@ private static GatePolicy MapToDomain(GatePolicyRecord record) ApprovalPolicy = DeserializeApprovalPolicy(record.ApprovalPolicyJson), ExceptionPolicy = DeserializeExceptionPolicy(record.ExceptionPolicyJson), EvaluationCriteria = DeserializeCriteria(record.EvaluationCriteriaJson), + CoreSignalRules = Deserializar>(record.CoreSignalRulesJson) ?? new List(), + ArtifactApplicability = Deserializar>(record.ArtifactApplicabilityJson) ?? new List(), RequiresCoreVerdict = record.RequiresCoreVerdict, Active = record.Active }; @@ -89,6 +93,8 @@ private static GatePolicy MapToDomain(GatePolicyRecord record) ApprovalPolicyJson = JsonSerializer.Serialize(aggregate.ApprovalPolicy, JsonOptions), ExceptionPolicyJson = JsonSerializer.Serialize(aggregate.ExceptionPolicy, JsonOptions), EvaluationCriteriaJson = JsonSerializer.Serialize(aggregate.EvaluationCriteria, JsonOptions), + CoreSignalRulesJson = JsonSerializer.Serialize(aggregate.CoreSignalRules, JsonOptions), + ArtifactApplicabilityJson = JsonSerializer.Serialize(aggregate.ArtifactApplicability, JsonOptions), RequiresCoreVerdict = aggregate.RequiresCoreVerdict, Active = aggregate.Active, CreatedBy = aggregate.Props.Audit.CreatedBy, @@ -147,6 +153,19 @@ private static ApprovalPolicy DeserializeApprovalPolicy(string json) } } + /// + /// CP-06 — un jsonb ilegible devuelve `null`, y el llamante lo traduce a lista vacía. + /// + /// Vacía es la lectura SEGURA: sin reglas todo es advisory y nada bloquea. Propagar la + /// excepción tumbaría la lectura de la política entera por una fila corrupta. + /// + private static T? Deserializar(string? json) where T : class + { + if (string.IsNullOrWhiteSpace(json)) return null; + try { return JsonSerializer.Deserialize(json, JsonOptions); } + catch (JsonException) { return null; } + } + private static List DeserializeCriteria(string json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlGateSubmissionRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlGateSubmissionRepository.cs index ced1f4c5..10afe235 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlGateSubmissionRepository.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlGateSubmissionRepository.cs @@ -1,3 +1,4 @@ +using Tracker.Domain.Governance.GatePolicy; using Tracker.Domain.Governance.Criteria; using Tracker.Domain.Governance.GateSubmission; @@ -68,6 +69,8 @@ public async Task UpdateAsync(GateSubmission aggregate, CancellationToken cancel record.EvidenceJson = JsonSerializer.Serialize(aggregate.Evidence, JsonOptions); record.ApprovalsJson = JsonSerializer.Serialize(aggregate.Approvals, JsonOptions); record.CriteriaEvaluationJson = JsonSerializer.Serialize(aggregate.CriteriaEvaluation, JsonOptions); + record.CoreSignalEvaluationJson = JsonSerializer.Serialize(aggregate.CoreSignalEvaluation, JsonOptions); + record.AppliedCoreSignalRulesJson = JsonSerializer.Serialize(aggregate.AppliedCoreSignalRules, JsonOptions); record.CoreVerdictJson = aggregate.CoreVerdict is null ? null : JsonSerializer.Serialize(aggregate.CoreVerdict, JsonOptions); @@ -93,6 +96,8 @@ private static GateSubmission MapToDomain(GateSubmissionRecord record) Evidence = Deserialize>(record.EvidenceJson) ?? new List(), Approvals = Deserialize>(record.ApprovalsJson) ?? new List(), CriteriaEvaluation = Deserialize>(record.CriteriaEvaluationJson) ?? new List(), + CoreSignalEvaluation = Deserialize>(record.CoreSignalEvaluationJson) ?? new List(), + AppliedCoreSignalRules = Deserialize>(record.AppliedCoreSignalRulesJson) ?? new List(), CoreVerdict = string.IsNullOrWhiteSpace(record.CoreVerdictJson) ? null : Deserialize(record.CoreVerdictJson!), @@ -116,6 +121,8 @@ private static GateSubmission MapToDomain(GateSubmissionRecord record) EvidenceJson = JsonSerializer.Serialize(aggregate.Evidence, JsonOptions), ApprovalsJson = JsonSerializer.Serialize(aggregate.Approvals, JsonOptions), CriteriaEvaluationJson = JsonSerializer.Serialize(aggregate.CriteriaEvaluation, JsonOptions), + CoreSignalEvaluationJson = JsonSerializer.Serialize(aggregate.CoreSignalEvaluation, JsonOptions), + AppliedCoreSignalRulesJson = JsonSerializer.Serialize(aggregate.AppliedCoreSignalRules, JsonOptions), CoreVerdictJson = aggregate.CoreVerdict is null ? null : JsonSerializer.Serialize(aggregate.CoreVerdict, JsonOptions), diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Configurations/CoreEvaluationTransactionRecordConfiguration.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Configurations/CoreEvaluationTransactionRecordConfiguration.cs index 52c561d7..6435e06b 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Configurations/CoreEvaluationTransactionRecordConfiguration.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Configurations/CoreEvaluationTransactionRecordConfiguration.cs @@ -55,6 +55,7 @@ public void Configure(EntityTypeBuilder builder builder.Property(x => x.AccountableOwnersJson).HasColumnName("accountable_owners").HasColumnType("jsonb").IsRequired().HasDefaultValue("[]"); builder.Property(x => x.BlockingViolationCount).HasColumnName("blocking_violation_count").IsRequired().HasDefaultValue(0); builder.Property(x => x.VersionsJson).HasColumnName("versions").HasColumnType("jsonb").IsRequired().HasDefaultValue("{}"); + builder.Property(x => x.AttachmentJson).HasColumnName("attachment").HasColumnType("jsonb"); builder.HasIndex(x => new { x.TenantId, x.Status }); builder.HasIndex(x => x.OperationId).IsUnique(); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Entities/CoreEvaluationTransactionRecord.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Entities/CoreEvaluationTransactionRecord.cs index 09100f74..7d7ffca4 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Entities/CoreEvaluationTransactionRecord.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/Entities/CoreEvaluationTransactionRecord.cs @@ -54,6 +54,9 @@ public sealed class CoreEvaluationTransactionRecord : IAuditableRecord, ITenantS /// Versiones de lo que produjo el veredicto (jsonb) — auditoría/reproducibilidad. public string VersionsJson { get; set; } = "{}"; + /// CP-07 — dónde cuelga del expediente SDLC (jsonb). NULL = sin adjuntar. + public string? AttachmentJson { get; set; } + public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } public string UpdatedBy { get; set; } = string.Empty; diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs index adbdaf13..bb131610 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs @@ -169,7 +169,11 @@ private static CoreEvaluationTransaction MapToDomain(CoreEvaluationTransactionRe AccountableOwners = DeserializeList(record.AccountableOwnersJson), BlockingViolationCount = record.BlockingViolationCount, Versions = Deserialize>(record.VersionsJson) - ?? new Dictionary() + ?? new Dictionary(), + // CP-07 — NULL es «sin adjuntar», que no es lo mismo que un vínculo vacío. + Attachment = record.AttachmentJson is null + ? null + : Deserialize(record.AttachmentJson) }; var result = CoreEvaluationTransaction.Create( @@ -204,6 +208,9 @@ private static void ApplyIngestFields( record.AccountableOwnersJson = JsonSerializer.Serialize(aggregate.AccountableOwners, JsonOptions); record.BlockingViolationCount = aggregate.BlockingViolationCount; record.VersionsJson = JsonSerializer.Serialize(aggregate.Versions, JsonOptions); + record.AttachmentJson = aggregate.Attachment is null + ? null + : JsonSerializer.Serialize(aggregate.Attachment, JsonOptions); } private static T? Deserialize(string? json) where T : class diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/Configurations/PhaseArtifactRecordConfiguration.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/Configurations/PhaseArtifactRecordConfiguration.cs index bbb076c2..344cf1f7 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/Configurations/PhaseArtifactRecordConfiguration.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/Configurations/PhaseArtifactRecordConfiguration.cs @@ -17,6 +17,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Phase).HasColumnName("phase").IsRequired().HasMaxLength(32); builder.Property(x => x.Status).HasColumnName("status").IsRequired().HasMaxLength(16); builder.Property(x => x.ItemsJson).HasColumnName("items").HasColumnType("jsonb"); + builder.Property(x => x.VersionsJson).HasColumnName("versions").HasColumnType("jsonb").HasDefaultValue("[]"); builder.Property(x => x.CreatedBy).HasColumnName("created_by").IsRequired(); builder.Property(x => x.CreatedAtUtc).HasColumnName("created_at").IsRequired(); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/Entities/PhaseArtifactRecord.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/Entities/PhaseArtifactRecord.cs index 4c97159a..c2c8e1b1 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/Entities/PhaseArtifactRecord.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/Entities/PhaseArtifactRecord.cs @@ -13,6 +13,9 @@ public sealed class PhaseArtifactRecord : IAuditableRecord, ITenantScopedRecord public string Status { get; set; } = string.Empty; public string ItemsJson { get; set; } = "[]"; + /// CP-14 — la historia de versiones del expediente (jsonb). + public string VersionsJson { get; set; } = "[]"; + public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } public string UpdatedBy { get; set; } = string.Empty; diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/PostgreSqlPhaseArtifactRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/PostgreSqlPhaseArtifactRepository.cs index 4dec8928..40b9a3bc 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/PostgreSqlPhaseArtifactRepository.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Sdlc/PostgreSqlPhaseArtifactRepository.cs @@ -40,6 +40,7 @@ public async Task UpdateAsync(DomainPhaseArtifact aggregate, CancellationToken c record.Status = aggregate.Status; record.ItemsJson = JsonSerializer.Serialize(aggregate.Items, JsonOptions); + record.VersionsJson = JsonSerializer.Serialize(aggregate.Versions, JsonOptions); record.UpdatedBy = aggregate.Props.Audit.UpdatedBy; record.UpdatedAtUtc = aggregate.Props.Audit.UpdatedAtUtc; } @@ -55,7 +56,8 @@ private static DomainPhaseArtifact MapToDomain(PhaseArtifactRecord record) InitiativeId = record.InitiativeId, Phase = record.Phase, Status = record.Status, - Items = Deserialize>(record.ItemsJson) ?? new List() + Items = Deserialize>(record.ItemsJson) ?? new List(), + Versions = Deserialize>(record.VersionsJson) ?? new List() }; return DomainPhaseArtifact.Rehydrate(props); @@ -69,6 +71,7 @@ private static DomainPhaseArtifact MapToDomain(PhaseArtifactRecord record) Phase = aggregate.Phase, Status = aggregate.Status, ItemsJson = JsonSerializer.Serialize(aggregate.Items, JsonOptions), + VersionsJson = JsonSerializer.Serialize(aggregate.Versions, JsonOptions), CreatedBy = aggregate.Props.Audit.CreatedBy, CreatedAtUtc = aggregate.Props.Audit.CreatedAtUtc, UpdatedBy = aggregate.Props.Audit.UpdatedBy, diff --git a/src/apps/tracker-api/Tracker.Infrastructure/ServiceDecorationExtensions.cs b/src/apps/tracker-api/Tracker.Infrastructure/ServiceDecorationExtensions.cs new file mode 100644 index 00000000..61032ba7 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/ServiceDecorationExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Tracker.Infrastructure; + +/// +/// Replace an already-registered service with a decorator that wraps it. +/// +/// Implemented here because the .NET container does not ship it; the pattern is the one +/// Scrutor uses. The original registration is resolved by hand and handed to the decorator, so +/// that registration stays the single description of how the inner service is built. +/// +/// PROMOTED from a private helper inside the transparency wiring (GAP-004): a second caller +/// appeared, and copying it would have created two definitions of the same mechanism — the exact +/// defect this repository has spent the week removing from its gate corpora. +/// +public static class ServiceDecorationExtensions +{ + public static IServiceCollection DecorateService(this IServiceCollection services) + where TService : class + where TDecorator : class, TService + { + var original = services.LastOrDefault(d => d.ServiceType == typeof(TService)) + ?? throw new InvalidOperationException( + $"There is no registered {typeof(TService).Name} to decorate."); + + services.Remove(original); + + object CreateInner(IServiceProvider sp) => + original.ImplementationInstance + ?? original.ImplementationFactory?.Invoke(sp) + ?? ActivatorUtilities.CreateInstance(sp, original.ImplementationType!); + + services.Add(new ServiceDescriptor( + typeof(TService), + sp => ActivatorUtilities.CreateInstance(sp, CreateInner(sp)), + original.Lifetime)); + + return services; + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs index af09ca75..18c7fa31 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs @@ -46,7 +46,7 @@ public static IServiceCollection AddTransparencySigning( services.TryAddSingleton(new MerkleTransparencyService(issuer, transparencyService)); services.TryAddSingleton(new JsonlTransparencyLedger(options.LedgerPath)); - services.Decorate(); + services.DecorateService(); return services; } @@ -70,33 +70,4 @@ private static byte[] RequireSeed(string? base64, string name) return seed; } - /// - /// Sustituye un servicio ya registrado por un decorador que lo envuelve. - /// - /// Se implementa aquí porque el contenedor de .NET no lo trae; el patrón es el mismo que - /// usa Scrutor. Resuelve la implementación original a mano y se la pasa al decorador, de forma - /// que el registro previo sigue siendo la única fuente de cómo se construye. - /// - private static IServiceCollection Decorate(this IServiceCollection services) - where TService : class - where TDecorator : class, TService - { - var original = services.LastOrDefault(d => d.ServiceType == typeof(TService)) - ?? throw new InvalidOperationException( - $"Transparency: no hay ningun {typeof(TService).Name} registrado que decorar."); - - services.Remove(original); - - object CreateInner(IServiceProvider sp) => - original.ImplementationInstance - ?? original.ImplementationFactory?.Invoke(sp) - ?? ActivatorUtilities.CreateInstance(sp, original.ImplementationType!); - - services.Add(new ServiceDescriptor( - typeof(TService), - sp => ActivatorUtilities.CreateInstance(sp, CreateInner(sp)), - original.Lifetime)); - - return services; - } } diff --git a/src/apps/tracker-api/Tracker.Presentation/Auth/TrackerPermissions.cs b/src/apps/tracker-api/Tracker.Presentation/Auth/TrackerPermissions.cs index 11797b6d..3d076fb8 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Auth/TrackerPermissions.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Auth/TrackerPermissions.cs @@ -59,6 +59,16 @@ public static class TrackerPermissions /// rutas de fichero de ese tenant. /// public const string CoreTransactionIngest = "tracker:core-transaction:ingest"; + + /// + /// CP-07 — ADJUNTAR un depósito ya existente al expediente SDLC de una iniciativa y fase. + /// + /// Aparte de a propósito: depositar es una operación + /// de MÁQUINA (el Core entrega un veredicto que produjo) y adjuntar es una decisión de + /// GOBIERNO sobre dónde encaja esa evidencia en el expediente. Una clave de máquina filtrada + /// no debe poder colgar evidencia en la fase que alguien está intentando cerrar. + /// + public const string CoreTransactionAttach = "tracker:core-transaction:attach"; public const string TenantRead = "tracker:tenant:read"; public const string TenantCreate = "tracker:tenant:create"; public const string ProductRead = "tracker:product:read"; diff --git a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/Bootstrappers/TrackerIntegrationGatewaysBootstrapper.cs b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/Bootstrappers/TrackerIntegrationGatewaysBootstrapper.cs index 64622b99..2f9a4179 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/Bootstrappers/TrackerIntegrationGatewaysBootstrapper.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/Bootstrappers/TrackerIntegrationGatewaysBootstrapper.cs @@ -85,6 +85,17 @@ public void Run() } }); + // GAP-004 / GT-650 — the Core publishes its artifact registry since ADR-0125 landed, so the + // hand-built `core-standin` mirror can be replaced. The sync source DECORATES the stand-in + // and falls back to it when the Core is unreachable, stamping the provenance so a consumer + // reads which one it got instead of trusting that a sync happened. + // DECORATED, not replaced: the stand-in stays `internal` to Application and keeps being the + // fallback, exactly as the transparency signing wire decorates the audit repository. + Tracker.Infrastructure.ServiceDecorationExtensions.DecorateService< + Tracker.Application.Governance.ArtifactFieldSchema.IPhaseArtifactProfileSource, + Tracker.Infrastructure.Governance.CoreSyncPhaseArtifactProfileSource>(Result); + Result.AddHostedService(); + // Repository source reader: the GitHub reader is HttpClient-backed against the GitHub // REST API (own base + User-Agent). The resolver dispatches by provider and the context // builder (Application) consumes it to assemble the inline satellite context. diff --git a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs index 31207e8c..0f4eafcc 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs @@ -111,6 +111,7 @@ public static WebApplication MapTrackerApiSurface(this WebApplication app) api.MapGatePolicyEndpoints(); api.MapGateSubmissionEndpoints(); api.MapArtifactFieldSchemaEndpoints(); + api.MapGovernancePackageEndpoints(); api.MapEvidenceRecordEndpoints(); api.MapAuditEntryEndpoints(); api.MapArchitectureReferenceEndpoints(); diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/ArtifactFieldSchemaEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/ArtifactFieldSchemaEndpoints.cs index b57cfec8..a23f2d3e 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/ArtifactFieldSchemaEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/ArtifactFieldSchemaEndpoints.cs @@ -15,9 +15,12 @@ public static void MapArtifactFieldSchemaEndpoints(this IEndpointRouteBuilder ap var group = app.MapGroup("/artifact-field-schemas").WithTags("GateGovernance"); // Core-authoritative base artifacts per phase (read-only). PRD required in Discovery, etc. - app.MapGet("/phase-artifact-profiles", async (IMediator mediator, CancellationToken ct) => + app.MapGet("/phase-artifact-profiles", async ( + ITrackerUserContext user, IMediator mediator, CancellationToken ct) => { - var result = await mediator.Send(new GetPhaseArtifactProfilesQuery(), ct); + // CP-02 — el tenant es el de la SESIÓN. Aceptarlo por parámetro convertiría este + // endpoint en una forma de leer la configuración de artefactos de otro cliente. + var result = await mediator.Send(new GetPhaseArtifactProfilesQuery(user.TenantId), ct); return Results.Ok(result); }) .RequireTrackerPermission(TrackerPermissions.GatePolicyRead) diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GatePolicyEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GatePolicyEndpoints.cs index d300b5cd..5883c06b 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GatePolicyEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GatePolicyEndpoints.cs @@ -68,7 +68,11 @@ public static void MapGatePolicyEndpoints(this IEndpointRouteBuilder app) (request.EvaluationCriteria ?? new List()) .Select(c => new UpsertGatePolicyCriterion( c.Id, c.Label, c.ArtifactType, c.FieldPath, c.Operator, - c.Expected ?? new List(), c.Mandatory, c.Severity)).ToList()); + c.Expected ?? new List(), c.Mandatory, c.Severity)).ToList(), + (request.CoreSignalRules ?? new List()) + .Select(r => new UpsertGatePolicyCoreSignal(r.Signal, r.Disposition, r.MinSeverity)).ToList(), + (request.ArtifactApplicability ?? new List()) + .Select(r => new UpsertGatePolicyArtifactApplicability(r.ArtifactKind, r.Applicability)).ToList()); var result = await mediator.Send(command, ct); return result.ToOk(); @@ -103,6 +107,29 @@ public sealed class UpsertGatePolicyCriterionRequest public string Severity { get; init; } = "blocking"; } +/// CP-06 — una fila de la matriz de señales del Core. +public sealed class UpsertGatePolicyCoreSignalRequest +{ + /// El result kind o dimensión, tal y como el Core la nombra (`design`, `phaseArtifacts`, …). + public string Signal { get; init; } = string.Empty; + + /// `blocking`, `advisory`, `hidden` o `not-applicable`. Una desconocida se rechaza. + public string Disposition { get; init; } = string.Empty; + + /// `info`, `warning` o `error`. Ausente significa cualquier severidad. + public string? MinSeverity { get; init; } +} + +/// CP-02 — una fila del overlay de artefactos del tenant. +public sealed class UpsertGatePolicyArtifactApplicabilityRequest +{ + /// El artefacto tal y como lo nombra el catálogo del Core (`prd`, `coverage-report`, …). + public string ArtifactKind { get; init; } = string.Empty; + + /// `required`, `optional` o `not-applicable`. Una desconocida se rechaza. + public string Applicability { get; init; } = string.Empty; +} + public sealed class UpsertGatePolicyRequest { public string Mode { get; init; } = "SIMPLE"; @@ -113,4 +140,16 @@ public sealed class UpsertGatePolicyRequest public bool RequiresCoreVerdict { get; init; } public bool Active { get; init; } = true; public List? EvaluationCriteria { get; init; } + + /// + /// CP-06 — la matriz. Ausente u omitida NO cambia la conducta: sin reglas, toda señal del + /// Core es advisory y ninguna detiene la fase. + /// + public List? CoreSignalRules { get; init; } + + /// + /// CP-02 — el overlay. Ausente u omitido NO cambia nada: sin marcas se sirve el estándar del + /// Core tal cual. + /// + public List? ArtifactApplicability { get; init; } } diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs new file mode 100644 index 00000000..7ec3cb35 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs @@ -0,0 +1,167 @@ +using Tracker.Application.Governance.GovernancePackage; +using Tracker.Domain.Governance.GovernancePackage; + +namespace Tracker.Presentation.Endpoints.Governance; + +using Package = Tracker.Domain.Governance.GovernancePackage.GovernancePackage; + +/// +/// GT-532 — empaquetar la gobernanza de un cliente: exportarla como documento portable y aplicarla +/// sobre un tenant. +/// +/// EL TENANT SALE SIEMPRE DE LA SESIÓN, en las dos operaciones. Ni la exportación lo +/// acepta como parámetro ni la aplicación lo acepta en el cuerpo. Un `tenantId` en cualquiera de +/// los dos sitios convertiría estos endpoints en una forma de leer o reescribir la gobernanza de +/// otro cliente, y el propio documento tampoco lleva su tenant de origen dentro, precisamente para +/// que ni siquiera por accidente pueda usarse como destino. +/// +/// Lo que el producto NO entrega aquí es contenido. Estos endpoints son el mecanismo; +/// no existen paquetes de fábrica con nuestra opinión sobre qué debe exigir una compuerta. Según +/// `T-056` la validación de contenido es configuración del tenant y no código del motor. +/// +public static class GovernancePackageEndpoints +{ + public static void MapGovernancePackageEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/governance-packages").WithTags("GateGovernance"); + + group.MapGet("/export", async ( + ITrackerUserContext user, + IMediator mediator, + string? name, + string? version, + string? description, + CancellationToken ct) => + { + var result = await mediator.Send(new ExportGovernancePackageQuery( + user.TenantId, + string.IsNullOrWhiteSpace(name) ? "governance" : name, + string.IsNullOrWhiteSpace(version) ? DateTime.UtcNow.ToString("yyyy.MM.dd") : version, + description ?? string.Empty, + // Procedencia como TEXTO. Nunca el identificador: ver la nota del tipo de dominio. + "tenant de la sesion"), ct); + + return Results.Ok(result); + }) + .RequireTrackerPermission(TrackerPermissions.GatePolicyRead) + .WithName("ExportGovernancePackage"); + + group.MapPost("/apply", async ( + GovernancePackageRequest request, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var package = new Package + { + Name = request.Name ?? string.Empty, + Version = request.Version ?? string.Empty, + Description = request.Description ?? string.Empty, + ExportedFrom = request.ExportedFrom ?? string.Empty, + ExportedAtUtc = request.ExportedAtUtc ?? default, + GatePolicies = (request.GatePolicies ?? new List()) + .Select(p => new PackagedGatePolicy + { + Phase = p.Phase ?? string.Empty, + Mode = p.Mode ?? string.Empty, + RequiredEvidence = (p.RequiredEvidence ?? new List()) + .Select(e => new PackagedEvidence(e.Type ?? string.Empty, e.Mandatory)).ToList(), + ApprovalStrategy = p.ApprovalStrategy ?? string.Empty, + ApprovalStages = p.ApprovalStages ?? string.Empty, + Approvers = (p.Approvers ?? new List()) + .Select(a => new PackagedApprover(a.RoleOrIdentity ?? string.Empty, a.Stage, a.Quorum)).ToList(), + RequiresCoreVerdict = p.RequiresCoreVerdict, + Active = p.Active, + EvaluationCriteria = (p.EvaluationCriteria ?? new List()) + .Select(c => new PackagedCriterion( + c.Id ?? string.Empty, c.Label ?? string.Empty, c.ArtifactType ?? string.Empty, + c.FieldPath ?? string.Empty, c.Operator ?? string.Empty, + c.Expected ?? new List(), c.Mandatory, c.Severity ?? string.Empty)).ToList(), + }).ToList(), + ArtifactFieldSchemas = (request.ArtifactFieldSchemas ?? new List()) + .Select(s => new PackagedArtifactFieldSchema + { + ArtifactType = s.ArtifactType ?? string.Empty, + CustomFields = (s.CustomFields ?? new List()) + .Select(f => new PackagedCustomField( + f.Name ?? string.Empty, f.Label ?? string.Empty, f.DataType ?? "string", + f.EligibleValues ?? new List(), f.Required)).ToList(), + }).ToList(), + }; + + var result = await mediator.Send( + new ApplyGovernancePackageCommand(user.TenantId, user.ActorId, package), ct); + return result.ToOk(); + }) + .RequireTrackerPermission(TrackerPermissions.GatePolicyWrite) + .WithName("ApplyGovernancePackage"); + } +} + +/// +/// Cuerpo de una aplicación. Deliberadamente SIN `tenantId`: el destino es el tenant de la sesión, +/// y aceptarlo aquí sería una forma de escribir en otro cliente. +/// +public sealed class GovernancePackageRequest +{ + public string? Name { get; init; } + public string? Version { get; init; } + public string? Description { get; init; } + public string? ExportedFrom { get; init; } + public DateTime? ExportedAtUtc { get; init; } + public List? GatePolicies { get; init; } + public List? ArtifactFieldSchemas { get; init; } +} + +public sealed class PackagedGatePolicyRequest +{ + public string? Phase { get; init; } + public string? Mode { get; init; } + public List? RequiredEvidence { get; init; } + public string? ApprovalStrategy { get; init; } + public string? ApprovalStages { get; init; } + public List? Approvers { get; init; } + public bool RequiresCoreVerdict { get; init; } + public bool Active { get; init; } = true; + public List? EvaluationCriteria { get; init; } +} + +public sealed class PackagedEvidenceRequest +{ + public string? Type { get; init; } + public bool Mandatory { get; init; } +} + +public sealed class PackagedApproverRequest +{ + public string? RoleOrIdentity { get; init; } + public int Stage { get; init; } + public int Quorum { get; init; } +} + +public sealed class PackagedCriterionRequest +{ + public string? Id { get; init; } + public string? Label { get; init; } + public string? ArtifactType { get; init; } + public string? FieldPath { get; init; } + public string? Operator { get; init; } + public List? Expected { get; init; } + public bool Mandatory { get; init; } + public string? Severity { get; init; } +} + +public sealed class PackagedArtifactFieldSchemaRequest +{ + public string? ArtifactType { get; init; } + public List? CustomFields { get; init; } +} + +public sealed class PackagedCustomFieldRequest +{ + public string? Name { get; init; } + public string? Label { get; init; } + public string? DataType { get; init; } + public List? EligibleValues { get; init; } + public bool Required { get; init; } +} diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationDtos.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationDtos.cs index bd6d9650..3a8b4e12 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationDtos.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationDtos.cs @@ -84,6 +84,108 @@ public sealed class CoreEvaluationVerdictView public CoreGateEvaluationView[] Gates { get; init; } = []; public Dictionary Summary { get; init; } = new(); public DateTime? EvaluatedAt { get; init; } + + /// + /// CP-05 — la inteligencia del resultado canónico, TIPADA. + /// + /// El Tracker se quedaba con overallVerdict y results.gate y tiraba el + /// resto: quién completaba una fase recibía un pass/fail parcial y ninguna acción. Esto lo + /// conserva. + /// + /// Por qué campos y no el saco Summary. Es la misma lección que dejó + /// escrita EAG-03 unas líneas más abajo: una bolsa opaca «llegaba, se guardaba y no se podía + /// interrogar». Un dato que no se puede consultar no participa en ninguna decisión. + /// + public CoreEvaluationIntelligenceView? Intelligence { get; init; } +} + +/// +/// CP-05 — trazabilidad, diagnóstico y procedencia del veredicto. +/// +/// Nada de esto decide: son hechos del motor. Qué bloquea una fase de este tenant es +/// configuración suya (T-056) y vive en CP-06; aquí sólo se conserva sin pérdida. +/// +public sealed class CoreEvaluationIntelligenceView +{ + public CoreRuleExecutionView[] RulesExecuted { get; init; } = []; + public CoreRuleExecutionView[] PoliciesApplied { get; init; } = []; + public CoreRiskView[] Risks { get; init; } = []; + public CoreRecommendationView[] Recommendations { get; init; } = []; + public CoreRequiredActionView[] RequiredActions { get; init; } = []; + public CoreQualitySignalView[] QualitySignals { get; init; } = []; + + /// + /// CP-05 — los sub-resultados por kind que el Tracker no interroga, conservados en crudo. + /// + /// El gate sólo usa `gate`; los otros diez del contrato se descartaban en este límite. + /// Aquí llegan intactos, y el que el Core añada mañana también. + /// + public Dictionary OtherResultKinds { get; init; } = new(); + + /// + /// Qué kinds trajo la respuesta, `gate` incluido y ordenados. Es lo que hace CONSULTABLE la + /// parte cruda: se puede preguntar «¿el Core evaluó `design`?» sin abrir el JSON ni depender + /// de una forma que aquí no se declara. + /// + public string[] KindsPresent { get; init; } = []; + + /// Correlaciona esta evaluación con la corrida del Core que la produjo. + public string? CorrelationId { get; init; } + + /// Versión del contrato de resultado. Sin ella, un cambio de forma es indetectable. + public string? SchemaVersion { get; init; } + + public string? CoreVersion { get; init; } + public string? Ruleset { get; init; } + public string? RulesetVersion { get; init; } +} + +public sealed class CoreRuleExecutionView +{ + public string? RuleId { get; init; } + public string? RulesetRef { get; init; } + public string? Engine { get; init; } + public string? Verdict { get; init; } +} + +public sealed class CoreRiskView +{ + public string? Id { get; init; } + public string? Level { get; init; } + public string? Category { get; init; } + public string? Message { get; init; } + public string? Location { get; init; } + public string? RuleRef { get; init; } +} + +public sealed class CoreRecommendationView +{ + public string? Id { get; init; } + public string? Kind { get; init; } + public string? Message { get; init; } + public string? Rationale { get; init; } + public string[] References { get; init; } = []; +} + +public sealed class CoreRequiredActionView +{ + public string? Id { get; init; } + public string? GapId { get; init; } + public string? Description { get; init; } + + /// Lo que dice el MOTOR. No es la decisión de la compuerta de este tenant. + public bool Blocking { get; init; } + + public string? Remediation { get; init; } +} + +public sealed class CoreQualitySignalView +{ + public string? Dimension { get; init; } + public string? Status { get; init; } + + /// Cuántas evidencias sostienen la señal. Cero con estado present es una contradicción visible. + public int EvidenceCount { get; init; } } public sealed class CoreGateEvaluationView @@ -213,12 +315,128 @@ internal sealed class CoreEvaluationEnvelope [JsonPropertyName("evaluatedAt")] public DateTime? EvaluatedAt { get; init; } + + // CP-05 — trazabilidad y diagnostico del resultado canonico. Cada uno de estos campos se + // verifico contra `EvaluationResult` en el Core (`core-domain/evaluation/contracts/ + // evaluation-result.ts`) ANTES de escribirlo aqui: un campo que el Core no publica nunca se + // llena, y un DTO lleno de nulos permanentes se lee como cobertura igual que una alerta sobre + // una metrica que nadie emite. + [JsonPropertyName("rulesExecuted")] + public CoreRuleExecutionRefDto[]? RulesExecuted { get; init; } + + [JsonPropertyName("policiesApplied")] + public CoreRuleExecutionRefDto[]? PoliciesApplied { get; init; } + + [JsonPropertyName("risks")] + public CoreRiskFindingDto[]? Risks { get; init; } + + [JsonPropertyName("recommendations")] + public CoreRecommendationDto[]? Recommendations { get; init; } + + [JsonPropertyName("requiredActions")] + public CoreRequiredActionDto[]? RequiredActions { get; init; } + + /// Opcional en el contrato del Core: un contexto sin evidencia no lo trae. + [JsonPropertyName("qualitySignals")] + public CoreQualitySignalDto[]? QualitySignals { get; init; } + + [JsonPropertyName("correlationId")] + public string? CorrelationId { get; init; } + + [JsonPropertyName("schemaVersion")] + public string? SchemaVersion { get; init; } + + [JsonPropertyName("versions")] + public CoreVersionsDto? Versions { get; init; } +} + +/// Espejo de RuleExecutionRef. Qué corrió y con qué veredicto. +internal sealed class CoreRuleExecutionRefDto +{ + [JsonPropertyName("ruleId")] public string? RuleId { get; init; } + [JsonPropertyName("rulesetRef")] public string? RulesetRef { get; init; } + [JsonPropertyName("engine")] public string? Engine { get; init; } + [JsonPropertyName("verdict")] public string? Verdict { get; init; } +} + +/// Espejo de RiskFinding. Distinto de un gap: no es un incumplimiento. +internal sealed class CoreRiskFindingDto +{ + [JsonPropertyName("id")] public string? Id { get; init; } + [JsonPropertyName("level")] public string? Level { get; init; } + [JsonPropertyName("category")] public string? Category { get; init; } + [JsonPropertyName("message")] public string? Message { get; init; } + [JsonPropertyName("location")] public string? Location { get; init; } + [JsonPropertyName("ruleRef")] public string? RuleRef { get; init; } +} + +/// Espejo de Recommendation. +internal sealed class CoreRecommendationDto +{ + [JsonPropertyName("id")] public string? Id { get; init; } + [JsonPropertyName("kind")] public string? Kind { get; init; } + [JsonPropertyName("message")] public string? Message { get; init; } + [JsonPropertyName("rationale")] public string? Rationale { get; init; } + [JsonPropertyName("references")] public string[]? References { get; init; } +} + +/// +/// Espejo de RequiredAction. blocking viene del Core y se conserva TAL CUAL: es +/// información del motor, no la decisión del Tracker. Qué bloquea una fase de este tenant lo +/// resuelve CP-06, no este límite (T-056). +/// +internal sealed class CoreRequiredActionDto +{ + [JsonPropertyName("id")] public string? Id { get; init; } + [JsonPropertyName("gapId")] public string? GapId { get; init; } + [JsonPropertyName("description")] public string? Description { get; init; } + [JsonPropertyName("blocking")] public bool? Blocking { get; init; } + [JsonPropertyName("remediation")] public string? Remediation { get; init; } +} + +/// +/// Espejo de EvidenceSignal. La evidencia que lo respalda NO se re-espeja aquí: su forma +/// es del Core y copiarla sería inventar contrato. Se conserva la dimensión, el estado y cuánta +/// evidencia la sostiene, que es lo que hace interrogable la señal. +/// +internal sealed class CoreQualitySignalDto +{ + [JsonPropertyName("dimension")] public string? Dimension { get; init; } + [JsonPropertyName("status")] public string? Status { get; init; } + [JsonPropertyName("evidence")] public JsonElement[]? Evidence { get; init; } +} + +/// Espejo de versions: con qué motor y ruleset se produjo el veredicto. +internal sealed class CoreVersionsDto +{ + [JsonPropertyName("core")] public string? Core { get; init; } + [JsonPropertyName("ruleset")] public string? Ruleset { get; init; } + [JsonPropertyName("rulesetVersion")] public string? RulesetVersion { get; init; } } +/// +/// CP-05 — los sub-resultados por kind. El Core publica ONCE (`gate`, `artifact`, `evidence`, +/// `architecture`, `blueprint`, `topology`, `checkpoint`, `deployment`, `compliance`, `design`, +/// `phaseArtifacts`) y el Tracker sólo interroga `gate`. +/// +/// Los otros diez se CONSERVAN en crudo. `JsonExtensionData` recoge cualquier +/// propiedad no declarada, así que ningún kind se pierde — y tampoco se perderá uno que el Core +/// añada mañana, que es la parte que un espejo escrito a mano no puede prometer. +/// +/// Por qué crudo y no diez DTOs tipados. Tipar diez formas que ningún consumidor lee +/// todavía sería inventar contrato y mantenerlo sin uso; el fichero ya lleva escrita la regla — +/// «lo que el Core no manda, aquí no aparece». La diferencia con la bolsa opaca que EAG-03 +/// eliminó es el consumidor: aquellos `gaps` los NECESITABA la decisión de la compuerta y por eso +/// tenían que ser interrogables. Estos no los lee nadie aún; el requisito es no perderlos. El día +/// que un consumidor pida uno, se tipa ese — por demanda, no por especulación. +/// internal sealed class CoreEvaluationResults { [JsonPropertyName("gate")] public CoreEvaluationGateEnvelope[]? Gate { get; init; } + + [JsonExtensionData] + public Dictionary? OtherKinds { get; init; } } internal sealed class CoreEvaluationGateEnvelope 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 aa67a6fe..1266f39b 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationEndpoints.cs @@ -56,13 +56,48 @@ public static void MapCoreEvaluationEndpoints(this IEndpointRouteBuilder app) } else { - transaction.Complete(new Dictionary + // CP-05 — el asiento conserva lo que el Core devolvió, no un resumen de cuatro + // campos. Antes, la inteligencia del veredicto llegaba a la RESPUESTA y moría + // aquí: el ledger guardaba decision/status/phase/rulesets y nada más. + var intel = result.ResponseResult.Intelligence; + + var responseData = new Dictionary { ["decision"] = result.ResultDecision, ["status"] = result.Status, ["phase"] = result.Phase, ["rulesetsApplied"] = result.RulesetsApplied - }); + }; + + if (intel is not null) + { + // Lo que no tiene columna propia se conserva en el jsonb que ya existe. No se + // añaden columnas tipadas por si acaso: las que hay se tiparon cuando alguien + // necesitó consultarlas (enrutar por dueño, contar bloqueantes). Cuando la UI + // de CP-05 diga qué consulta de verdad, se tipa eso y no antes. + responseData["policiesApplied"] = intel.PoliciesApplied; + responseData["risks"] = intel.Risks; + responseData["recommendations"] = intel.Recommendations; + responseData["requiredActions"] = intel.RequiredActions; + responseData["qualitySignals"] = intel.QualitySignals; + responseData["resultKinds"] = intel.KindsPresent; + responseData["otherResultKinds"] = intel.OtherResultKinds; + } + + transaction.CompleteFromCore( + responseData, + producerSurface: "tracker-gateway", + correlationId: intel?.CorrelationId, + schemaVersion: intel?.SchemaVersion, + overallVerdict: result.ResponseResult.Summary.TryGetValue("overallVerdict", out var ov) + ? ov as string + : null, + outcome: result.ResponseResult.Summary.TryGetValue("outcome", out var oc) + ? oc as string + : null, + evaluatedAtUtc: result.ResponseResult.EvaluatedAt, + rulesExecuted: MapRulesExecuted(intel), + versions: MapVersions(intel)); } await transactions.AddAsync(transaction, ct); @@ -79,4 +114,39 @@ public static void MapCoreEvaluationEndpoints(this IEndpointRouteBuilder app) .Produces(401) .Produces(403); } + + /// + /// CP-05 — las reglas ejecutadas, en el vocabulario del dominio. + /// + /// Engine y Verdict son obligatorios en el agregado y el Core puede no + /// mandarlos. Una regla sin motor se registra con motor unknown en vez de descartarse: + /// perder la ejecución entera borraría la prueba de que la regla corrió, mientras que decir + /// «no sé con qué motor» es cierto y comprobable. Coaccionarla a un motor conocido sería lo + /// único inaceptable, y es justo lo que el propio contrato advierte. + /// + internal static IReadOnlyList MapRulesExecuted( + CoreEvaluationIntelligenceView? intel) + => intel is null + ? [] + : [.. intel.RulesExecuted + .Where(r => !string.IsNullOrWhiteSpace(r.RuleId)) + .Select(r => new CoreEvaluationRuleExecution + { + RuleId = r.RuleId!, + RulesetRef = r.RulesetRef, + Engine = string.IsNullOrWhiteSpace(r.Engine) ? "unknown" : r.Engine!, + Verdict = string.IsNullOrWhiteSpace(r.Verdict) ? "unknown" : r.Verdict!, + })]; + + /// Sólo las versiones que el Core mandó: una clave ausente no se inventa vacía. + internal static IReadOnlyDictionary MapVersions(CoreEvaluationIntelligenceView? intel) + { + var versions = new Dictionary(StringComparer.Ordinal); + if (intel is null) return versions; + + if (!string.IsNullOrWhiteSpace(intel.CoreVersion)) versions["core"] = intel.CoreVersion!; + if (!string.IsNullOrWhiteSpace(intel.Ruleset)) versions["ruleset"] = intel.Ruleset!; + if (!string.IsNullOrWhiteSpace(intel.RulesetVersion)) versions["rulesetVersion"] = intel.RulesetVersion!; + return versions; + } } diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs index 3f461dce..a132e837 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs @@ -119,6 +119,66 @@ public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuil // `repositoryUrl` y `since`. Sin ellos el unico acceso era «todo el tenant», asi que // interrogar el historial de UN repositorio obligaba a descargarlo entero y filtrar en el // cliente. Sin filtros la respuesta es identica a la de siempre (mas reciente primero). + // CP-07 — adjuntar un depósito al expediente SDLC. Es un VÍNCULO: el veredicto no se + // copia ni se reinterpreta, sigue viviendo en la fila que el Core depositó. + group.MapPost("/{id:guid}/attachment", async ( + Guid id, + AttachCoreEvaluationRequest request, + ITrackerUserContext user, + ICoreEvaluationTransactionRepository repository, + IUnitOfWork unitOfWork, + CancellationToken ct) => + { + var transaction = await repository.GetByIdAsync(id, ct); + // Un depósito de otro tenant se responde NO ENCONTRADO y no «prohibido»: distinguirlos + // convertiría este endpoint en una forma de averiguar qué ids existen en otro cliente. + if (transaction is null || transaction.TenantId != user.TenantId) + return Results.NotFound(); + + var attached = transaction.AttachTo( + user.TenantId, + request.InitiativeId, + request.Phase, + user.ActorId.ToString(), + request.ArtifactKind, + request.GateSubmissionId, + request.ArtifactVersion); + + if (attached.IsFailure) + return Results.BadRequest(new { error = attached.Error }); + + await repository.UpdateAsync(transaction, ct); + await unitOfWork.SaveEntitiesAsync(ct); + return Results.Ok(CoreEvaluationTransactionDto.From(transaction)); + }) + .RequireTrackerPermission(TrackerPermissions.CoreTransactionAttach) + .WithName("AttachCoreEvaluationTransaction") + .Produces(200) + .Produces(404); + + // CP-07 — adjuntar mal es un error humano corriente y tiene que ser reversible. Lo que NO + // se deshace es el depósito, que es el registro de lo que el Core dijo. + group.MapDelete("/{id:guid}/attachment", async ( + Guid id, + ITrackerUserContext user, + ICoreEvaluationTransactionRepository repository, + IUnitOfWork unitOfWork, + CancellationToken ct) => + { + var transaction = await repository.GetByIdAsync(id, ct); + if (transaction is null || transaction.TenantId != user.TenantId) + return Results.NotFound(); + + transaction.Detach(user.TenantId); + await repository.UpdateAsync(transaction, ct); + await unitOfWork.SaveEntitiesAsync(ct); + return Results.Ok(CoreEvaluationTransactionDto.From(transaction)); + }) + .RequireTrackerPermission(TrackerPermissions.CoreTransactionAttach) + .WithName("DetachCoreEvaluationTransaction") + .Produces(200) + .Produces(404); + group.MapGet("/", async ( string? repositoryUrl, DateTime? since, @@ -194,3 +254,23 @@ public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuil }; } + +/// +/// CP-07 — a qué parte del expediente se adjunta un depósito. +/// +/// NO lleva `TenantId`: el tenant es el de la SESIÓN. Aceptarlo aquí convertiría este +/// endpoint en una forma de colgar evidencia en el expediente de otro cliente. +/// +public sealed class AttachCoreEvaluationRequest +{ + public Guid InitiativeId { get; init; } + public string Phase { get; init; } = string.Empty; + + /// Opcional: un depósito puede hablar de una fase entera y no de una pieza. + public string? ArtifactKind { get; init; } + + /// CP-14 — qué versión del artefacto se evaluó. Sólo tiene sentido con `artifactKind`. + public int? ArtifactVersion { get; init; } + + public Guid? GateSubmissionId { get; init; } +} diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Sdlc/SdlcPhaseEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Sdlc/SdlcPhaseEndpoints.cs index be8c195a..60452b32 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Sdlc/SdlcPhaseEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Sdlc/SdlcPhaseEndpoints.cs @@ -94,6 +94,26 @@ public static void MapSdlcPhaseEndpoints(this IEndpointRouteBuilder app) }) .RequireTrackerPermission(TrackerPermissions.SdlcRead) .WithName("GetInitiativeProgression"); + + // CP-16 — la bitácora SDLC de la iniciativa. Es una PROYECCIÓN de la auditoría, las + // entregas de compuerta y los depósitos adjuntos; no hay ledger propio que pueda divergir + // de los agregados que resume. + initiatives.MapGet("/{initiativeId:guid}/ledger", async ( + Guid initiativeId, + string? phase, + string? artifactKind, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var result = await mediator.Send( + new Tracker.Application.Sdlc.InitiativeLedger.GetInitiativeLedgerQuery( + user.TenantId, initiativeId, phase, artifactKind), ct); + return Results.Ok(result); + }) + .RequireTrackerPermission(TrackerPermissions.SdlcRead) + .WithName("GetInitiativeLedger") + .Produces(200); } } diff --git a/src/apps/tracker-api/Tracker.Presentation/Integration/CoreArchitectureGateway.cs b/src/apps/tracker-api/Tracker.Presentation/Integration/CoreArchitectureGateway.cs index 475cc94c..17477ad7 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Integration/CoreArchitectureGateway.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Integration/CoreArchitectureGateway.cs @@ -19,6 +19,13 @@ Task RecommendTopologyAsync( IReadOnlyDictionary signals, CancellationToken cancellationToken); + /// + /// GAP-004 / GT-650 — the Core's artifact registry, published since ADR-0125 landed. Falls back + /// to an empty object rather than throwing, so a caller can tell «the Core said nothing» from + /// «the Core said there is nothing», which are different answers. + /// + Task GetArtifactRegistryAsync(CancellationToken cancellationToken); + Task EvaluatePhaseArtifactsAsync( string phase, IReadOnlyList topologies, @@ -42,6 +49,11 @@ public Task ListTopologiesAsync(CancellationToken cancellationToken cancellationToken); } + public Task GetArtifactRegistryAsync(CancellationToken cancellationToken) + { + return GetAsync("phases/artifacts", () => CloneJson("{}"), cancellationToken); + } + public Task GetTopologyAsync(string id, CancellationToken cancellationToken) { return GetAsync( diff --git a/src/apps/tracker-api/Tracker.Presentation/Integration/CoreArtifactRegistrySyncService.cs b/src/apps/tracker-api/Tracker.Presentation/Integration/CoreArtifactRegistrySyncService.cs new file mode 100644 index 00000000..bb5a134b --- /dev/null +++ b/src/apps/tracker-api/Tracker.Presentation/Integration/CoreArtifactRegistrySyncService.cs @@ -0,0 +1,88 @@ +using Microsoft.Extensions.Hosting; +using Tracker.Infrastructure.Governance; + +namespace Tracker.Presentation.Integration; + +/// +/// GAP-004 — keeps fed from the Core. +/// +/// Why a background service and not a lazy fetch. `GetProfiles()` is synchronous and +/// sits on the evaluation path. A lazy fetch would put an HTTP round trip — and an HTTP failure — +/// inside the first governance decision after a restart, which is exactly when a system is least +/// able to absorb it. The catalogue changes when the Core ships, not per request. +/// +/// A failed sync is not a failed startup. If the Core is unreachable the source keeps +/// serving the stand-in, stamped `core-standin`, and this retries on the next tick. The Tracker +/// governing with a mirrored catalogue is a documented, visible state; the Tracker refusing to +/// start because a remote is down is an outage we would have caused ourselves. +/// +public sealed class CoreArtifactRegistrySyncService : BackgroundService +{ + /// + /// The Core's artifact catalogue changes when the Core ships. Fifteen minutes is frequent + /// enough that a deploy propagates within one coffee break and rare enough that it is not + /// traffic anybody notices. + /// + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(15); + + private readonly IServiceProvider _services; + private readonly ILogger _logger; + + public CoreArtifactRegistrySyncService( + IServiceProvider services, + ILogger logger) + { + _services = services; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await SyncOnceAsync(stoppingToken); + try + { + await Task.Delay(Interval, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + + private async Task SyncOnceAsync(CancellationToken ct) + { + try + { + using var scope = _services.CreateScope(); + var source = scope.ServiceProvider + .GetService() + as CoreSyncPhaseArtifactProfileSource; + + if (source is null) + { + // The stand-in is registered instead. Nothing to sync, and saying so once beats a + // silent no-op that looks like a successful sync in the logs. + _logger.LogDebug("No core-sync profile source is registered; skipping the artifact sync."); + return; + } + + var gateway = scope.ServiceProvider.GetRequiredService(); + var registry = await gateway.GetArtifactRegistryAsync(ct); + + if (!source.Apply(registry)) + { + _logger.LogWarning( + "The Core artifact registry could not be applied; still serving the previous catalogue."); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Never let a sync failure take the host down: the fallback is a documented, visible + // state and the next tick retries. + _logger.LogWarning(ex, "Artifact registry sync failed; retrying at the next interval."); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Presentation/Integration/CoreEvaluationGateway.cs b/src/apps/tracker-api/Tracker.Presentation/Integration/CoreEvaluationGateway.cs index 10b599d3..f5574511 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Integration/CoreEvaluationGateway.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Integration/CoreEvaluationGateway.cs @@ -43,7 +43,19 @@ public sealed class CoreEvaluationGateway( HttpClient httpClient, IOptions options, ILogger logger, - ICoreCapabilityProbe? capabilityProbe = null) : ICoreEvaluationGateway + ICoreCapabilityProbe? capabilityProbe = null, + // CP-04 criterio 3 — el paquete de fase se ARMA AQUÍ, no lo manda el llamante. + // + // Van como dependencias opcionales por la misma razón que `capabilityProbe`: un despliegue + // sin ellas sigue evaluando con la forma anterior en vez de dejar de arrancar. + // + // Se leen del servidor y NO del cuerpo de la petición a propósito: si `artifacts` viajara en + // `EvaluateContextRequest`, un llamante podría declarar como presentado un artefacto que no + // existe y el Core lo evaluaría como si estuviera. El expediente es del Tracker; el llamante + // pide una evaluación, no describe el mundo. + Tracker.Domain.Sdlc.PhaseArtifact.IPhaseArtifactRepository? phaseArtifacts = null, + Tracker.Application.Governance.ArtifactFieldSchema.ITenantPhaseArtifactCatalog? artifactProfiles = null) + : ICoreEvaluationGateway { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly CoreEvaluationOptions _options = options.Value; @@ -99,7 +111,8 @@ public async Task EvaluateAsync( try { - var corePayload = BuildCorePayload(request, requestContext, user); + var artifactContext = await BuildArtifactContextAsync(request, user, cancellationToken); + var corePayload = BuildCorePayload(request, requestContext, user, artifactContext); using var response = await httpClient.PostAsJsonAsync("evaluate", corePayload, JsonOptions, cancellationToken); var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); @@ -301,7 +314,8 @@ private object BuildInlineCorePayload( private object BuildCorePayload( EvaluateContextRequest request, CoreEvaluationRequestContext context, - ITrackerUserContext user) + ITrackerUserContext user, + Dictionary? artifactContext = null) { // Canonical stateless EvaluationContext (ADR-0101). The Core evaluates the // declared context; tenant/product/initiative are OPAQUE context objects @@ -318,7 +332,10 @@ private object BuildCorePayload( { ["operationId"] = context.OperationId, ["trackerWorkspaceRef"] = context.WorkspaceRef, - ["repositoryRef"] = context.RepositoryRef, + // CP-04 — `repositoryRef` YA NO viaja aquí: ahora va como `repositoryRevision`, un + // campo tipado que el Core evalúa. No se deja copia porque no había consumidor — + // verificado en los tres lados: el Core sólo lee `evaluationDate` del passthrough, + // y ni el Tracker ni la web leen el passthrough que el Core devuelve. ["targetType"] = request.TargetType, ["targetId"] = request.TargetId }; @@ -336,9 +353,36 @@ private object BuildCorePayload( // Opaque ref the Core resolves beneath WORKSPACE_ROOT (must exist there). ["workspaceRef"] = _options.LocalWorkspaceRef, ["phaseId"] = CanonicalPhase(request.Phase), - ["passthrough"] = passthrough + ["passthrough"] = passthrough, + // CP-04 — qué versión del contrato de contexto habla este satélite. Hecho del + // llamante, no suposición sobre el Core (ver `ContextSchemaVersion`). + ["schemaVersion"] = _options.ContextSchemaVersion }; + // CP-04 — QUIÉN pidió la evaluación. Atribución de la PETICIÓN, jamás de un hallazgo: + // el dueño de una violación lo decide CODEOWNERS en el Core, y rellenarlo con quien pidió + // la evaluación sería inventar una acusación. + var requester = _options.SendTypedContextFacts ? BuildRequester(request, user) : null; + if (requester is not null) + { + payload["requester"] = requester; + } + + // CP-04 — QUÉ revisión se juzga. Ausente es ausente: sin revisión conocida no se manda el + // campo, en vez de mandar `HEAD` como marcador de posición, que es indistinguible de una + // revisión real llamada HEAD. + var revision = _options.SendTypedContextFacts ? BuildRepositoryRevision(context) : null; + if (revision is not null) + { + payload["repositoryRevision"] = revision; + } + + // CP-04 criterio 3 — el paquete de fase: qué exige el estándar aquí y qué se presentó. + if (artifactContext is not null) + { + payload["artifacts"] = artifactContext; + } + payload["tenant"] = new Dictionary { ["tenantId"] = tenantId.ToString() }; if (!string.IsNullOrWhiteSpace(request.ProductId)) { @@ -368,6 +412,133 @@ private object BuildCorePayload( return payload; } + /// + /// CP-04 criterio 3 — el paquete de fase, en la forma que el Core declara (`ArtifactContext`). + /// + /// Es la unión de CP-02 y CP-04. required sale del catálogo DERIVADO DEL + /// CORE que CP-02 cableó — la misma costura, no una segunda copia — y presented del + /// expediente que la gente llena. Enviar sólo lo presentado dejaría al Core sin saber contra + /// qué contrastarlo; enviar sólo lo requerido, sin saber qué hay. + /// + /// Devuelve null cuando no se sabe nada de ninguna de las dos mitades. Un + /// bloque con dos listas vacías afirmaría «este gate no exige nada y no se presentó nada», + /// que es una evaluación distinta de «no pude armar el paquete». + /// + private async Task?> BuildArtifactContextAsync( + EvaluateContextRequest request, ITrackerUserContext user, CancellationToken cancellationToken) + { + var phase = CanonicalPhase(request.Phase); + + // `required` — lo que el estándar del Core exige en esta fase. `Required` es `binding` + // en el registro del Core; lo advisory cuenta para completitud y no se declara como + // exigido, porque el Core lo leería como parte del contrato del gate. + // CP-02 — la vista del TENANT, no el estándar pelado. `artifacts.required` es «lo que la + // configuración SDLC activa espera en esta fase», y la configuración activa de este + // producto es la del tenant. Mandar el estándar sin overlay haría que el Core evaluara + // contra una lista distinta de la que el Tracker gobierna: dos superficies discrepando + // sobre el mismo hecho, que es el defecto que esta campaña lleva cerrando. + var perfil = artifactProfiles is null + ? null + : await artifactProfiles.GetPhaseAsync(TenantScope.Resolve(request.TenantId, user), phase, cancellationToken); + + var required = perfil? + .Artifacts + .Where(a => a.Required) + .Select(a => a.ArtifactKind) + .Where(k => !string.IsNullOrWhiteSpace(k)) + .OrderBy(k => k, StringComparer.Ordinal) + .ToList(); + + var presented = await BuildPresentedAsync(request, phase, cancellationToken); + + if ((required is null || required.Count == 0) && (presented is null || presented.Count == 0)) + return null; + + var context = new Dictionary(); + if (required is { Count: > 0 }) context["required"] = required; + if (presented is { Count: > 0 }) context["presented"] = presented; + return context; + } + + /// + /// Lo que el expediente de esta iniciativa y fase lleva de verdad. + /// + /// Un `initiativeId` que no es un Guid del Tracker no es un error: este contrato lo + /// declara como la referencia OPACA que el Core recibe, y un llamante externo puede usar su + /// propio identificador. En ese caso no hay expediente que buscar y se dice que no, en vez de + /// registrar un fallo por algo que el contrato permite. + /// + /// Sólo viajan los ítems con contenido — un `ref` o un cuerpo. Declarar como presentado + /// un hueco vacío que alguien abrió en el formulario le diría al Core que el artefacto está. + /// + private async Task>?> BuildPresentedAsync( + EvaluateContextRequest request, string phase, CancellationToken cancellationToken) + { + if (phaseArtifacts is null) return null; + if (!Guid.TryParse(request.InitiativeId, out var initiativeId) || initiativeId == Guid.Empty) + return null; + + var expediente = await phaseArtifacts.GetByInitiativeAndPhaseAsync(initiativeId, phase, cancellationToken); + if (expediente is null) return null; + + return expediente.Items + .Where(i => !string.IsNullOrWhiteSpace(i.Type)) + .Where(i => !string.IsNullOrWhiteSpace(i.Ref) || !string.IsNullOrWhiteSpace(i.Content)) + .Select(i => + { + var item = new Dictionary { ["artifactId"] = i.Type }; + // `ref` sólo cuando lo hay. El contenido inline NO viaja: el Core evalúa hechos + // declarados, y volcar el cuerpo de un artefacto aquí sacaría del perímetro del + // tenant un documento que nadie pidió mandar. + if (!string.IsNullOrWhiteSpace(i.Ref)) item["ref"] = i.Ref; + return item; + }) + .ToList(); + } + + /// + /// CP-04 — quién pidió la evaluación, en el vocabulario del contrato del Core. + /// + /// actorType distingue una petición humana de una de agente, que es la + /// distinción que la gobernanza necesita y que un id suelto no lleva. El id explícito de + /// EvaluatedBy gana sobre el de la sesión porque un endpoint de servicio puede evaluar + /// en nombre de alguien; cuando no hay ninguno de los dos, el campo NO se manda — un + /// requester vacío afirmaría una atribución que no tenemos. + /// + private static Dictionary? BuildRequester( + EvaluateContextRequest request, ITrackerUserContext user) + { + var actorId = request.EvaluatedBy ?? user.ActorId; + if (actorId == Guid.Empty) return null; + + return new Dictionary + { + // El Tracker sólo autentica personas y servicios; un agente que evalúe lo hace hoy a + // través de una identidad de servicio, así que afirmar `agent` aquí sería adivinar. + ["actorType"] = "user", + ["actorId"] = actorId.ToString(), + }; + } + + /// + /// CP-04 — qué revisión se juzga, tipada. Antes viajaba dentro de passthrough, que el + /// Core echa de vuelta sin interpretar: el dato estaba, pero no participaba en la evaluación. + /// + private static Dictionary? BuildRepositoryRevision( + CoreEvaluationRequestContext context) + { + var revision = context.RepositoryRef?.Revision; + if (string.IsNullOrWhiteSpace(revision)) return null; + + var revisionContext = new Dictionary { ["revision"] = revision }; + if (!string.IsNullOrWhiteSpace(context.RepositoryRef?.Url)) + { + revisionContext["repositoryRef"] = context.RepositoryRef!.Url; + } + + return revisionContext; + } + /// /// EAG-03: convierte los gaps[] del Core en hallazgos tipados con ubicacion /// descompuesta. Sin esto el veredicto tecnico llegaba como una bolsa opaca que se @@ -425,10 +596,99 @@ private static CoreEvaluationVerdictView ParseVerdict(string responseJson) ["overallVerdict"] = result.OverallVerdict, ["outcome"] = result.Outcome }, - EvaluatedAt = result.EvaluatedAt ?? DateTime.UtcNow + EvaluatedAt = result.EvaluatedAt ?? DateTime.UtcNow, + Intelligence = MapIntelligence(result) + }; + } + + /// + /// CP-05 — conserva la parte del resultado canónico que este límite descartaba. + /// + /// El mapeo es total y sin opinión: no filtra por severidad, no reordena y no decide. + /// Un ACL que elige qué recomendaciones «valen la pena» toma una decisión de gobierno en el + /// sitio equivocado; qué exige una compuerta de este tenant es CP-06 (T-056). + /// + /// Devuelve null cuando el Core no mandó NADA de esto — una respuesta antigua o + /// una que no ejecutó pipeline. Un bloque vacío diría «el motor no encontró nada», que no es + /// lo mismo que «el motor no lo contó». + /// + private static CoreEvaluationIntelligenceView? MapIntelligence(CoreEvaluationEnvelope result) + { + var otrosKinds = result.Results?.OtherKinds ?? new Dictionary(); + + var trajoAlgo = + result.RulesExecuted is not null || result.PoliciesApplied is not null || + result.Risks is not null || result.Recommendations is not null || + result.RequiredActions is not null || result.QualitySignals is not null || + result.CorrelationId is not null || result.SchemaVersion is not null || + result.Versions is not null || otrosKinds.Count > 0; + + if (!trajoAlgo) + return null; + + // `gate` se lista aunque viaje tipado aparte: quien pregunta qué evaluó el Core espera la + // respuesta completa, no la completa menos la que este límite decidió tratar distinto. + var kinds = otrosKinds.Keys.ToList(); + if (result.Results?.Gate is not null) + kinds.Add("gate"); + kinds.Sort(StringComparer.Ordinal); + + return new CoreEvaluationIntelligenceView + { + OtherResultKinds = otrosKinds, + KindsPresent = kinds.ToArray(), + RulesExecuted = MapRuleRefs(result.RulesExecuted), + PoliciesApplied = MapRuleRefs(result.PoliciesApplied), + Risks = (result.Risks ?? []).Select(r => new CoreRiskView + { + Id = r.Id, + Level = r.Level, + Category = r.Category, + Message = r.Message, + Location = r.Location, + RuleRef = r.RuleRef, + }).ToArray(), + Recommendations = (result.Recommendations ?? []).Select(r => new CoreRecommendationView + { + Id = r.Id, + Kind = r.Kind, + Message = r.Message, + Rationale = r.Rationale, + References = r.References ?? [], + }).ToArray(), + RequiredActions = (result.RequiredActions ?? []).Select(a => new CoreRequiredActionView + { + Id = a.Id, + GapId = a.GapId, + Description = a.Description, + // Ausente se lee como NO bloqueante: inventar un bloqueo que el motor no declaró + // es la dirección peligrosa de este default. + Blocking = a.Blocking ?? false, + Remediation = a.Remediation, + }).ToArray(), + QualitySignals = (result.QualitySignals ?? []).Select(s => new CoreQualitySignalView + { + Dimension = s.Dimension, + Status = s.Status, + EvidenceCount = s.Evidence?.Length ?? 0, + }).ToArray(), + CorrelationId = result.CorrelationId, + SchemaVersion = result.SchemaVersion, + CoreVersion = result.Versions?.Core, + Ruleset = result.Versions?.Ruleset, + RulesetVersion = result.Versions?.RulesetVersion, }; } + private static CoreRuleExecutionView[] MapRuleRefs(CoreRuleExecutionRefDto[]? refs) => + (refs ?? []).Select(r => new CoreRuleExecutionView + { + RuleId = r.RuleId, + RulesetRef = r.RulesetRef, + Engine = r.Engine, + Verdict = r.Verdict, + }).ToArray(); + private static JsonElement ExtractEnvelopePayload(JsonElement root) { foreach (var propertyName in new[] { "data", "result", "value", "payload" }) diff --git a/src/apps/tracker-api/Tracker.Presentation/Integration/CoreEvaluationOptions.cs b/src/apps/tracker-api/Tracker.Presentation/Integration/CoreEvaluationOptions.cs index c305d20e..04b1c3b8 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Integration/CoreEvaluationOptions.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Integration/CoreEvaluationOptions.cs @@ -27,6 +27,50 @@ public sealed class CoreEvaluationOptions /// public string[] RequiredEvaluationKinds { get; init; } = ["gate"]; + /// + /// CP-04 — versión del contrato de CONTEXTO que este satélite dice hablar, enviada como + /// `schemaVersion` en el payload. + /// + /// Es un hecho del LLAMANTE, no una suposición sobre el Core. Declarar «hablo + /// 1.0.0» es cierto por construcción; afirmar qué versión habla el Core no lo sería. Y el + /// manifiesto de capacidades no ayuda aquí: su `schemaVersion` es la del contrato de + /// RESULTADO (`EVALUATION_RESULT_SCHEMA_VERSION`), no la del contexto. + /// + /// El valor espeja `EVALUATION_CONTEXT_SCHEMA_VERSION` de + /// `core-domain/evaluation/contracts/evaluation-context.ts`. Es configurable justamente + /// porque es una copia: el día que el Core mueva la suya, se ajusta sin recompilar en vez de + /// quedarse mintiendo en silencio. + /// + public string ContextSchemaVersion { get; init; } = "1.0.0"; + + /// + /// CP-04 criterio 4 — los campos técnicos opcionales (`repoFacts`, `baselineRepoFacts`, + /// `qualitySignals`) sólo viajan cuando están habilitados. + /// + /// Apagado por defecto, y hoy no hay nada que enviar. El Tracker no produce + /// hechos estructurales ni señales de calidad: no existe indexador ni proveedor detrás de + /// ningún puerto suyo. Enviarlos vacíos le diría al Core «miré y no hay nada» cuando la + /// verdad es «no miré», y son dos afirmaciones distintas — el Core trata la ausencia como + /// «sin hallazgos estructurales», nunca como un fallo. La puerta existe para que el día que + /// haya productor no haya que decidir esto con prisa. + /// + public bool SendOptionalTechnicalContext { get; init; } + + /// + /// CP-04 — manda `requester` y `repositoryRevision` como campos TIPADOS. + /// + /// Encendido por defecto porque es la conducta correcta, y apagable porque el riesgo + /// es real. El Core corre un `ValidationPipe` global con `forbidNonWhitelisted: true`, así + /// que un Core anterior a `GT-652` — que es quien abrió estos dos campos en su DTO — responde + /// 400 a la evaluación ENTERA, no la acepta recortada. El orden de despliegue es por tanto + /// Core primero. + /// + /// Existe este interruptor para que ese acoplamiento sea recuperable con una variable de + /// entorno en vez de con un despliegue de vuelta atrás. Apagarlo devuelve el payload a la + /// forma mínima anterior, que ningún Core rechaza. + /// + public bool SendTypedContextFacts { get; init; } = true; + /// /// CP-08 — optional pin for the Core capability manifest fingerprint (`sha256`). Empty means /// "read and log it, but do not compare"; environments that deploy Core and Tracker as a diff --git a/src/apps/tracker-api/Tracker.Presentation/appsettings.json b/src/apps/tracker-api/Tracker.Presentation/appsettings.json index b7bf008b..3008d9be 100644 --- a/src/apps/tracker-api/Tracker.Presentation/appsettings.json +++ b/src/apps/tracker-api/Tracker.Presentation/appsettings.json @@ -33,7 +33,7 @@ "tracker-admin": [ "tracker:*" ], "tracker-gate-reader": [ "tracker:gate:read" ], "tracker-gate-operator": [ "tracker:gate:read", "tracker:gate:evaluate", "tracker:core:evaluate", "tracker:gate-decision:read", "tracker:gate-decision:create", "tracker:gate-decision:decide", "tracker:evidence:read", "tracker:evidence:create" ], - "tracker-api-operator": [ "tracker:initiative:read", "tracker:initiative:create", "tracker:initiative:approve", "tracker:sdlc-execution:read", "tracker:sdlc-execution:create", "tracker:sdlc-execution:advance", "tracker:audit:read", "tracker:audit:create", "tracker:core-transaction:read" ] + "tracker-api-operator": [ "tracker:initiative:read", "tracker:initiative:create", "tracker:initiative:approve", "tracker:sdlc-execution:read", "tracker:sdlc-execution:create", "tracker:sdlc-execution:advance", "tracker:audit:read", "tracker:audit:create", "tracker:core-transaction:read", "tracker:core-transaction:attach" ] }, "UmsPermissionMap": { } diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Governance/CoreArtifactSchemaCatalogTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Governance/CoreArtifactSchemaCatalogTests.cs new file mode 100644 index 00000000..3a150466 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Application/Governance/CoreArtifactSchemaCatalogTests.cs @@ -0,0 +1,145 @@ +using System.Text.RegularExpressions; +using Tracker.Application.Governance.ArtifactFieldSchema; + +namespace Tracker.Tests.Application.Governance; + +/// +/// Contrasta la tabla de con su fuente en prosa, +/// docs/artifacts/CORE_ARTIFACT_SCHEMAS.md. +/// +/// POR QUÉ. La correspondencia entre los tipos del Tracker y los artefactos del Core es una +/// decisión escrita, y las decisiones escritas en dos sitios se separan. Esta semana el repositorio +/// encontró la misma avería cuatro veces: dos tableros de gaps que se contradecían en 5 de 34 +/// identificadores, y dos ficheros marcados «traducidos» que eran copias del español. En todos los +/// casos había una comprobación verde al lado que miraba otra cosa. +/// +/// Estas pruebas fallan en las DOS direcciones: una fila en el markdown que nadie cableó, y +/// una entrada en el código que nadie documentó. Una guarda que sólo mira un sentido deja el otro +/// como sitio cómodo donde esconder cosas. +/// +public class CoreArtifactSchemaCatalogTests +{ + private const string Doc = "docs/artifacts/CORE_ARTIFACT_SCHEMAS.md"; + + /// + /// Filas de la tabla con tipo del Tracker. Las que dicen (no catalog kind) se saltan a + /// propósito: son artefactos con esquema en el Core y sin tipo aquí, y están en el documento + /// para que quien los busque descubra que existen. + /// + private static Dictionary FilasDelDocumento() + { + var texto = File.ReadAllText(Path.Combine(RaizDelRepositorio(), Doc)); + var filas = new Dictionary(); + + // | fase | `tipo` | Nombre en el Core | [`fichero`](https://evolith.dev/schema/fichero) | + var patron = new Regex( + @"^\|\s*\w+\s*\|\s*`([a-z0-9-]+)`\s*\|[^|]*\|\s*\[[^\]]*\]\((https://evolith\.dev/schema/[^)]+)\)\s*\|", + RegexOptions.Multiline); + + foreach (System.Text.RegularExpressions.Match m in patron.Matches(texto)) + { + filas[m.Groups[1].Value] = m.Groups[2].Value; + } + + return filas; + } + + [Fact] + public void LaTablaDelCodigoYLaDelDocumentoDicenLoMismo() + { + var doc = FilasDelDocumento(); + + // Una guarda que recorre cero filas pasa siempre. Si el formato del documento cambia y el + // patrón deja de encajar, esto lo dice en vez de dar un verde vacío. + doc.Should().HaveCount(10, + "el documento publica catorce esquemas y diez de ellos tienen tipo en el Tracker; " + + "si este número cambia hay que decidir el mapeo, no ajustar la prueba"); + + CoreArtifactSchemaCatalog.Mappings.Should().BeEquivalentTo(doc, + "la correspondencia es una decisión escrita y vive en dos sitios: el documento explica " + + "el porqué de cada fila y el código la aplica. En cuanto discrepan, uno de los dos " + + "miente y nadie sabe cuál"); + } + + [Fact] + public void LosTiposMapeadosExistenEnElCatalogoDeFases() + { + var conocidos = PhaseArtifactCatalog.ByPhase.Values + .SelectMany(a => a) + .Select(a => a.ArtifactKind) + .ToHashSet(); + + foreach (var tipo in CoreArtifactSchemaCatalog.Mappings.Keys.Concat(CoreArtifactSchemaCatalog.ToolOutputs)) + { + conocidos.Should().Contain(tipo, + $"`{tipo}` tiene esquema o procedencia declarada pero ningún artefacto lo pide: " + + "es un mapeo a la nada, y se lee como cobertura"); + } + } + + [Fact] + public void ElRegistroDeAdrNoSeMapeaAlEsquemaDeUnAdr() + { + // Los nombres invitan al atajo. Un registro es una LISTA de ADRs y no un ADR, así que el + // mapeo daría una validación que falla contra el artefacto real: cobertura sobre el papel + // y un falso negativo en la práctica. + CoreArtifactSchemaCatalog.For("adr-registry").Provenance + .Should().Be(ArtifactSchemaProvenance.None); + } + + [Fact] + public void SinEsquemaYSalidaDeHerramientaNoSeConfunden() + { + // Son dos ausencias distintas y piden respuestas opuestas: a una le falta un esquema que + // habría que escribir; la otra NO debe tenerlo nunca. Colapsarlas en `null` perdería eso. + CoreArtifactSchemaCatalog.For("ci-pipeline-result").Provenance + .Should().Be(ArtifactSchemaProvenance.ToolOutput); + CoreArtifactSchemaCatalog.For("coverage-report").Provenance + .Should().Be(ArtifactSchemaProvenance.ToolOutput); + CoreArtifactSchemaCatalog.For("business-case").Provenance + .Should().Be(ArtifactSchemaProvenance.None); + + CoreArtifactSchemaCatalog.For("ci-pipeline-result").SchemaId.Should().BeNull(); + CoreArtifactSchemaCatalog.For("business-case").SchemaId.Should().BeNull(); + } + + [Fact] + public void LosTresNombresQueNoCoincidenEstanCableadosAMano() + { + // Un mapeador por slug fallaría estos tres, y fallaría en silencio. + CoreArtifactSchemaCatalog.For("security-scan-result").SchemaId + .Should().EndWith("security-scan-report.schema.json"); + CoreArtifactSchemaCatalog.For("rollback-plan").SchemaId + .Should().EndWith("rollback-rehearsal.schema.json"); + CoreArtifactSchemaCatalog.For("observability-readiness").SchemaId + .Should().EndWith("observability-validation.schema.json"); + } + + [Fact] + public void ElPerfilQueSeSirveLlevaLaReferencia() + { + var perfiles = new StandInPhaseArtifactProfileSource().GetProfiles(); + + var prd = perfiles.Single(p => p.Phase == "discovery") + .Artifacts.Single(a => a.ArtifactKind == "prd"); + + prd.CanonicalSchema.Provenance.Should().Be(ArtifactSchemaProvenance.CoreSchema); + prd.CanonicalSchema.SchemaId.Should().Be("https://evolith.dev/schema/prd.schema.json"); + + // Y el que no tiene esquema lo dice, en vez de venir vacío y parecer un fallo de carga. + var caso = perfiles.Single(p => p.Phase == "discovery") + .Artifacts.Single(a => a.ArtifactKind == "business-case"); + caso.CanonicalSchema.Provenance.Should().Be(ArtifactSchemaProvenance.None); + } + + private static string RaizDelRepositorio() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) + { + dir = dir.Parent; + } + dir.Should().NotBeNull("la prueba necesita ubicar la raiz del repositorio"); + return dir!.FullName; + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Governance/GateSubmissionHandlersTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Governance/GateSubmissionHandlersTests.cs index 46bdb333..67a59184 100644 --- a/src/apps/tracker-api/Tracker.Tests/Application/Governance/GateSubmissionHandlersTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Application/Governance/GateSubmissionHandlersTests.cs @@ -123,8 +123,25 @@ public async Task Evaluate_ApprovedPath_WritesEvaluatedAudit_AndReturnsState() It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((CriteriaEvaluationOutcome?)null); + // CP-07 criterio 3 — sin matriz configurada el evaluador de señales devuelve null, y la + // compuerta se decide exactamente como antes. Es la propiedad que impide que añadir la + // ficha cambiara la conducta de cada compuerta ya configurada. + var señales = new Mock(); + señales + .Setup(c => c.EvaluateAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Tracker.Domain.Governance.GatePolicy.CoreSignalEvaluationOutcome?)null); + + // CP-14 — sin expediente de fase no hay versiones que sellar, y una aprobación válida no + // puede convertirse en error por algo que el modelo permite. + var expedientes = new Mock(); + expedientes + .Setup(r => r.GetByInitiativeAndPhaseAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Tracker.Domain.Sdlc.PhaseArtifact.PhaseArtifact?)null); + var handler = new EvaluateGateSubmissionCommandHandler( - _submissions.Object, _criteria.Object, _audit.Object, Mock.Of(), _uow.Object); + _submissions.Object, _criteria.Object, señales.Object, expedientes.Object, _audit.Object, + Mock.Of(), _uow.Object); var result = await handler.Handle( new EvaluateGateSubmissionCommand(_tenantId, _actorId, submission.Id), CancellationToken.None); diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Governance/TenantArtifactOverlayTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Governance/TenantArtifactOverlayTests.cs new file mode 100644 index 00000000..4e91c4e6 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Application/Governance/TenantArtifactOverlayTests.cs @@ -0,0 +1,130 @@ +using Tracker.Application.Governance.ArtifactFieldSchema; +using Tracker.Domain.Governance.GatePolicy; + +namespace Tracker.Tests.Application.Governance; + +/// +/// CP-02 criterio 2 — el tenant marca artefactos SIN modificar el estándar base. +/// +/// Lo que se prueba son las propiedades que hacen honesto el overlay: que la base no se +/// toque, que marcar no sea borrar, que se pueda distinguir «lo exige el Core» de «lo exige este +/// tenant», y que una marca sobre un artefacto que el Core no declara no se cuele como catálogo +/// propio por la puerta de atrás. +/// +public class TenantArtifactOverlayTests +{ + private static PhaseArtifactProfileDto Base() => new() + { + Phase = "construction", + Source = PhaseArtifactProfileSourceKind.CoreSync, + Artifacts = + [ + new PhaseArtifactDto { ArtifactKind = "coverage-report", Label = "Coverage", Required = true }, + new PhaseArtifactDto { ArtifactKind = "cfr-metric", Label = "CFR", Required = false }, + ], + }; + + private static PhaseArtifactDto Art(PhaseArtifactProfileDto p, string kind) => + p.Artifacts.Single(a => a.ArtifactKind == kind); + + [Fact] + public void EL_ESTANDAR_BASE_NO_SE_TOCA() + { + // La propiedad que da nombre al criterio. Si el overlay mutara el perfil de entrada, la + // configuración de un tenant editaría el catálogo del Core para todos los demás. + var baseProfile = Base(); + + TenantPhaseArtifactCatalog.Apply(baseProfile, + [new ArtifactApplicabilityRule("coverage-report", ArtifactApplicability.NotApplicable)]); + + Art(baseProfile, "coverage-report").Required.Should().BeTrue(); + Art(baseProfile, "coverage-report").TenantApplicability.Should().BeNull(); + } + + [Fact] + public void UnTenantPuedeExigirLoQueElCoreDaPorOpcional() + { + var r = TenantPhaseArtifactCatalog.Apply(Base(), + [new ArtifactApplicabilityRule("cfr-metric", ArtifactApplicability.Required)]); + + Art(r, "cfr-metric").Required.Should().BeTrue(); + Art(r, "cfr-metric").StandardRequired.Should().BeFalse("el estándar decía que no, y eso se conserva"); + } + + [Fact] + public void MARCAR_NO_ES_BORRAR() + { + // Quitarlo de la lista dejaría a quien lea el catálogo sin saber que el estándar lo pide y + // que este tenant lo descartó, que son dos hechos distintos y ambos auditables. + var r = TenantPhaseArtifactCatalog.Apply(Base(), + [new ArtifactApplicabilityRule("coverage-report", ArtifactApplicability.NotApplicable)]); + + r.Artifacts.Should().HaveCount(2); + Art(r, "coverage-report").Required.Should().BeFalse(); + Art(r, "coverage-report").TenantApplicability.Should().Be(ArtifactApplicability.NotApplicable); + Art(r, "coverage-report").StandardRequired.Should().BeTrue(); + } + + [Fact] + public void SE_DISTINGUE_QUIEN_LO_EXIGE() + { + // Servir sólo el resultado dejaría a un auditor sin poder separar «el Core lo exige» de + // «este tenant lo exige». + var r = TenantPhaseArtifactCatalog.Apply(Base(), + [new ArtifactApplicabilityRule("cfr-metric", ArtifactApplicability.Required)]); + + Art(r, "coverage-report").TenantApplicability.Should().BeNull("nadie lo marcó: se sirve el estándar tal cual"); + Art(r, "cfr-metric").TenantApplicability.Should().Be(ArtifactApplicability.Required); + } + + [Fact] + public void UNA_MARCA_SOBRE_ALGO_QUE_EL_CORE_NO_DECLARA_SE_IGNORA() + { + // Añadir artefactos propios del tenant es otra capacidad y otra ficha; aceptarla aquí + // haría que el catálogo dejara de ser «el estándar del Core con una opinión encima» y + // pasara a ser dos catálogos mezclados sin poder distinguirlos. + var r = TenantPhaseArtifactCatalog.Apply(Base(), + [new ArtifactApplicabilityRule("invento-del-tenant", ArtifactApplicability.Required)]); + + r.Artifacts.Should().HaveCount(2); + r.Artifacts.Should().NotContain(a => a.ArtifactKind == "invento-del-tenant"); + } + + [Fact] + public void SinOverlaySeSirveElEstandarIntacto() + { + var r = TenantPhaseArtifactCatalog.Apply(Base(), []); + + Art(r, "coverage-report").Required.Should().BeTrue(); + r.Artifacts.Should().OnlyContain(a => a.TenantApplicability == null); + } + + [Fact] + public void EntreDosMarcasDelMismoArtefactoGanaLaUltima() + { + // Ninguna es más específica que la otra, así que cualquier otro criterio sería arbitrario + // y sorprendería a quien acaba de guardar. + var r = TenantPhaseArtifactCatalog.Apply(Base(), + [ + new ArtifactApplicabilityRule("cfr-metric", ArtifactApplicability.NotApplicable), + new ArtifactApplicabilityRule("cfr-metric", ArtifactApplicability.Required), + ]); + + Art(r, "cfr-metric").TenantApplicability.Should().Be(ArtifactApplicability.Required); + } + + [Fact] + public void UnaAplicabilidadInvalidaNoSeAlmacena() + { + // Guardarla e ignorarla luego daría un tenant que cree haber marcado un artefacto como no + // aplicable y un catálogo que se lo sigue exigiendo. + var creada = GatePolicy.Create( + Guid.NewGuid(), GatePhase.Construction, GateMode.Simple, + [], new ApprovalPolicy(ApprovalStrategy.Single, ApprovalStages.Sequential, []), + requiresCoreVerdict: false, createdBy: "test", + artifactApplicability: [new ArtifactApplicabilityRule("prd", "casi-obligatorio")]); + + creada.IsFailure.Should().BeTrue(); + creada.Error.Should().Be("GatePolicy.InvalidArtifactApplicability"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Integration/CoreEvaluationTransactionContractTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Integration/CoreEvaluationTransactionContractTests.cs index f26f34f1..6772edb3 100644 --- a/src/apps/tracker-api/Tracker.Tests/Application/Integration/CoreEvaluationTransactionContractTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Application/Integration/CoreEvaluationTransactionContractTests.cs @@ -63,6 +63,8 @@ public class CoreEvaluationTransactionContractTests "accountableOwners", "blockingViolationCount", "versions", + // CP-07 — el vínculo al expediente SDLC. Declarado en `types.ts` junto al resto. + "attachment", ]; /// diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Metrics/ScorecardProjectionTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Metrics/ScorecardProjectionTests.cs index 3edad0fd..a45c5c1b 100644 --- a/src/apps/tracker-api/Tracker.Tests/Application/Metrics/ScorecardProjectionTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Application/Metrics/ScorecardProjectionTests.cs @@ -26,9 +26,30 @@ public class ScorecardProjectionTests private readonly Mock _blueprints = new(); private readonly Mock _slas = new(); + // CP-02 criterio 3 — el catálogo del tenant. Por defecto NO devuelve perfil, así que las + // pruebas que ya existían siguen midiendo exactamente lo que medían: sin catálogo, el + // scorecard puntúa lo entregado y nada más. + private readonly Mock _catalogo = new(); + private IScorecardProjection Sut() => new ScorecardProjection( - _progressions.Object, _artifacts.Object, _blueprints.Object, _slas.Object); + _progressions.Object, _artifacts.Object, _blueprints.Object, _slas.Object, _catalogo.Object); + + /// Declara que el catálogo del tenant EXIGE estos artefactos en esa fase. + private void CatalogoExige(string fase, params string[] tipos) + => _catalogo + .Setup(c => c.GetPhaseAsync(It.IsAny(), fase, It.IsAny())) + .ReturnsAsync(new Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactProfileDto + { + Phase = fase, + Artifacts = [.. tipos.Select(t => + new Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactDto + { + ArtifactKind = t, + Label = t, + Required = true, + })], + }); private DomainPhaseProgression Progresion(params (string Phase, DateTime Approved)[] historia) { @@ -64,6 +85,94 @@ private void SinArtefactos() => It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((DomainPhaseArtifact?)null); + // ── CP-02 criterio 3 — el scorecard puntúa contra el catálogo del tenant ── + + [Fact] + public async Task UN_EXIGIDO_QUE_NADIE_ENTREGO_YA_NO_ES_INVISIBLE() + { + // EL FALSO VERDE QUE ESTO CIERRA. Antes los hechos salían sólo del expediente, así que una + // fase con el expediente vacío puntuaba `null` — «nada exigible todavía» — mientras el + // catálogo del tenant pedía dos cosas. El cuadro decía que no había nada que reprochar + // justo cuando faltaba todo. + // El plazo YA venció, que es cuando un exigido sin entregar debe doler. Dentro de plazo la + // regla existente lo deja en `pendiente` y es correcta: penalizar antes del vencimiento + // sería castigar por adelantado. + ConSla(("construction", 1)); + SinArtefactos(); + CatalogoExige("construction", "coverage-report", "integration-evidence"); + _progressions.Setup(r => r.GetByInitiativeAsync(_initiativeId, It.IsAny())) + .ReturnsAsync(Progresion(("design", DateTime.UtcNow.AddDays(-30)))); + + var dto = await Sut().ProjectAsync(_tenantId, _initiativeId); + + var fase = dto!.Fases.Single(f => f.Fase == "construction"); + fase.Artefactos.Select(a => a.Type) + .Should().BeEquivalentTo(["coverage-report", "integration-evidence"]); + fase.Artefactos.Should().OnlyContain(a => a.Estado == ArtifactSemaphore.Missing, + "vencido y sin entregar es exactamente lo que el cuadro tenía que estar diciendo"); + dto.Score.Should().Be(0, "y ahora pesa: antes la misma fase puntuaba «nada exigible»"); + } + + [Fact] + public async Task LO_ENTREGADO_NO_SE_DUPLICA_CON_LO_EXIGIDO() + { + ConSla(); + ConArtefactos("construction", ("coverage-report", true)); + CatalogoExige("construction", "coverage-report", "integration-evidence"); + _progressions.Setup(r => r.GetByInitiativeAsync(_initiativeId, It.IsAny())) + .ReturnsAsync(Progresion(("construction", DateTime.UtcNow.AddDays(-1)))); + + var dto = await Sut().ProjectAsync(_tenantId, _initiativeId); + + var fase = dto!.Fases.Single(f => f.Fase == "construction"); + fase.Artefactos.Should().HaveCount(2); + fase.Artefactos.Count(a => a.Type == "coverage-report").Should().Be(1); + } + + [Fact] + public async Task LO_QUE_EL_TENANT_DECLARO_NO_APLICABLE_NO_SE_PINTA() + { + // El catálogo llega YA con el overlay aplicado, así que un artefacto marcado no aplicable + // no viene como `Required`. Puntuar contra el estándar pelado castigaría a un tenant por + // no entregar lo que él mismo descartó. + ConSla(); + SinArtefactos(); + _catalogo + .Setup(c => c.GetPhaseAsync(It.IsAny(), "construction", It.IsAny())) + .ReturnsAsync(new Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactProfileDto + { + Phase = "construction", + Artifacts = + [ + new Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactDto + { + ArtifactKind = "cfr-metric", Label = "CFR", Required = false, + }, + ], + }); + _progressions.Setup(r => r.GetByInitiativeAsync(_initiativeId, It.IsAny())) + .ReturnsAsync(Progresion(("construction", DateTime.UtcNow.AddDays(-1)))); + + var dto = await Sut().ProjectAsync(_tenantId, _initiativeId); + + dto!.Fases.Single(f => f.Fase == "construction").Artefactos.Should().BeEmpty(); + } + + [Fact] + public async Task SinCatalogoElScorecardSeComportaComoAntes() + { + // Sin perfil resuelto no se añade nada: un despliegue sin catálogo no cambia de conducta. + ConSla(); + ConArtefactos("construction", ("algo-local", true)); + _progressions.Setup(r => r.GetByInitiativeAsync(_initiativeId, It.IsAny())) + .ReturnsAsync(Progresion(("construction", DateTime.UtcNow.AddDays(-1)))); + + var dto = await Sut().ProjectAsync(_tenantId, _initiativeId); + + dto!.Fases.Single(f => f.Fase == "construction").Artefactos + .Should().ContainSingle().Which.Type.Should().Be("algo-local"); + } + [Fact] public async Task SinProgresion_DevuelveNulo() { diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/GetPhaseArtifactCoreCatalogTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/GetPhaseArtifactCoreCatalogTests.cs new file mode 100644 index 00000000..9ced52ce --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/GetPhaseArtifactCoreCatalogTests.cs @@ -0,0 +1,156 @@ +using Tracker.Application.Governance.ArtifactFieldSchema; +using Tracker.Application.Sdlc.PhaseArtifact.Queries.GetPhaseArtifact; +using Tracker.Domain.Sdlc.PhaseArtifact; +using DomainPhaseArtifact = Tracker.Domain.Sdlc.PhaseArtifact.PhaseArtifact; +using GovPhaseArtifactDto = Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactDto; + +namespace Tracker.Tests.Application.Sdlc; + +/// +/// CP-02 — el expediente operativo contrastado contra el catálogo del Core. +/// +/// Lo que se prueba no es que los campos se copien, sino las propiedades que hacen que +/// este contraste sea seguro en una superficie de gobierno: que REPORTE y no imponga (T-056), +/// que diga las DOS direcciones, que no confunda «no había catálogo» con «el catálogo no pide +/// nada», y que una diferencia de mayúsculas no invente un hueco que no existe. +/// +public class GetPhaseArtifactCoreCatalogTests +{ + private static readonly Guid Tenant = Guid.NewGuid(); + private static readonly Guid Initiative = Guid.NewGuid(); + + // CP-02 — el handler pasó a leer del catálogo TENANT-AWARE: el mismo estándar del Core con + // el overlay del tenant aplicado encima. + private sealed class FakeProfiles : ITenantPhaseArtifactCatalog + { + private readonly IReadOnlyList _profiles; + private readonly string _source; + + public FakeProfiles(string source, IReadOnlyList profiles) + { + _source = source; + _profiles = profiles; + } + + private PhaseArtifactProfileDto Perfil() => new() + { + Phase = "construction", + Source = _source, + Artifacts = _profiles.ToList(), + }; + + public Task> GetProfilesAsync(Guid t, CancellationToken ct) + => Task.FromResult>([Perfil()]); + + public Task GetPhaseAsync(Guid t, string phase, CancellationToken ct) + => Task.FromResult( + string.Equals(phase, "construction", StringComparison.OrdinalIgnoreCase) ? Perfil() : null); + } + + private sealed class FakeRepo : IPhaseArtifactRepository + { + private readonly DomainPhaseArtifact? _one; + public FakeRepo(DomainPhaseArtifact? one) => _one = one; + + public Task GetByInitiativeAndPhaseAsync( + Guid initiativeId, string phase, CancellationToken ct) => Task.FromResult(_one); + + public Task AddAsync(DomainPhaseArtifact a, CancellationToken ct) => Task.CompletedTask; + public Task UpdateAsync(DomainPhaseArtifact a, CancellationToken ct) => Task.CompletedTask; + } + + private static DomainPhaseArtifact Expediente(params (string Type, string? Ref)[] items) + { + var a = DomainPhaseArtifact.CreateForPhase(Tenant, Initiative, "construction", Guid.NewGuid()).Value; + foreach (var (type, @ref) in items) a.SetItem(type, null, @ref, null); + return a; + } + + private static GovPhaseArtifactDto Declarado(string kind, bool required = true) => + new() { ArtifactKind = kind, Label = kind.ToUpperInvariant(), Required = required }; + + private static Task Consultar( + DomainPhaseArtifact? expediente, ITenantPhaseArtifactCatalog profiles) => + new GetPhaseArtifactQueryHandler(new FakeRepo(expediente), profiles) + .Handle(new GetPhaseArtifactQuery(Tenant, Initiative, "construction"), CancellationToken.None); + + [Fact] + public async Task DiceLasDosDirecciones_LoQueFaltaYLoQueSobra() + { + // La razón de que existan las dos: reportar solo lo declarado haría que un expediente + // lleno de items fuera de estándar pareciera completo. + var dto = await Consultar( + Expediente(("coverage-report", "cov.xml"), ("invento-local", "x.md")), + new FakeProfiles(PhaseArtifactProfileSourceKind.CoreSync, + new[] { Declarado("coverage-report"), Declarado("integration-evidence") })); + + var cat = dto!.CoreCatalog!; + cat.Artifacts.Single(a => a.ArtifactKind == "coverage-report").Present.Should().BeTrue(); + cat.Artifacts.Single(a => a.ArtifactKind == "integration-evidence").Present.Should().BeFalse(); + cat.UnknownItemKinds.Should().ContainSingle().Which.Should().Be("invento-local"); + } + + [Fact] + public async Task REPORTA_PERO_NO_IMPONE() + { + // T-056. Un artefacto requerido ausente NO puede cambiar el estado del expediente ni + // hacer fallar la consulta: qué exige una compuerta lo configura el tenant, no lo + // cablea el motor. Si esto se rompe, el producto empezó a opinar por su cuenta. + var dto = await Consultar( + Expediente(), + new FakeProfiles(PhaseArtifactProfileSourceKind.CoreSync, new[] { Declarado("coverage-report") })); + + dto.Should().NotBeNull(); + dto!.Status.Should().Be(PhaseArtifactStatus.Draft); + dto.CoreCatalog!.Artifacts.Should().OnlyContain(a => !a.Present); + } + + [Fact] + public async Task SinCatalogoNoEsCatalogoVacio() + { + // Las dos ausencias son distintas y la diferencia importa: «el estándar no estaba + // disponible» no es «el estándar no pide nada aquí». La segunda es la clase de vacío + // confiado que una superficie de gobierno no debe producir. + var sinLaFase = new FakeProfilesOtraFase(); + var dto = await Consultar(Expediente(("coverage-report", "cov.xml")), sinLaFase); + + dto!.CoreCatalog.Should().BeNull(); + dto.Items.Should().ContainSingle("el expediente se sigue sirviendo entero"); + } + + private sealed class FakeProfilesOtraFase : ITenantPhaseArtifactCatalog + { + public Task> GetProfilesAsync(Guid t, CancellationToken ct) + => Task.FromResult>( + [new() { Phase = "qa", Source = PhaseArtifactProfileSourceKind.CoreSync }]); + + public Task GetPhaseAsync(Guid t, string phase, CancellationToken ct) + => Task.FromResult(null); + } + + [Fact] + public async Task UnaDiferenciaDeMayusculasNoInventaUnHueco() + { + // El catálogo escribe slugs; el expediente acepta lo que le manden. Un cotejo sensible + // a mayúsculas reportaría como ausente un artefacto que está — un hueco falso, que en + // gobierno es peor que no cotejar. + var dto = await Consultar( + Expediente(("Coverage-Report", "cov.xml")), + new FakeProfiles(PhaseArtifactProfileSourceKind.CoreSync, new[] { Declarado("coverage-report") })); + + dto!.CoreCatalog!.Artifacts.Single().Present.Should().BeTrue(); + dto.CoreCatalog.UnknownItemKinds.Should().BeEmpty(); + } + + [Fact] + public async Task LaProcedenciaViajaEnElPayload() + { + // Un lector tiene que poder distinguir una respuesta sincronizada de una espejada + // leyendo el payload, no confiando en que hubo sincronización. + var dto = await Consultar( + Expediente(), + new FakeProfiles(PhaseArtifactProfileSourceKind.StandIn, new[] { Declarado("coverage-report") })); + + dto!.CoreCatalog!.Source.Should().Be(PhaseArtifactProfileSourceKind.StandIn); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/InitiativeLedgerTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/InitiativeLedgerTests.cs new file mode 100644 index 00000000..547e8d36 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/InitiativeLedgerTests.cs @@ -0,0 +1,238 @@ +using Tracker.Application.Sdlc.InitiativeLedger; +using Tracker.Domain.Audit.AuditEntry; +using Tracker.Domain.Governance.GatePolicy; +using Tracker.Domain.Governance.GateSubmission; +using Tracker.Domain.Integration.CoreEvaluationTransaction; + +namespace Tracker.Tests.Application.Sdlc; + +/// +/// CP-16 — la bitácora SDLC de una iniciativa. +/// +/// Lo que se prueba son las propiedades que la hacen auditable: que sea una PROYECCIÓN sin +/// camino de escritura propio, que distinga la clase de cada hecho —un advisory y un bloqueo no +/// pueden leerse igual—, que no cruce la frontera de tenant, y que no invente historia cuando un +/// agregado no registró algo. +/// +public class InitiativeLedgerTests +{ + private static readonly Guid Tenant = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid OtroTenant = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid Iniciativa = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly DateTime T0 = new(2026, 8, 3, 9, 0, 0, DateTimeKind.Utc); + + private sealed class FakeAudit(params AuditEntry[] entries) : IAuditEntryRepository + { + public Task> GetByEntityAsync(string type, string id, CancellationToken ct) + => Task.FromResult>( + [.. entries.Where(e => e.EntityType == type && e.EntityId == id)]); + + public Task GetByIdAsync(Guid id, CancellationToken ct) => Task.FromResult(null); + public Task> GetByTenantAsync(Guid t, CancellationToken ct) + => Task.FromResult>([.. entries]); + public Task> GetByActorAsync(Guid a, CancellationToken ct) + => Task.FromResult>([]); + public Task AddAsync(AuditEntry e, CancellationToken ct) => Task.CompletedTask; + } + + private sealed class FakeSubmissions(params GateSubmission[] items) : IGateSubmissionRepository + { + public Task> GetByInitiativeAsync(Guid id, CancellationToken ct) + => Task.FromResult>([.. items.Where(s => s.InitiativeId == id)]); + + public Task GetByIdAsync(Guid id, CancellationToken ct) => Task.FromResult(null); + public Task GetByInitiativeAndPhaseAsync(Guid i, string p, CancellationToken ct) + => Task.FromResult(null); + public Task> GetByTenantAsync(Guid t, CancellationToken ct) + => Task.FromResult>([.. items]); + public Task AddAsync(GateSubmission s, CancellationToken ct) => Task.CompletedTask; + public Task UpdateAsync(GateSubmission s, CancellationToken ct) => Task.CompletedTask; + } + + private sealed class FakeTransactions(params CoreEvaluationTransaction[] items) + : ICoreEvaluationTransactionRepository + { + public Task> GetByTenantAsync(Guid t, CancellationToken ct) + => Task.FromResult>([.. items.Where(i => i.TenantId == t)]); + + public Task GetByIdAsync(Guid id, CancellationToken ct) + => Task.FromResult(null); + public Task GetByOperationIdAsync(string op, CancellationToken ct) + => Task.FromResult(null); + public Task GetByCorrelationIdAsync(Guid t, string c, CancellationToken ct) + => Task.FromResult(null); + public Task> GetRevisionTimelineAsync( + Guid t, string? url, DateTime? since, CancellationToken ct) + => Task.FromResult>([]); + public Task AddAsync(CoreEvaluationTransaction x, CancellationToken ct) => Task.CompletedTask; + public Task UpdateAsync(CoreEvaluationTransaction x, CancellationToken ct) => Task.CompletedTask; + public Task DeleteAsync(CoreEvaluationTransaction x, CancellationToken ct) => Task.CompletedTask; + } + + private sealed class FakeArtifacts(Tracker.Domain.Sdlc.PhaseArtifact.PhaseArtifact? uno = null) + : Tracker.Domain.Sdlc.PhaseArtifact.IPhaseArtifactRepository + { + public Task GetByInitiativeAndPhaseAsync( + Guid i, string p, CancellationToken ct) + => Task.FromResult(uno is not null && string.Equals(uno.Phase, p, StringComparison.OrdinalIgnoreCase) ? uno : null); + + public Task AddAsync(Tracker.Domain.Sdlc.PhaseArtifact.PhaseArtifact a, CancellationToken ct) => Task.CompletedTask; + public Task UpdateAsync(Tracker.Domain.Sdlc.PhaseArtifact.PhaseArtifact a, CancellationToken ct) => Task.CompletedTask; + } + + private static AuditEntry Entrada(string action, string entityType = "Initiative", string? entityId = null) => + AuditEntry.Create( + Tenant, entityType, entityId ?? Iniciativa.ToString(), action, + Guid.NewGuid(), AuditActor.Human(), new Dictionary()).Value!; + + private static CoreEvaluationTransaction Deposito(Guid tenant, bool adjunto, string fase = "construction") + { + var t = CoreEvaluationTransaction.Ingest(tenant, new CoreEvaluationDeposit + { + SchemaVersion = "1.0.0", + CorrelationId = "core-run-1", + ProducerSurface = "cli", + EvaluatedAtUtc = T0, + OverallVerdict = "FAIL", + Outcome = "rejected", + }).Value!; + if (adjunto) t.AttachTo(tenant, Iniciativa, fase, "actor-1"); + return t; + } + + private static async Task Bitacora( + IAuditEntryRepository? audit = null, + IGateSubmissionRepository? subs = null, + ICoreEvaluationTransactionRepository? tx = null, + string? phase = null, + string? artifactKind = null, + Tracker.Domain.Sdlc.PhaseArtifact.IPhaseArtifactRepository? expedientes = null) + => await new GetInitiativeLedgerQueryHandler( + audit ?? new FakeAudit(), subs ?? new FakeSubmissions(), tx ?? new FakeTransactions(), + expedientes ?? new FakeArtifacts()) + .Handle(new GetInitiativeLedgerQuery(Tenant, Iniciativa, phase, artifactKind), CancellationToken.None); + + [Fact] + public async Task UneLoQueLaAuditoriaYLosDepositosSaben() + { + var r = await Bitacora( + audit: new FakeAudit(Entrada("initiative.created")), + tx: new FakeTransactions(Deposito(Tenant, adjunto: true))); + + r.Entries.Should().HaveCount(2); + r.Entries.Select(e => e.Source).Should().BeEquivalentTo(["audit", "core-deposit"]); + } + + [Fact] + public async Task NO_CRUZA_LA_FRONTERA_DE_TENANT() + { + // Una consulta por iniciativa que confiara en que el id es del tenant correcto sería una + // fuga con forma de listado. + var r = await Bitacora(tx: new FakeTransactions(Deposito(OtroTenant, adjunto: true))); + + r.Entries.Should().BeEmpty(); + } + + [Fact] + public async Task UN_DEPOSITO_SUELTO_NO_ES_HISTORIA_DE_ESTA_FASE() + { + // Meterlo aquí contaría como recorrido de la iniciativa algo que nadie situó en ella. + var r = await Bitacora(tx: new FakeTransactions(Deposito(Tenant, adjunto: false))); + + r.Entries.Should().BeEmpty(); + } + + [Fact] + public async Task DISTINGUE_LA_CLASE_DE_CADA_HECHO() + { + // Criterio 4. Sin esto, «el Core recomendó algo» y «el gate quedó bloqueado» se leen igual + // en una lista cronológica y nadie puede decir qué detuvo la fase. + var r = await Bitacora(audit: new FakeAudit( + Entrada("gate.approved"), Entrada("gate.rejected"), Entrada("waiver.granted"), Entrada("artifact.edited"))); + + var porAccion = r.Entries.ToDictionary(e => e.Action, e => e.Kind); + porAccion["gate.approved"].Should().Be(LedgerEventKind.RequirementMet); + porAccion["gate.rejected"].Should().Be(LedgerEventKind.Blocked); + porAccion["waiver.granted"].Should().Be(LedgerEventKind.ExceptionApproved); + porAccion["artifact.edited"].Should().Be(LedgerEventKind.Record, + "lo que no encaja NO se fuerza a una clase con significado"); + } + + [Fact] + public async Task FiltraPorFase() + { + var r = await Bitacora( + tx: new FakeTransactions(Deposito(Tenant, true, "construction"), Deposito(Tenant, true, "qa")), + phase: "qa"); + + r.Entries.Should().ContainSingle().Which.Phase.Should().Be("qa"); + r.Phase.Should().Be("qa", "el filtro aplicado viaja con la respuesta"); + } + + [Fact] + public async Task VieneEnOrdenCronologico() + { + var r = await Bitacora( + audit: new FakeAudit(Entrada("initiative.created")), + tx: new FakeTransactions(Deposito(Tenant, adjunto: true))); + + r.Entries.Should().BeInAscendingOrder(e => e.OccurredAtUtc); + } + + [Fact] + public async Task CadaEventoIdentificaActorYCorrelacion() + { + // Criterio 2. + var r = await Bitacora(tx: new FakeTransactions(Deposito(Tenant, adjunto: true))); + + var e = r.Entries.Single(); + e.ActorId.Should().Be("actor-1"); + e.CorrelationId.Should().Be("core-run-1"); + e.Phase.Should().Be("construction"); + } + + [Fact] + public async Task LA_VERSION_DEL_ARTEFACTO_LLEGA_A_LA_BITACORA() + { + // CP-14 criterio 3. Sin el número, «la evaluación dice que el PRD está mal» deja de ser + // contestable en cuanto alguien edita el PRD. + var expediente = Tracker.Domain.Sdlc.PhaseArtifact.PhaseArtifact + .CreateForPhase(Tenant, Iniciativa, "construction", Guid.NewGuid()).Value!; + expediente.SetItem("prd", null, "prd-v1.md", null, authoredBy: "autor-1", changeReason: "primera"); + expediente.SetItem("prd", null, "prd-v2.md", null, authoredBy: "autor-2"); + + var r = await Bitacora(expedientes: new FakeArtifacts(expediente), phase: "construction"); + + var versiones = r.Entries.Where(e => e.Source == "artifact-version").ToList(); + versiones.Should().HaveCount(2); + versiones.Select(v => v.ArtifactVersion).Should().BeEquivalentTo([1, 2]); + versiones[0].ActorId.Should().Be("autor-1"); + versiones[0].Detail.Should().Be("primera"); + } + + [Fact] + public async Task UNA_VERSION_APROBADA_ES_REQUISITO_CUMPLIDO_Y_EDITAR_NO_LO_ES() + { + // Editar no es cumplir: si toda versión contara como requisito cumplido, la bitácora diría + // que un artefacto quedó satisfecho cada vez que alguien pulsó guardar. + var expediente = Tracker.Domain.Sdlc.PhaseArtifact.PhaseArtifact + .CreateForPhase(Tenant, Iniciativa, "construction", Guid.NewGuid()).Value!; + expediente.SetItem("prd", null, "prd.md", null, authoredBy: "autor-1"); + expediente.ApproveCurrentVersion("prd", null); + + var r = await Bitacora(expedientes: new FakeArtifacts(expediente), phase: "construction"); + + r.Entries.Single(e => e.Source == "artifact-version").Kind + .Should().Be(LedgerEventKind.RequirementMet); + } + + [Fact] + public async Task SinNadaRegistradoLaBitacoraEstaVaciaYNoInventa() + { + // Rellenar huecos con suposiciones convertiría una bitácora de auditoría en una narración. + var r = await Bitacora(); + + r.Entries.Should().BeEmpty(); + r.InitiativeId.Should().Be(Iniciativa); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Governance/CoreDepositSignalsTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/CoreDepositSignalsTests.cs new file mode 100644 index 00000000..7e5fbfd7 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/CoreDepositSignalsTests.cs @@ -0,0 +1,139 @@ +using Tracker.Domain.Governance.GatePolicy; +using Tracker.Domain.Integration.CoreEvaluationTransaction; + +namespace Tracker.Tests.Domain.Governance; + +/// +/// CP-07 criterio 3 — un depósito adjunto, traducido a lo que la matriz del tenant sabe evaluar. +/// +/// Lo que se prueba es que TRADUCE y no decide: no mira umbrales, no normaliza severidades +/// y no fabrica categorías. Las reglas las aplica CoreSignalEvaluator después. +/// +public class CoreDepositSignalsTests +{ + private static readonly Guid Tenant = Guid.NewGuid(); + private static readonly Guid Iniciativa = Guid.NewGuid(); + + private static CoreEvaluationTransaction Deposito( + string verdict = "FAIL", + params CoreEvaluationViolation[] violaciones) + => CoreEvaluationTransaction.Ingest(Tenant, new CoreEvaluationDeposit + { + SchemaVersion = "1.0.0", + CorrelationId = "core-run-1", + ProducerSurface = "cli", + EvaluatedAtUtc = new DateTime(2026, 8, 3, 9, 0, 0, DateTimeKind.Utc), + OverallVerdict = verdict, + Outcome = verdict == "PASS" ? "approved" : "rejected", + Violations = violaciones, + // El ledger RE-DERIVA estos dos y rechaza el depósito si el productor calculó otra + // cosa, así que aquí se calculan igual en vez de fijarlos a mano. + AccountableOwners = CoreEvaluationTransaction.DeriveAccountableOwners(violaciones), + BlockingViolationCount = CoreEvaluationTransaction.DeriveBlockingCount(violaciones), + }).Value!; + + private static CoreEvaluationViolation Violacion( + string ruleId = "R1", string? categoria = null, string severidad = "error", bool frozen = false) + => new() + { + RuleId = ruleId, + Category = categoria, + Severity = severidad, + Message = $"mensaje de {ruleId}", + File = "src/x.cs", + Fingerprint = $"fp-{ruleId}", + Frozen = frozen, + }; + + private static CoreEvaluationTransaction Adjunto(CoreEvaluationTransaction t, string fase = "construction") + { + t.AttachTo(Tenant, Iniciativa, fase, "actor-1"); + return t; + } + + [Fact] + public void UN_DEPOSITO_SUELTO_NO_HABLA_DE_NINGUNA_FASE() + { + // Dejarlo entrar haría que la evaluación de una compuerta dependiera de evidencia que + // nadie situó ahí. + CoreDepositSignals.From(Deposito("FAIL", Violacion())).Should().BeEmpty(); + } + + [Fact] + public void LaCategoriaSeUsaComoSeñalCuandoLaHay() + { + // Es lo que permite a un tenant decir «las violaciones de arquitectura que me depositen + // bloquean, las de estilo no». + var obs = CoreDepositSignals.From(Adjunto(Deposito("FAIL", Violacion(categoria: "architecture")))); + + obs.Should().Contain(o => o.Signal == "architecture" && o.Severity == "error"); + } + + [Fact] + public void SinCategoriaCaeEnLaSeñalDeDeposito() + { + // Inventarle una categoría sería adivinar de qué habla. + var obs = CoreDepositSignals.From(Adjunto(Deposito("FAIL", Violacion()))); + + obs.Should().Contain(o => o.Signal == CoreDepositSignals.DepositSignal && o.Severity == "error"); + } + + [Fact] + public void EL_VEREDICTO_SE_OBSERVA_AUNQUE_NO_HAYA_VIOLACIONES() + { + // No es redundante: un productor puede devolver FAIL sin violaciones tipadas, y sin esta + // observación ese caso sería invisible para la matriz. + var obs = CoreDepositSignals.From(Adjunto(Deposito("FAIL"))); + + obs.Should().ContainSingle(); + obs[0].Signal.Should().Be(CoreDepositSignals.DepositSignal); + obs[0].Severity.Should().BeNull("el veredicto no lleva severidad; inventarle una permitiría reglas que nadie escribió"); + obs[0].Detail.Should().Contain("construction"); + } + + [Fact] + public void UNA_VIOLACION_CONGELADA_NO_SE_VUELVE_A_OBSERVAR() + { + // Es una que el tenant ya aceptó a sabiendas, y el propio ledger ya la excluye de su + // conteo de bloqueantes. Contarla aquí haría que las dos superficies del mismo producto + // discreparan sobre el mismo hecho. + var obs = CoreDepositSignals.From(Adjunto(Deposito("FAIL", Violacion(frozen: true)))); + + obs.Should().ContainSingle("sólo queda la observación del veredicto"); + obs[0].Signal.Should().Be(CoreDepositSignals.DepositSignal); + } + + [Fact] + public void LaSeveridadViajaTalCual() + { + // Normalizarla escondería un valor que el productor sí emitió; el evaluador ya sabe tratar + // una severidad desconocida sin fabricar un bloqueo. + var obs = CoreDepositSignals.From(Adjunto(Deposito("FAIL", Violacion(severidad: "catastrofica")))); + + obs.Should().Contain(o => o.Severity == "catastrofica"); + } + + [Fact] + public void ElDetalleLlevaDondeYQuienLoDijo() + { + var obs = CoreDepositSignals.From(Adjunto(Deposito("FAIL", Violacion()))); + + var violacion = obs.First(o => o.Detail.Contains("R1")); + violacion.Detail.Should().Contain("src/x.cs").And.Contain("core-run-1"); + } + + [Fact] + public void DeExtremoAExtremo_ElTenantDecideSiUnDepositoBloquea() + { + // La prueba que junta las tres fichas: depósito adjunto (CP-07) → observaciones → matriz + // del tenant (CP-06). + var obs = CoreDepositSignals.From(Adjunto(Deposito("FAIL", Violacion(categoria: "architecture")))); + + var sinReglas = CoreSignalEvaluator.Evaluate(obs, [], DateTime.UtcNow); + sinReglas.Blocking.Should().BeEmpty("sin regla configurada nada bloquea: es el default de CP-06"); + + var conRegla = CoreSignalEvaluator.Evaluate( + obs, [new CoreSignalRule("architecture", CoreSignalDisposition.Blocking)], DateTime.UtcNow); + conRegla.Blocking.Should().ContainSingle().Which.Signal.Should().Be("architecture"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Governance/CoreSignalMatrixTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/CoreSignalMatrixTests.cs new file mode 100644 index 00000000..45d8a4bb --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/CoreSignalMatrixTests.cs @@ -0,0 +1,154 @@ +using Tracker.Domain.Governance.GatePolicy; + +namespace Tracker.Tests.Domain.Governance; + +/// +/// CP-06 — la matriz que convierte señales del Core en decisión de compuerta. +/// +/// Lo que se prueba no es que las reglas se apliquen, sino las propiedades que deciden si +/// esta matriz es segura de poner delante de un avance de fase: que lo NO configurado no bloquee, +/// que una regla más específica gane sin depender del orden de la lista, que una severidad +/// desconocida no se coaccione, y que la traza permita auditar la decisión después. +/// +public class CoreSignalMatrixTests +{ + private static readonly DateTime Momento = new(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc); + + private static CoreSignalEvaluationOutcome Evaluar( + IReadOnlyList observaciones, + params CoreSignalRule[] reglas) + => CoreSignalEvaluator.Evaluate(observaciones, reglas, Momento); + + private static CoreSignalObservation Señal(string kind, string? severidad = null) => + new(kind, severidad, $"hallazgo de {kind}"); + + [Fact] + public void LO_NO_CONFIGURADO_NO_BLOQUEA() + { + // La propiedad de seguridad de esta ficha. El default contrario convertiría cada dimensión + // nueva que el Core publique en un bloqueo que ningún tenant pidió — el producto opinando + // por su cuenta, que es justo lo que T-056 prohíbe. + var r = Evaluar([Señal("design"), Señal("architecture", "error")]); + + r.Blocking.Should().BeEmpty(); + r.Results.Should().OnlyContain(x => x.Disposition == CoreSignalDisposition.Advisory); + r.Results.Should().OnlyContain(x => x.AppliedRule == null); + } + + [Fact] + public void UnTenantDecideQueUnaSeñalBloquea() + { + var r = Evaluar( + [Señal("phaseArtifacts"), Señal("design")], + new CoreSignalRule("phaseArtifacts", CoreSignalDisposition.Blocking)); + + r.Blocking.Should().ContainSingle().Which.Signal.Should().Be("phaseArtifacts"); + r.AdvisoryOnly.Should().ContainSingle().Which.Signal.Should().Be("design"); + } + + [Fact] + public void LaDecisionDiceQueBloqueoYQueSoloRecomendo() + { + // Criterio 3. Si sólo se pudiera leer lo que bloqueó, una recomendación del Core sería + // indistinguible de que el Core no dijera nada. + var r = Evaluar( + [Señal("architecture", "error"), Señal("qualitySignals")], + new CoreSignalRule("architecture", CoreSignalDisposition.Blocking)); + + r.Blocking.Single().Reason.Should().Contain("architecture"); + r.AdvisoryOnly.Single().Signal.Should().Be("qualitySignals"); + r.AdvisoryOnly.Single().Reason.Should().Contain("no bloquea"); + } + + [Fact] + public void LaSeveridadMinimaRecortaLaRegla() + { + var reglas = new[] { new CoreSignalRule("architecture", CoreSignalDisposition.Blocking, CoreSignalSeverity.Error) }; + + CoreSignalEvaluator.Evaluate([Señal("architecture", "error")], reglas, Momento) + .Blocking.Should().ContainSingle(); + CoreSignalEvaluator.Evaluate([Señal("architecture", "warning")], reglas, Momento) + .Blocking.Should().BeEmpty("un warning no alcanza el mínimo que el tenant fijó"); + } + + [Fact] + public void GanaLaReglaMasEspecificaNoLaPrimeraDeLaLista() + { + // Un desempate por posición dejaría el resultado a merced de cómo el tenant ordenó su + // lista, que no es una decisión que nadie haya tomado conscientemente. Se comprueba en + // los DOS órdenes: si sólo se probara uno, un evaluador «primera que encaje» pasaría. + var general = new CoreSignalRule("design", CoreSignalDisposition.Advisory); + var especifica = new CoreSignalRule("design", CoreSignalDisposition.Blocking, CoreSignalSeverity.Error); + + CoreSignalEvaluator.Evaluate([Señal("design", "error")], [general, especifica], Momento) + .Blocking.Should().ContainSingle(); + CoreSignalEvaluator.Evaluate([Señal("design", "error")], [especifica, general], Momento) + .Blocking.Should().ContainSingle(); + } + + [Fact] + public void UNA_SEVERIDAD_DESCONOCIDA_NO_SE_COACCIONA() + { + // Subirla fabricaría bloqueos; bajarla escondería hallazgos. Se deja fuera de las reglas + // con mínimo, así que sigue viéndose por el camino del default en vez de desaparecer. + var r = Evaluar( + [Señal("architecture", "catastrofica")], + new CoreSignalRule("architecture", CoreSignalDisposition.Blocking, CoreSignalSeverity.Error)); + + r.Blocking.Should().BeEmpty(); + r.Results.Should().ContainSingle().Which.Severity.Should().Be("catastrofica", + "la severidad observada se conserva tal cual; no se normaliza a una conocida"); + } + + [Fact] + public void UnaSeñalSinSeveridadNoSatisfaceUnMinimo() + { + var r = Evaluar( + [Señal("design")], + new CoreSignalRule("design", CoreSignalDisposition.Blocking, CoreSignalSeverity.Warning)); + + r.Blocking.Should().BeEmpty("afirmar que alcanza el mínimo inventaría un dato que el Core no mandó"); + } + + [Fact] + public void LaTrazaLLEVA_LA_CONFIGURACION_APLICADA() + { + // Criterio 4. La matriz de un tenant cambia; sin la copia de las reglas vigentes en ese + // instante, auditar la decisión exigiría creer que la configuración de hoy es la de + // entonces — justo lo que una auditoría no puede asumir. + var regla = new CoreSignalRule("phaseArtifacts", CoreSignalDisposition.Blocking); + var r = Evaluar([Señal("phaseArtifacts")], regla); + + r.AppliedRules.Should().ContainSingle().Which.Should().Be(regla); + r.Blocking.Single().AppliedRule.Should().Be(regla); + r.EvaluatedAtUtc.Should().Be(Momento); + } + + [Fact] + public void HiddenYNotApplicableSeRegistranYNoBloquean() + { + var r = Evaluar( + [Señal("drift"), Señal("knowledge")], + new CoreSignalRule("drift", CoreSignalDisposition.Hidden), + new CoreSignalRule("knowledge", CoreSignalDisposition.NotApplicable)); + + r.Blocking.Should().BeEmpty(); + r.Results.Should().HaveCount(2, "descartar no es lo mismo que no haber visto"); + r.AdvisoryOnly.Should().BeEmpty("ninguna de las dos es advisory, y el informe no debe fingir que lo son"); + } + + [Fact] + public void UnaReglaInvalidaNoSeAlmacena() + { + // Guardarla y luego ignorarla daría un tenant que cree haber configurado un bloqueo y una + // compuerta que no lo aplica, sin que nada lo señale. + var creada = GatePolicy.Create( + Guid.NewGuid(), GatePhase.Construction, GateMode.Simple, + [], new ApprovalPolicy(ApprovalStrategy.Single, ApprovalStages.Sequential, []), + requiresCoreVerdict: false, createdBy: "test", + coreSignalRules: [new CoreSignalRule("design", "casi-bloqueante")]); + + creada.IsFailure.Should().BeTrue(); + creada.Error.Should().Be("GatePolicy.InvalidCoreSignalDisposition"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GateSubmissionCoreSignalsTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GateSubmissionCoreSignalsTests.cs new file mode 100644 index 00000000..a7fd6ba0 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GateSubmissionCoreSignalsTests.cs @@ -0,0 +1,86 @@ +using Tracker.Domain.Governance.GatePolicy; +using Tracker.Domain.Governance.GateSubmission; + +namespace Tracker.Tests.Domain.Governance; + +/// +/// CP-06 — la matriz plegada en la DECISIÓN de la compuerta. +/// +/// Un evaluador que nadie llama es el defecto que este tablero lleva toda la campaña +/// corrigiendo, así que lo que se prueba aquí es el cableado: que una disposición `blocking` +/// impida avanzar, que `advisory` no lo impida, que una compuerta SIN matriz se comporte +/// exactamente como antes, y que la traza quede persistida en la entrega. +/// +public class GateSubmissionCoreSignalsTests +{ + private static readonly DateTime Momento = new(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc); + + private static GateSubmission Entrega() + { + var politica = GatePolicy.Create( + Guid.NewGuid(), GatePhase.Construction, GateMode.Simple, + requiredEvidence: [], + approvalPolicy: new ApprovalPolicy(ApprovalStrategy.Single, ApprovalStages.Sequential, []), + requiresCoreVerdict: false, + createdBy: "test").Value!; + + return GateSubmission.Open( + politica, politica.TenantId, Guid.NewGuid(), GatePhase.Construction, "test", []).Value!; + } + + private static CoreSignalEvaluationOutcome Señales(params CoreSignalRule[] reglas) => + CoreSignalEvaluator.Evaluate( + [new CoreSignalObservation("phaseArtifacts", null, "falta el PRD")], reglas, Momento); + + [Fact] + public void UnaSeñalBloqueanteImpideAprobar() + { + var entrega = Entrega(); + + var unmet = entrega.Evaluate( + coreSignals: Señales(new CoreSignalRule("phaseArtifacts", CoreSignalDisposition.Blocking))).Value!; + + unmet.Should().Contain("core-signal:phaseArtifacts"); + entrega.State.Should().NotBe(GateSubmissionState.Approved); + } + + [Fact] + public void UnaSeñalADVISORY_NO_IMPIDE_APROBAR() + { + // La otra mitad, y la que de verdad separa esta ficha de «todo lo del Core bloquea». + var entrega = Entrega(); + + var unmet = entrega.Evaluate(coreSignals: Señales()).Value!; + + unmet.Should().BeEmpty(); + entrega.State.Should().Be(GateSubmissionState.Approved); + } + + [Fact] + public void UNA_COMPUERTA_SIN_MATRIZ_SE_COMPORTA_COMO_ANTES() + { + // Sin esto, añadir CP-06 cambiaría la conducta de cada compuerta ya configurada sin que + // ningún tenant hubiera pedido nada. + var entrega = Entrega(); + + var unmet = entrega.Evaluate().Value!; + + unmet.Should().BeEmpty(); + entrega.State.Should().Be(GateSubmissionState.Approved); + entrega.CoreSignalEvaluation.Should().BeEmpty(); + } + + [Fact] + public void LaTrazaYLasReglasQuedanEnLaEntrega() + { + // Criterio 4: la auditoría guarda la configuración aplicada en el momento de la decisión. + var regla = new CoreSignalRule("phaseArtifacts", CoreSignalDisposition.Blocking); + var entrega = Entrega(); + + entrega.Evaluate(coreSignals: Señales(regla)); + + entrega.CoreSignalEvaluation.Should().ContainSingle() + .Which.Disposition.Should().Be(CoreSignalDisposition.Blocking); + entrega.AppliedCoreSignalRules.Should().ContainSingle().Which.Should().Be(regla); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs new file mode 100644 index 00000000..1dee939f --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs @@ -0,0 +1,123 @@ +using Tracker.Domain.Governance.GovernancePackage; + +namespace Tracker.Tests.Domain.Governance; + +/// +/// GT-532 — el documento de paquete de gobernanza. +/// +/// Lo que se prueba aquí no es que los campos se copien, sino las tres decisiones que hacen +/// que un paquete sea seguro de mover entre clientes: que un paquete vacío se rechace, que no +/// pueda contener dos respuestas para la misma fase, y que NO lleve dentro el tenant de origen. +/// +public class GovernancePackageTests +{ + private static GovernancePackage Paquete( + IReadOnlyList? politicas = null, + IReadOnlyList? esquemas = null, + string nombre = "Estándar corporativo", + string version = "2026.1") => new() + { + Name = nombre, + Version = version, + GatePolicies = politicas ?? new List + { + new() { Phase = "discovery", Mode = "advisory" }, + }, + ArtifactFieldSchemas = esquemas ?? new List(), + }; + + [Fact] + public void UnPaqueteCompletoEsValido() + { + Paquete().Validate().Should().BeEmpty(); + } + + [Fact] + public void UnPaqueteVacioSeRechaza() + { + // Aplicar un paquete vacío no haría nada y el informe diría «0 cambios», que es + // indistinguible de un fallo de exportación. La misma confusión entre «no había nada» y + // «no se leyó nada» que las guardas de este repositorio persiguen. + var vacio = Paquete(politicas: new List()); + vacio.IsEmpty.Should().BeTrue(); + vacio.Validate().Should().Contain("GovernancePackage.Empty"); + } + + [Fact] + public void ExigeNombreYVersion() + { + Paquete(nombre: " ").Validate().Should().Contain("GovernancePackage.NameRequired"); + Paquete(version: "").Validate().Should().Contain("GovernancePackage.VersionRequired"); + } + + [Fact] + public void NoAdmiteDosPoliticasParaLaMismaFase() + { + // Con dos, el resultado dependería del orden de aplicación: una forma silenciosa de no + // ser determinista, y de que dos tenants con el «mismo» paquete acaben distintos. + var dos = Paquete(politicas: new List + { + new() { Phase = "qa", Mode = "advisory" }, + new() { Phase = "qa", Mode = "blocking" }, + }); + dos.Validate().Should().Contain("GovernancePackage.DuplicatePhase:qa"); + } + + [Fact] + public void NoAdmiteDosEsquemasParaElMismoTipoDeArtefacto() + { + var dos = Paquete(esquemas: new List + { + new() { ArtifactType = "prd" }, + new() { ArtifactType = "prd" }, + }); + dos.Validate().Should().Contain("GovernancePackage.DuplicateArtifactType:prd"); + } + + [Fact] + public void RechazaUnaPoliticaSinFaseYUnEsquemaSinTipo() + { + Paquete(politicas: new List { new() { Phase = " ", Mode = "advisory" } }) + .Validate().Should().Contain("GovernancePackage.PolicyPhaseRequired"); + + Paquete(esquemas: new List { new() { ArtifactType = " " } }) + .Validate().Should().Contain("GovernancePackage.SchemaArtifactTypeRequired"); + } + + [Fact] + public void ElDocumentoNoExponeNingunIdentificadorDeTenant() + { + // La prueba que de verdad importa. Un paquete que arrastrara el tenant de origen al + // destino sería la fuga de aislamiento que el producto prohíbe, y es un error que se + // cometería anadiendo un campo «por comodidad» meses despues. Se comprueba por REFLEXION + // sobre el tipo, no sobre una instancia, para que anadir el campo rompa esta prueba. + var propiedades = typeof(GovernancePackage).GetProperties() + .Select(p => p.Name) + .ToList(); + + propiedades.Should().NotContain("TenantId"); + propiedades.Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEquivalentTo(new List(), + "la procedencia viaja como texto en `ExportedFrom`, nunca como una clave que " + + "alguien pueda usar para escribir en otro tenant"); + + foreach (var tipo in new[] + { + typeof(PackagedGatePolicy), typeof(PackagedArtifactFieldSchema), + typeof(PackagedCriterion), typeof(PackagedCustomField), + }) + { + tipo.GetProperties().Select(p => p.Name) + .Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEmpty($"{tipo.Name} tampoco puede llevar tenant dentro de un paquete"); + } + } + + [Fact] + public void LaProcedenciaEsTextoInformativoYSobrevive() + { + var p = Paquete() with { ExportedFrom = "acme (produccion)", ExportedAtUtc = new DateTime(2026, 8, 2) }; + p.Validate().Should().BeEmpty(); + p.ExportedFrom.Should().Be("acme (produccion)"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Integration/CoreEvaluationAttachmentTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Integration/CoreEvaluationAttachmentTests.cs new file mode 100644 index 00000000..30df8312 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Integration/CoreEvaluationAttachmentTests.cs @@ -0,0 +1,147 @@ +using Tracker.Domain.Integration.CoreEvaluationTransaction; + +namespace Tracker.Tests.Domain.Integration; + +/// +/// CP-07 — un depósito del Core colgado del expediente SDLC. +/// +/// Lo que se prueba no es que los campos se guarden, sino las propiedades que deciden si +/// esta operación es segura: que adjuntar NO toque el veredicto, que no cruce la frontera de +/// tenant —es la operación que une dos agregados, o sea justo donde se colaría una fuga— y que +/// una ausencia se guarde como ausencia. +/// +public class CoreEvaluationAttachmentTests +{ + private static readonly Guid Tenant = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid OtroTenant = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid Iniciativa = Guid.Parse("33333333-3333-3333-3333-333333333333"); + + private static CoreEvaluationTransaction Deposito() + { + var deposito = new CoreEvaluationDeposit + { + SchemaVersion = "1.0.0", + CorrelationId = "core-run-1", + ProducerSurface = "cli", + EvaluatedAtUtc = new DateTime(2026, 8, 3, 9, 0, 0, DateTimeKind.Utc), + OverallVerdict = "FAIL", + Outcome = "rejected", + Violations = + [ + new CoreEvaluationViolation { RuleId = "R1", Severity = "error", Fingerprint = "f1" }, + ], + BlockingViolationCount = 1, + }; + return CoreEvaluationTransaction.Ingest(Tenant, deposito).Value!; + } + + [Fact] + public void ADJUNTAR_NO_TOCA_EL_VEREDICTO() + { + // Es la propiedad que separa un VÍNCULO de una segunda opinión. Si adjuntar pudiera + // ajustar el veredicto, el ledger dejaría de ser lo que el productor depositó. + var t = Deposito(); + var antes = (t.OverallVerdict, t.Outcome, t.BlockingViolationCount, t.Violations.Count); + + t.AttachTo(Tenant, Iniciativa, "construction", "actor-1").IsSuccess.Should().BeTrue(); + + (t.OverallVerdict, t.Outcome, t.BlockingViolationCount, t.Violations.Count).Should().Be(antes); + t.Attachment!.InitiativeId.Should().Be(Iniciativa); + t.Attachment.Phase.Should().Be("construction"); + t.Attachment.AttachedBy.Should().Be("actor-1"); + } + + [Fact] + public void NO_CRUZA_LA_FRONTERA_DE_TENANT() + { + // Adjuntar es la operación que une dos agregados, así que es exactamente donde se colaría + // una fuga entre clientes. + var t = Deposito(); + + var r = t.AttachTo(OtroTenant, Iniciativa, "construction", "actor-1"); + + r.IsFailure.Should().BeTrue(); + r.Error.Should().Be("CoreEvaluationTransaction.TenantMismatch"); + t.Attachment.Should().BeNull("un intento fallido no puede dejar rastro parcial"); + } + + [Fact] + public void UnaAusenciaSeGuardaComoAusencia() + { + // «No se dijo de qué artefacto habla» y «habla del artefacto llamado ""» no son lo mismo. + var t = Deposito(); + + t.AttachTo(Tenant, Iniciativa, "construction", "actor-1", artifactKind: " ", gateSubmissionId: Guid.Empty); + + t.Attachment!.ArtifactKind.Should().BeNull(); + t.Attachment.GateSubmissionId.Should().BeNull(); + } + + [Fact] + public void PuedeApuntarAUnArtefactoYAUnaEntregaConcretos() + { + var entrega = Guid.NewGuid(); + var t = Deposito(); + + t.AttachTo(Tenant, Iniciativa, "qa", "actor-1", "coverage-report", entrega); + + t.Attachment!.ArtifactKind.Should().Be("coverage-report"); + t.Attachment.GateSubmissionId.Should().Be(entrega); + } + + [Fact] + public void ReAdjuntarSustituyeElVinculoYNoLoDuplica() + { + var t = Deposito(); + t.AttachTo(Tenant, Iniciativa, "construction", "actor-1"); + + t.AttachTo(Tenant, Iniciativa, "qa", "actor-2").IsSuccess.Should().BeTrue(); + + t.Attachment!.Phase.Should().Be("qa"); + t.Attachment.AttachedBy.Should().Be("actor-2"); + } + + [Fact] + public void SePuedeDesadjuntarPeroNoDesdepositar() + { + // Adjuntar mal es un error humano corriente y tiene que ser reversible. Lo que NO se + // deshace es el depósito, que es el registro de lo que el Core dijo. + var t = Deposito(); + t.AttachTo(Tenant, Iniciativa, "construction", "actor-1"); + + t.Detach(Tenant).IsSuccess.Should().BeTrue(); + + t.Attachment.Should().BeNull(); + t.OverallVerdict.Should().Be("FAIL", "el veredicto depositado sigue ahí"); + t.Violations.Should().ContainSingle(); + } + + [Fact] + public void DesadjuntarTampocoCruzaLaFrontera() + { + var t = Deposito(); + t.AttachTo(Tenant, Iniciativa, "construction", "actor-1"); + + t.Detach(OtroTenant).IsFailure.Should().BeTrue(); + + t.Attachment.Should().NotBeNull("otro cliente no puede desadjuntar lo que no es suyo"); + } + + [Theory] + [InlineData("", "construction", "CoreEvaluationTransaction.InitiativeRequired")] + [InlineData("33333333-3333-3333-3333-333333333333", " ", "CoreEvaluationTransaction.PhaseRequired")] + public void UnVinculoIncompletoNoSeGuarda(string iniciativa, string fase, string error) + { + var t = Deposito(); + + var r = t.AttachTo( + Tenant, + string.IsNullOrEmpty(iniciativa) ? Guid.Empty : Guid.Parse(iniciativa), + fase, + "actor-1"); + + r.IsFailure.Should().BeTrue(); + r.Error.Should().Be(error); + t.Attachment.Should().BeNull(); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Sdlc/ArtifactVersioningTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Sdlc/ArtifactVersioningTests.cs new file mode 100644 index 00000000..707ce026 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Sdlc/ArtifactVersioningTests.cs @@ -0,0 +1,180 @@ +using Tracker.Domain.Sdlc.PhaseArtifact; +using DomainPhaseArtifact = Tracker.Domain.Sdlc.PhaseArtifact.PhaseArtifact; + +namespace Tracker.Tests.Domain.Sdlc; + +/// +/// CP-14 — versionado documental de artefactos SDLC. +/// +/// Lo que se prueba son las propiedades que hacen auditable una historia: que una versión no +/// se edite nunca, que restaurar deje rastro en vez de reescribir el pasado, que un guardado sin +/// cambios no ensucie la historia, y que lo aprobado no cambie bajo los pies de quien lo aprobó. +/// +public class ArtifactVersioningTests +{ + private const string Autor = "actor-1"; + + private static DomainPhaseArtifact Expediente() => + DomainPhaseArtifact.CreateForPhase(Guid.NewGuid(), Guid.NewGuid(), "construction", Guid.NewGuid()).Value!; + + private static IReadOnlyList Historia(DomainPhaseArtifact a, string tipo = "prd") => + a.VersionsOf(tipo, null); + + [Fact] + public void CadaEdicionCreaUnaVersionConAutorFechaYChecksum() + { + var a = Expediente(); + + a.SetItem("prd", null, "prd-v1.md", null, authoredBy: Autor, changeReason: "primera entrega"); + + var v = Historia(a).Should().ContainSingle().Subject; + v.Version.Should().Be(1); + v.AuthoredBy.Should().Be(Autor); + v.ChangeReason.Should().Be("primera entrega"); + v.Checksum.Should().HaveLength(64, "es un SHA-256 en hexadecimal"); + v.Status.Should().Be(ArtifactVersionStatus.Current); + } + + [Fact] + public void UNA_VERSION_NO_SE_EDITA_NUNCA() + { + // Editar en sitio haría que «qué versión se envió al Core» cambiara bajo los pies de quien + // hizo la pregunta. + var a = Expediente(); + a.SetItem("prd", null, "prd-v1.md", null, authoredBy: Autor); + var primera = Historia(a).Single(); + + a.SetItem("prd", null, "prd-v2.md", null, authoredBy: "actor-2"); + + var historia = Historia(a); + historia.Should().HaveCount(2); + // Lo inmutable es el CONTENIDO y su autoría. El estado sí transiciona: pasar a + // `superseded` es precisamente el registro de que otra la sustituyó, y confundir ambas + // cosas exigiría o bien perder ese registro o bien un segundo sitio donde guardarlo. + historia[0].Should().BeEquivalentTo(primera, o => o.Excluding(v => v.Status)); + historia[0].Status.Should().Be(ArtifactVersionStatus.Superseded); + historia[1].Status.Should().Be(ArtifactVersionStatus.Current); + } + + [Fact] + public void UN_GUARDADO_SIN_CAMBIOS_NO_CREA_VERSION() + { + // Llenar la historia de ruido haría más difícil encontrar el cambio real, que es justo lo + // que la historia existe para permitir. + var a = Expediente(); + a.SetItem("prd", null, "prd-v1.md", null, authoredBy: Autor); + a.SetItem("prd", null, "prd-v1.md", null, authoredBy: Autor); + + Historia(a).Should().ContainSingle(); + } + + [Fact] + public void SIN_AUTOR_NO_SE_VERSIONA_PERO_EL_EXPEDIENTE_SE_ACTUALIZA() + { + // Guardar una versión a medias daría una historia que parece trazable y no lo es. + var a = Expediente(); + + a.SetItem("prd", null, "prd-v1.md", null).IsSuccess.Should().BeTrue(); + + a.Items.Should().ContainSingle().Which.Ref.Should().Be("prd-v1.md"); + Historia(a).Should().BeEmpty(); + } + + [Fact] + public void RESTAURAR_ABRE_UNA_VERSION_NUEVA_Y_NO_REESCRIBE_EL_PASADO() + { + // Hacer que parezca que nunca se editó es exactamente lo que un expediente auditable no + // puede hacer. + var a = Expediente(); + a.SetItem("prd", null, "prd-v1.md", null, authoredBy: Autor); + a.SetItem("prd", null, "prd-v2.md", null, authoredBy: Autor); + + a.RestoreVersion("prd", null, 1, "actor-3").IsSuccess.Should().BeTrue(); + + var historia = Historia(a); + historia.Should().HaveCount(3); + historia[2].Version.Should().Be(3); + historia[2].Ref.Should().Be("prd-v1.md", "el contenido vuelve"); + historia[2].ChangeReason.Should().Contain("Restaurada la versión 1"); + historia[1].Ref.Should().Be("prd-v2.md", "lo que hubo en medio sigue en la historia"); + } + + [Fact] + public void SePuedeCompararDosVersionesPorChecksum() + { + // Criterio 2: comparar. Dos versiones con el mismo checksum llevan lo mismo, y eso permite + // decir «esto es exactamente lo que se evaluó» sin volver a leer el documento. + var a = Expediente(); + a.SetItem("prd", null, "prd-v1.md", null, authoredBy: Autor); + a.SetItem("prd", null, "prd-v2.md", null, authoredBy: Autor); + a.RestoreVersion("prd", null, 1, Autor); + + var h = Historia(a); + h[0].Checksum.Should().Be(h[2].Checksum, "la restaurada lleva lo mismo que la 1"); + h[0].Checksum.Should().NotBe(h[1].Checksum); + } + + [Fact] + public void UNA_VERSION_APROBADA_NO_SE_MODIFICA_EN_SITIO() + { + // Criterio 4. Si el contenido aprobado pudiera cambiar, la aprobación dejaría de decir + // nada sobre qué se aprobó. + var a = Expediente(); + a.SetItem("prd", null, "prd-v1.md", null, authoredBy: Autor); + a.ApproveCurrentVersion("prd", null).IsSuccess.Should().BeTrue(); + var aprobada = Historia(a).Single(); + + a.SetItem("prd", null, "prd-v2.md", null, authoredBy: "actor-2"); + + var h = Historia(a); + h[0].Ref.Should().Be("prd-v1.md", "lo aprobado sigue siendo lo que era"); + h[0].Checksum.Should().Be(aprobada.Checksum); + h[0].Status.Should().Be(ArtifactVersionStatus.Superseded, "queda registrado que fue sustituida"); + h[1].Version.Should().Be(2); + } + + [Fact] + public void SELLAR_SIN_VERSION_VIVA_NO_ES_UN_ERROR_SILENCIOSO() + { + // Es el caso de una edición sin autor, que a propósito no versiona. Al aprobar la + // compuerta no hay nada que sellar, y eso tiene que ser distinguible de un fallo real. + var a = Expediente(); + a.SetItem("prd", null, "prd.md", null); + + var r = a.ApproveCurrentVersion("prd", null); + + r.IsFailure.Should().BeTrue(); + r.Error.Should().Be("PhaseArtifact.NoCurrentVersion"); + } + + [Fact] + public void CadaItemLlevaSuPropiaHistoria() + { + var a = Expediente(); + a.SetItem("prd", null, "prd.md", null, authoredBy: Autor); + a.SetItem("coverage-report", null, "cov.xml", null, authoredBy: Autor); + a.SetItem("prd", null, "prd-v2.md", null, authoredBy: Autor); + + Historia(a, "prd").Should().HaveCount(2); + Historia(a, "coverage-report").Should().ContainSingle(); + } + + [Fact] + public void RestaurarUnaVersionQueNoExisteFalla() + { + var a = Expediente(); + a.SetItem("prd", null, "prd.md", null, authoredBy: Autor); + + var r = a.RestoreVersion("prd", null, 99, Autor); + + r.IsFailure.Should().BeTrue(); + r.Error.Should().Be("PhaseArtifact.VersionNotFound"); + } + + [Fact] + public void UnRefYUnContenidoConElMismoTextoNoTienenLaMismaHuella() + { + // Son cosas distintas; confundirlas diría que dos versiones llevan lo mismo cuando no. + ArtifactChecksum.Of("igual", null).Should().NotBe(ArtifactChecksum.Of(null, "igual")); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Governance/CoreSyncPhaseArtifactProfileSourceTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Governance/CoreSyncPhaseArtifactProfileSourceTests.cs new file mode 100644 index 00000000..b53d100c --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Governance/CoreSyncPhaseArtifactProfileSourceTests.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Tracker.Application.Governance.ArtifactFieldSchema; +using Tracker.Infrastructure.Governance; + +namespace Tracker.Tests.Infrastructure.Governance; + +/// +/// GAP-004 — la fuente `core-sync`. +/// +/// Lo que se prueba aquí no es que los campos se copien, sino las tres propiedades que +/// deciden si esta fuente es segura de poner en el camino de una decisión de gobierno: que una +/// sincronización mala NO pueda vaciar el catálogo, que la procedencia no mienta antes de que haya +/// habido una sincronización, y que las fases se traduzcan por una tabla escrita y no por parecido +/// de cadena. +/// +public class CoreSyncPhaseArtifactProfileSourceTests +{ + private static JsonElement Json(string s) => JsonDocument.Parse(s).RootElement.Clone(); + + private static CoreSyncPhaseArtifactProfileSource Nueva(out IPhaseArtifactProfileSource standIn) + { + standIn = new FakeStandIn(); + return new CoreSyncPhaseArtifactProfileSource(standIn, NullLogger.Instance); + } + + private sealed class FakeStandIn : IPhaseArtifactProfileSource + { + public string Source => PhaseArtifactProfileSourceKind.StandIn; + + public IReadOnlyList GetProfiles() => new List + { + new() + { + Phase = "discovery", + Source = PhaseArtifactProfileSourceKind.StandIn, + Artifacts = new List + { + new() { ArtifactKind = "prd", Label = "PRD", Required = true }, + }, + }, + }; + } + + private const string RegistroValido = """ + { + "artifacts": [ + { "id": "prd", "label": "PRD", "phases": ["discovery"], "classification": "binding", + "schemaId": "https://evolith.dev/schema/prd.schema.json" }, + { "id": "coverage-report", "label": "Coverage Report", + "phases": ["construction", "quality"], "classification": "binding", + "producedBy": { "format": "cobertura|lcov|jacoco" } }, + { "id": "cfr-metric", "label": "CFR Metric", "phases": ["quality"], "classification": "advisory" } + ] + } + """; + + [Fact] + public void AntesDeSincronizarSirveElEspejoYNoMienteSobreSuProcedencia() + { + var src = Nueva(out _); + src.Source.Should().Be(PhaseArtifactProfileSourceKind.StandIn, + "declararse `core-sync` sin haber sincronizado seria justo la afirmacion que este campo " + + "existe para hacer comprobable"); + src.GetProfiles().Should().ContainSingle(p => p.Phase == "discovery"); + } + + [Fact] + public void TrasUnaSincronizacionValidaSirveLoDelCoreYLoDeclara() + { + var src = Nueva(out _); + src.Apply(Json(RegistroValido)).Should().BeTrue(); + src.Source.Should().Be(PhaseArtifactProfileSourceKind.CoreSync); + + var perfiles = src.GetProfiles(); + perfiles.Should().OnlyContain(p => p.Source == PhaseArtifactProfileSourceKind.CoreSync); + perfiles.Single(p => p.Phase == "discovery").Artifacts + .Should().ContainSingle(a => a.ArtifactKind == "prd" && a.Required); + } + + [Fact] + public void UnArtefactoConDosFasesApareceEnLasDos() + { + // `coverage-report` es la discrepancia que ADR-0125 resolvio con una LISTA de fases: se + // exige en construccion y se espera en calidad. Si aqui apareciera en una sola, el + // satelite reintroduciria la media verdad que el Core acaba de eliminar. + var src = Nueva(out _); + src.Apply(Json(RegistroValido)); + + src.GetProfiles().Single(p => p.Phase == "construction").Artifacts + .Should().Contain(a => a.ArtifactKind == "coverage-report"); + src.GetProfiles().Single(p => p.Phase == "qa").Artifacts + .Should().Contain(a => a.ArtifactKind == "coverage-report"); + } + + [Fact] + public void TraduceLasFasesPorTablaEscritaYNoPorParecidoDeCadena() + { + // El Core dice `quality` y `deployment`; el Tracker dice `qa` y `release`. Un mapeador por + // cadena los perderia en silencio, que es la misma clase de fallo que las tres etiquetas + // divergentes del catalogo de esquemas. + var src = Nueva(out _); + src.Apply(Json(RegistroValido)); + + var fases = src.GetProfiles().Select(p => p.Phase).ToList(); + fases.Should().Contain("qa"); + fases.Should().NotContain("quality"); + } + + [Fact] + public void UNA_SINCRONIZACION_MALA_NO_PUEDE_VACIAR_EL_CATALOGO() + { + // La prueba que de verdad importa. Una fase con cero artefactos requeridos se lee como + // «aqui no hace falta nada», que es lo mas peligroso que una superficie de gobierno puede + // decir por accidente. + var src = Nueva(out _); + src.Apply(Json(RegistroValido)).Should().BeTrue(); + var antes = src.GetProfiles(); + + foreach (var malo in new[] + { + """{ "artifacts": [] }""", + """{ "artifacts": "no soy un array" }""", + """{ "otra": 1 }""", + """{ "artifacts": [ { "label": "sin id", "phases": ["discovery"] } ] }""", + }) + { + src.Apply(Json(malo)).Should().BeFalse($"«{malo}» no puede sustituir un catalogo bueno"); + } + + src.GetProfiles().Should().BeEquivalentTo(antes, "lo que se servia sigue sirviendose"); + src.Source.Should().Be(PhaseArtifactProfileSourceKind.CoreSync); + } + + [Fact] + public void UnaFaseDesconocidaDelCoreSeDescartaYNoSeAdivina() + { + var src = Nueva(out _); + var conFaseRara = """ + { + "artifacts": [ + { "id": "prd", "label": "PRD", "phases": ["discovery"], "classification": "binding" }, + { "id": "x", "label": "X", "phases": ["fase-que-no-existe"], "classification": "binding" } + ] + } + """; + src.Apply(Json(conFaseRara)).Should().BeTrue(); + + src.GetProfiles().SelectMany(p => p.Artifacts).Select(a => a.ArtifactKind) + .Should().NotContain("x", + "archivar un artefacto bajo una fase que nadie evalua lo vuelve invisible; " + + "descartarlo y avisar es honesto, adivinar la fase no"); + } + + [Fact] + public void DistingueLasDosAusenciasDeEsquema() + { + var src = Nueva(out _); + src.Apply(Json(RegistroValido)); + var todos = src.GetProfiles().SelectMany(p => p.Artifacts).ToList(); + + todos.First(a => a.ArtifactKind == "prd").CanonicalSchema!.Provenance + .Should().Be(ArtifactSchemaProvenance.CoreSchema); + todos.First(a => a.ArtifactKind == "coverage-report").CanonicalSchema!.Provenance + .Should().Be(ArtifactSchemaProvenance.ToolOutput); + todos.First(a => a.ArtifactKind == "cfr-metric").CanonicalSchema!.Provenance + .Should().Be(ArtifactSchemaProvenance.None); + } + + [Fact] + public void AdvisoryNoSeSirveComoRequerido() + { + // `binding` es lo que una compuerta exige; `advisory` solo cuenta para completitud. + // Servir lo segundo como requerido inventaria un bloqueo que el Core no declara. + var src = Nueva(out _); + src.Apply(Json(RegistroValido)); + + src.GetProfiles().SelectMany(p => p.Artifacts) + .First(a => a.ArtifactKind == "cfr-metric").Required.Should().BeFalse(); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyOperabilityTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyOperabilityTests.cs new file mode 100644 index 00000000..e1cb56c8 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyOperabilityTests.cs @@ -0,0 +1,108 @@ +namespace Tracker.Tests.Infrastructure.Transparency; + +/// +/// GT-588 — que la firma del expediente se pueda ENCENDER en un despliegue. +/// +/// El cable existía y estaba probado: un decorador de IAuditEntryRepository, un árbol +/// de Merkle, COSE_Sign1, un ledger JSONL y su interop cross-lenguaje en CI. Y sin embargo ninguna +/// decisión se firmaba en ningún entorno, por una razón que no estaba en el código: **el chart no +/// declaraba la palanca**. `Transparency` no aparecía en `values.yaml`, ni en el configmap, ni en +/// el deployment, así que un operador con claves en la mano no tenía por dónde activarla salvo +/// inventando un `--set` sobre una clave inexistente — que Helm acepta en silencio y no enciende +/// nada. +/// +/// Estas pruebas leen el chart, no el código. Es a propósito: lo que faltaba era la +/// superficie de operación, y una prueba sobre las clases habría seguido verde todo el tiempo que +/// el gap estuvo abierto. Es la misma lección que `ObservabilityConventionTests` dejó escrita para +/// las trazas — un valor que no está DECLARADO no se documenta ni se revisa. +/// +/// Ninguna de ellas enciende la firma. Que un entorno la active con claves reales es una +/// decisión de despliegue y de custodia de secretos, no algo que una prueba deba forzar. +/// +public class TransparencyOperabilityTests +{ + private static string Chart() => + Path.Combine(RepoRoot(), "product/infra/helm/evolith-tracker-api"); + + private static string Valores() => File.ReadAllText(Path.Combine(Chart(), "values.yaml")); + private static string ConfigMap() => File.ReadAllText(Path.Combine(Chart(), "templates/configmap.yaml")); + private static string Deployment() => File.ReadAllText(Path.Combine(Chart(), "templates/deployment.yaml")); + + [Fact] + public void ElChartDeclaraLaPalancaDeFirma() + { + Valores().Should().Contain("transparency:", + "un `--set transparency.enabled=true` sobre una clave que no existe en values.yaml no " + + "falla, no se documenta y no se revisa: simplemente no enciende nada"); + ConfigMap().Should().Contain("Transparency__Enabled", + "sin esta clave el configmap no puede activar la firma ni aunque el operador tenga claves"); + } + + [Fact] + public void SigueApagadaPorDefecto() + { + // La guarda existe para que encenderla sea POSIBLE, no para encenderla. Activar la firma + // cambia lo que el producto promete sobre su propio expediente y exige claves reales. + Valores().Should().MatchRegex(@"transparency:\s*\r?\n(\s+#[^\r\n]*\r?\n)*\s+enabled:\s*false", + "el defecto sigue siendo APAGADO"); + } + + [Fact] + public void LasSemillasVienenDeUnSecretYNuncaDelChart() + { + var deployment = Deployment(); + + deployment.Should().Contain("Transparency__IssuerKeySeedBase64", + "sin esta variable el pod arranca sin material de clave y la aplicacion se niega a " + + "firmar — correctamente, pero entonces el chart promete algo que no ocurre"); + deployment.Should().Contain("secretKeyRef", + "las semillas Ed25519 llegan del deposito de secretos del despliegue"); + + // Lo que NO debe estar. Una semilla por defecto convertiria el ledger en algo que parece + // firmado y no prueba nada, que es peor que no tener ledger: induce confianza. + Valores().Should().NotMatchRegex(@"KeySeed\w*:\s*[""']?[A-Za-z0-9+/]{16,}", + "el chart NUNCA versiona material de clave"); + } + + [Fact] + public void ElLedgerExigeVolumenPersistente() + { + var deployment = Deployment(); + + deployment.Should().Contain("transparency-ledger", + "el ledger necesita su propio volumen"); + deployment.Should().Contain("persistentVolumeClaim", + "un ledger en el filesystem efimero del pod se borra en cada reinicio, y un log de " + + "transparencia que se borra solo es exactamente lo que la ficha llama decorativo"); + deployment.Should().Contain("required \"transparency.persistence.existingClaim", + "pedir persistencia sin PVC debe FALLAR el render, no montar silenciosamente un " + + "emptyDir con nombre de volumen persistente"); + } + + [Fact] + public void ElCableSigueSiendoUnDecoradorYNoUnCambioEnCadaLlamante() + { + // Si alguien sustituye el decorador por llamadas explicitas, el quinto camino que se + // añada mañana no firmara y nadie lo notara. Esta prueba es sobre la FORMA del cable. + var registro = File.ReadAllText(Path.Combine( + RepoRoot(), + "src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs")); + + // El helper paso de privado a compartido (`DecorateService`) cuando GAP-004 trajo un segundo + // llamante; lo que esta prueba vigila no es el nombre sino la FORMA del cable. + registro.Should().Contain("DecorateService", + "el cable firma los caminos que YA existen y el que alguien añada sin acordarse de " + + "esta ficha; una lista de llamantes explicitos solo firma los que alguien recordo"); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) + { + dir = dir.Parent; + } + dir.Should().NotBeNull("la prueba necesita ubicar la raiz del repositorio"); + return dir!.FullName; + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs new file mode 100644 index 00000000..e5c0cf6a --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs @@ -0,0 +1,105 @@ +using System.Reflection; +using Tracker.Application.Governance.GovernancePackage; +using Tracker.Presentation.Endpoints.Governance; + +namespace Tracker.Tests.Presentation.Governance; + +/// +/// GT-532 — la propiedad que hace seguro mover gobernanza entre clientes: **el destino nunca lo +/// elige el llamante**. +/// +/// Un paquete existe para llevarse de un tenant a otro, así que es exactamente el tipo de +/// función donde un `tenantId` «por comodidad» en el cuerpo o en la query se cuela sin que nadie +/// lo note, y donde el efecto de colarlo es reescribir la gobernanza de otro cliente. Estas +/// pruebas miran los TIPOS, no una llamada concreta: añadir el campo rompe la prueba aunque nadie +/// escriba una petición que lo use. +/// +public class GovernancePackageIsolationTests +{ + [Fact] + public void ElCuerpoDeUnaAplicacionNoAceptaTenant() + { + var propiedades = typeof(GovernancePackageRequest).GetProperties().Select(p => p.Name).ToList(); + + propiedades.Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEmpty( + "el destino es el tenant de la SESION; aceptarlo en el cuerpo convertiria este " + + "endpoint en una forma de reescribir la gobernanza de otro cliente"); + } + + [Fact] + public void NingunTipoDePeticionAnidadoAceptaTenant() + { + var tipos = new[] + { + typeof(PackagedGatePolicyRequest), typeof(PackagedEvidenceRequest), + typeof(PackagedApproverRequest), typeof(PackagedCriterionRequest), + typeof(PackagedArtifactFieldSchemaRequest), typeof(PackagedCustomFieldRequest), + }; + + foreach (var t in tipos) + { + t.GetProperties().Select(p => p.Name) + .Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEmpty($"{t.Name} viaja dentro del paquete y tampoco puede nombrar un tenant"); + } + } + + [Fact] + public void ElComandoDeAplicacionRecibeElTenantComoParametroPropioYNoDesdeElPaquete() + { + // El tenant es un parametro del COMANDO, que la capa de presentacion rellena desde la + // sesion. Si algun dia alguien lo moviera dentro del documento, el paquete pasaria a + // llevar su propio destino y esta separacion desapareceria en silencio. + var ctor = typeof(ApplyGovernancePackageCommand).GetConstructors().Single(); + var parametros = ctor.GetParameters().Select(p => p.Name).ToList(); + + parametros.Should().Contain("TenantId"); + parametros.Should().Contain("Package"); + } + + [Fact] + public void LaExportacionTampocoAceptaUnTenantElegidoPorElLlamante() + { + // El handler recibe un TenantId, pero el ENDPOINT lo toma de `ITrackerUserContext`. Lo que + // se comprueba aqui es que la query no expone ningun otro camino: un segundo parametro de + // tenant seria un modo de leer la configuracion de otro cliente. + var ctor = typeof(ExportGovernancePackageQuery).GetConstructors().Single(); + ctor.GetParameters().Select(p => p.Name) + .Count(n => n!.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().Be(1); + } + + [Fact] + public void ElRepositorioNoTraeNingunPaqueteDeFabrica() + { + // T-056: el producto entrega el MECANISMO de empaquetar, no contenido con nuestra opinion + // sobre que debe exigir una compuerta. Un paquete de ejemplo APLICABLE en el arbol seria + // esa capa entrando por la puerta de atras, asi que se comprueba que no exista. + var raiz = RepoRoot(); + var sospechosos = Directory + .GetFiles(raiz, "*.json", SearchOption.AllDirectories) + .Where(f => !f.Contains("/node_modules/") && !f.Contains("/obj/") && !f.Contains("/bin/")) + .Where(f => + { + var nombre = Path.GetFileName(f).ToLowerInvariant(); + return nombre.Contains("governance-package") || nombre.Contains("governance_package"); + }) + .ToList(); + + sospechosos.Should().BeEmpty( + "el repositorio no publica paquetes de gobernanza; segun T-056 el contenido lo " + + "configura el tenant y no lo cablea el motor"); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) + { + dir = dir.Parent; + } + dir.Should().NotBeNull(); + return dir!.FullName; + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationGatewayTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationGatewayTests.cs index 294206e0..d2b6696a 100644 --- a/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationGatewayTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationGatewayTests.cs @@ -84,6 +84,283 @@ public async Task EvaluateAsync_WhenCoreFailsAndFallbackDisabled_ReturnsFailedTr result.Error!.Code.Should().Be("CoreEvaluation.HttpError"); } + // ----------------------------------------------------------------------- + // CP-05 — el resultado canónico deja de perderse en este límite. + // ----------------------------------------------------------------------- + + [Fact] + public async Task CP05_ConservaTrazabilidadDiagnosticoYProcedencia() + { + // El Tracker se quedaba con `overallVerdict` y `results.gate`; todo lo demás moría aquí. + // Los nombres de campo son los que el Core publica de verdad en `EvaluationResult` + // (core-domain/evaluation/contracts/evaluation-result.ts), comprobados antes de escribirlos. + var handler = new StubHttpMessageHandler(new + { + success = true, + data = new + { + overallVerdict = "FAIL", + outcome = "rejected", + rulesExecuted = new[] { new { ruleId = "R1", rulesetRef = "core://rs", engine = "opa", verdict = "FAIL" } }, + policiesApplied = new[] { new { ruleId = "P1", engine = "rego", verdict = "PASS" } }, + risks = new[] { new { id = "K1", level = "high", category = "coverage", message = "sin datos" } }, + recommendations = new[] + { + new { id = "REC-1", kind = "architecture", message = "separa el módulo", rationale = "acoplamiento", references = new[] { "ADR-0101" } } + }, + requiredActions = new[] + { + new { id = "ACT-1", gapId = "G-9", description = "adjunta cobertura", blocking = true, remediation = "sube el informe" } + }, + qualitySignals = new[] { new { dimension = "testing", status = "present", evidence = new[] { new { source = "jest" } } } }, + correlationId = "core-run-77", + schemaVersion = "1.2.0", + versions = new { core = "1.4.0", ruleset = "sdlc", rulesetVersion = "9.1" }, + evaluatedAt = DateTime.UtcNow + } + }); + + var result = await CreateGateway(handler).EvaluateAsync(CreateRequest(), CreateUser(), CancellationToken.None); + + var intel = result.ResponseResult.Intelligence; + intel.Should().NotBeNull(); + intel!.RulesExecuted.Single().RuleId.Should().Be("R1"); + intel.PoliciesApplied.Single().Engine.Should().Be("rego"); + intel.Risks.Single().Level.Should().Be("high"); + intel.Recommendations.Single().References.Should().ContainSingle().Which.Should().Be("ADR-0101"); + intel.RequiredActions.Single().Blocking.Should().BeTrue(); + intel.QualitySignals.Single().EvidenceCount.Should().Be(1); + intel.CorrelationId.Should().Be("core-run-77"); + intel.SchemaVersion.Should().Be("1.2.0"); + intel.CoreVersion.Should().Be("1.4.0"); + intel.RulesetVersion.Should().Be("9.1"); + } + + [Fact] + public async Task CP05_NO_DECIDE_SOLO_CONSERVA() + { + // T-056. Una `requiredAction` marcada `blocking` por el motor NO puede convertirse por sí + // sola en un gate bloqueado: qué bloquea una fase de este tenant es su configuración + // (CP-06). Si esta prueba cae, el ACL empezó a gobernar por su cuenta. + var handler = new StubHttpMessageHandler(new + { + success = true, + data = new + { + overallVerdict = "PASS", + outcome = "accepted", + requiredActions = new[] { new { id = "ACT-1", description = "haz algo", blocking = true, remediation = "x" } } + } + }); + + var result = await CreateGateway(handler).EvaluateAsync(CreateRequest(), CreateUser(), CancellationToken.None); + + result.ResultDecision.Should().Be("PASSED", "el veredicto lo da el Core, no una acción que este límite haya leído"); + result.ResponseResult.Intelligence!.RequiredActions.Single().Blocking.Should().BeTrue(); + } + + [Fact] + public async Task CP05_SinInteligenciaEsNuloNoUnBloqueVacio() + { + // Las dos ausencias son distintas: un bloque vacío diría «el motor no encontró nada», + // que no es «el motor no lo contó» (una respuesta vieja, o sin pipeline). + var handler = new StubHttpMessageHandler(new + { + success = true, + data = new { overallVerdict = "PASS", outcome = "accepted" } + }); + + var result = await CreateGateway(handler).EvaluateAsync(CreateRequest(), CreateUser(), CancellationToken.None); + + result.ResponseResult.Intelligence.Should().BeNull(); + result.ResultDecision.Should().Be("PASSED", "la respuesta mínima sigue funcionando igual que antes"); + } + + [Fact] + public async Task CP05_UnaAccionSinBlockingNoSeInventaBloqueante() + { + // El default peligroso sería el otro. Un campo ausente no puede fabricar un bloqueo que + // el motor nunca declaró. + var handler = new StubHttpMessageHandler(new + { + success = true, + data = new + { + overallVerdict = "PASS", + requiredActions = new[] { new { id = "ACT-2", description = "opcional", remediation = "y" } } + } + }); + + var result = await CreateGateway(handler).EvaluateAsync(CreateRequest(), CreateUser(), CancellationToken.None); + + result.ResponseResult.Intelligence!.RequiredActions.Single().Blocking.Should().BeFalse(); + } + + [Fact] + public async Task CP05_LosKindsQueElGateNoUsaNoSePierden() + { + // El contrato del Core publica ONCE kinds y el Tracker sólo interroga `gate`. Los otros + // diez morían en este límite: el gate usaba una parte y el resto se descartaba entero. + var handler = new StubHttpMessageHandler(new + { + success = true, + data = new + { + overallVerdict = "PASS", + results = new + { + gate = new[] { new { gateId = "G1", verdict = "PASS" } }, + design = new { maturity = "emerging", score = 42 }, + phaseArtifacts = new { missing = new[] { "prd" } }, + compliance = new { framework = "iso-27001" } + } + } + }); + + var result = await CreateGateway(handler).EvaluateAsync(CreateRequest(), CreateUser(), CancellationToken.None); + + var intel = result.ResponseResult.Intelligence!; + intel.KindsPresent.Should().BeEquivalentTo(["compliance", "design", "gate", "phaseArtifacts"]); + intel.OtherResultKinds["design"].GetProperty("score").GetInt32().Should().Be(42); + intel.OtherResultKinds["phaseArtifacts"].GetProperty("missing")[0].GetString().Should().Be("prd"); + + // `gate` sigue TIPADO: conservar los demás no puede degradar el que sí se interroga. + result.ResponseResult.Gates.Should().ContainSingle().Which.Verdict.Should().Be("passed"); + intel.OtherResultKinds.Should().NotContainKey("gate"); + } + + [Fact] + public async Task CP05_UN_KIND_QUE_EL_CORE_AÑADA_MAÑANA_TAMBIEN_SOBREVIVE() + { + // La razón de usar `JsonExtensionData` y no una lista escrita a mano de los diez: un + // espejo enumerado vuelve a perder datos en cuanto el Core publica un kind nuevo, y lo + // haría en silencio. Este kind no existe en el contrato de hoy, a propósito. + var handler = new StubHttpMessageHandler(new + { + success = true, + data = new + { + overallVerdict = "PASS", + results = new { kindQueTodaviaNoExiste = new { algo = "valioso" } } + } + }); + + var result = await CreateGateway(handler).EvaluateAsync(CreateRequest(), CreateUser(), CancellationToken.None); + + var intel = result.ResponseResult.Intelligence!; + intel.KindsPresent.Should().ContainSingle().Which.Should().Be("kindQueTodaviaNoExiste"); + intel.OtherResultKinds["kindQueTodaviaNoExiste"].GetProperty("algo").GetString().Should().Be("valioso"); + } + + [Fact] + public async Task CP05_UnResultsVacioNoInventaKinds() + { + var handler = new StubHttpMessageHandler(new + { + success = true, + data = new { overallVerdict = "PASS", results = new { } } + }); + + var result = await CreateGateway(handler).EvaluateAsync(CreateRequest(), CreateUser(), CancellationToken.None); + + result.ResponseResult.Intelligence.Should().BeNull( + "sin kinds y sin trazabilidad no hay nada que conservar, y un bloque vacío diría lo contrario"); + } + + // ----------------------------------------------------------------------- + // CP-04 — el contexto que SALE deja de ser una forma mínima. + // ----------------------------------------------------------------------- + + private static async Task PayloadEnviado(CoreEvaluationOptions? options = null) + { + var handler = new StubHttpMessageHandler(new { success = true, data = new { overallVerdict = "PASS" } }); + await CreateGateway(handler, options).EvaluateAsync(CreateRequest(), CreateUser(), CancellationToken.None); + return JsonDocument.Parse(handler.RequestJson!).RootElement.Clone(); + } + + [Fact] + public async Task CP04_DeclaraLaVersionDelContratoDeContexto() + { + var root = await PayloadEnviado(); + + root.GetProperty("schemaVersion").GetString().Should().Be("1.0.0", + "es un hecho del llamante — «hablo esta versión» — no una suposición sobre el Core"); + } + + [Fact] + public async Task CP04_MandaQuienPidioYQueRevisionSeJuzga() + { + var root = await PayloadEnviado(); + + root.GetProperty("requester").GetProperty("actorId").GetString().Should().NotBeNullOrWhiteSpace(); + root.GetProperty("requester").GetProperty("actorType").GetString().Should().Be("user"); + root.GetProperty("repositoryRevision").GetProperty("revision").GetString().Should().Be("test"); + } + + [Fact] + public async Task CP04_LA_REVISION_YA_NO_VIAJA_SOLO_COMO_PASSTHROUGH() + { + // Antes el puntero de repositorio iba dentro de `passthrough`, que el Core echa de vuelta + // SIN interpretar: el dato estaba y no participaba en la evaluación. No se deja copia + // porque no había consumidor — el Core sólo lee `evaluationDate` del passthrough, y ni el + // Tracker ni la web leen el que devuelve. + var passthrough = (await PayloadEnviado()).GetProperty("passthrough"); + + passthrough.TryGetProperty("repositoryRef", out _).Should().BeFalse(); + passthrough.TryGetProperty("operationId", out _).Should().BeTrue("la correlación propia sí sigue ahí"); + } + + [Fact] + public async Task CP04_SIN_REVISION_CONOCIDA_NO_SE_INVENTA_UN_MARCADOR() + { + // Mandar `HEAD` de relleno sería indistinguible de una revisión real llamada HEAD, y el + // Core no podría saber cuál de las dos afirmaciones recibió. + var root = await PayloadEnviado(new CoreEvaluationOptions + { + BaseUrl = "http://core.local/api/v1", + MockFallback = false, + RepositoryUrl = "https://github.com/beyondnetcode/evolith_tracker", + RepositoryRevision = " ", + }); + + root.TryGetProperty("repositoryRevision", out _).Should().BeFalse(); + } + + [Fact] + public async Task CP04_LOS_CAMPOS_TECNICOS_OPCIONALES_NO_VIAJAN_VACIOS() + { + // Criterio 4. El Tracker no produce hechos estructurales ni señales de calidad: no hay + // indexador ni proveedor detrás de ningún puerto suyo. Mandarlos vacíos le diría al Core + // «miré y no hay nada» cuando la verdad es «no miré», y el Core trata la ausencia como + // «sin hallazgos estructurales», nunca como un fallo. + var root = await PayloadEnviado(); + + foreach (var campo in new[] { "repoFacts", "baselineRepoFacts", "qualitySignals" }) + { + root.TryGetProperty(campo, out _).Should().BeFalse($"«{campo}» no tiene productor todavía"); + } + } + + [Fact] + public async Task CP04_ElInterruptorDevuelveElPayloadALaFormaMinima() + { + // El acoplamiento es real: un Core anterior a GT-652 responde 400 a la evaluación ENTERA, + // no la acepta recortada. Este interruptor hace que el arreglo sea una variable de + // entorno y no un despliegue de vuelta atrás. + var root = await PayloadEnviado(new CoreEvaluationOptions + { + BaseUrl = "http://core.local/api/v1", + MockFallback = false, + RepositoryUrl = "https://github.com/beyondnetcode/evolith_tracker", + RepositoryRevision = "test", + SendTypedContextFacts = false, + }); + + root.TryGetProperty("requester", out _).Should().BeFalse(); + root.TryGetProperty("repositoryRevision", out _).Should().BeFalse(); + root.GetProperty("kinds").GetArrayLength().Should().BeGreaterThan(0, "el resto del payload no cambia"); + } + private static CoreEvaluationGateway CreateGateway( StubHttpMessageHandler handler, CoreEvaluationOptions? options = null) diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationLedgerFieldsTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationLedgerFieldsTests.cs new file mode 100644 index 00000000..d477c145 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationLedgerFieldsTests.cs @@ -0,0 +1,145 @@ +using Tracker.Domain.Integration.CoreEvaluationTransaction; +using Tracker.Presentation.Endpoints.Integration; + +namespace Tracker.Tests.Presentation.Integration; + +/// +/// CP-05 (ruta 1) — el asiento nacido en el GATEWAY deja de nacer a medias. +/// +/// Las columnas correlation_id, schema_version, overall_verdict, +/// outcome, rules_executed, evaluated_at y versions ya existían, pero +/// sólo las llenaba el camino de ingesta. Una columna que existe y está vacía para media +/// población es peor que no tenerla: quien consulta no distingue «no lo hubo» de «este camino no +/// lo guardó». +/// +public class CoreEvaluationLedgerFieldsTests +{ + private static CoreEvaluationTransaction Pendiente() => + CoreEvaluationTransaction.Create( + Guid.NewGuid(), "tracker-abc", "https://git/x", "HEAD", Guid.NewGuid()).Value!; + + private static CoreEvaluationIntelligenceView Intel() => new() + { + RulesExecuted = + [ + new CoreRuleExecutionView { RuleId = "R1", RulesetRef = "core://rs", Engine = "opa", Verdict = "FAIL" }, + ], + CorrelationId = "core-run-77", + SchemaVersion = "1.2.0", + CoreVersion = "1.4.0", + Ruleset = "sdlc", + RulesetVersion = "9.1", + }; + + [Fact] + public void ElAsientoDelGatewayConservaLosHechosCanonicos() + { + var t = Pendiente(); + var intel = Intel(); + + t.CompleteFromCore( + new Dictionary { ["decision"] = "FAILED" }, + producerSurface: "tracker-gateway", + correlationId: intel.CorrelationId, + schemaVersion: intel.SchemaVersion, + overallVerdict: "FAIL", + outcome: "rejected", + evaluatedAtUtc: new DateTime(2026, 8, 3, 10, 0, 0, DateTimeKind.Utc), + rulesExecuted: CoreEvaluationEndpoints.MapRulesExecuted(intel), + versions: CoreEvaluationEndpoints.MapVersions(intel)) + .IsSuccess.Should().BeTrue(); + + t.CorrelationId.Should().Be("core-run-77"); + t.SchemaVersion.Should().Be("1.2.0"); + t.OverallVerdict.Should().Be("FAIL"); + t.Outcome.Should().Be("rejected"); + t.RulesExecuted.Should().ContainSingle().Which.Engine.Should().Be("opa"); + t.Versions["rulesetVersion"].Should().Be("9.1"); + t.ProducerSurface.Should().Be("tracker-gateway", + "una fila del gateway y una depositada por la CLI tienen que ser distinguibles en la " + + "propia tabla, no por conjetura"); + } + + [Fact] + public void UNA_REGLA_SIN_MOTOR_SE_REGISTRA_NO_SE_DESCARTA() + { + // `Engine` es obligatorio en el agregado. Descartar la ejecución borraría la prueba de que + // la regla corrió; decir «no sé con qué motor» es cierto y comprobable. Lo inaceptable + // sería coaccionarla a un motor conocido, que es lo que el propio contrato advierte. + var intel = new CoreEvaluationIntelligenceView + { + RulesExecuted = [new CoreRuleExecutionView { RuleId = "R-sin-motor" }], + }; + + var mapeadas = CoreEvaluationEndpoints.MapRulesExecuted(intel); + + mapeadas.Should().ContainSingle(); + mapeadas[0].RuleId.Should().Be("R-sin-motor"); + mapeadas[0].Engine.Should().Be("unknown"); + mapeadas[0].Verdict.Should().Be("unknown"); + } + + [Fact] + public void UnaEjecucionSinRuleIdNoEntra() + { + // Sin identidad de regla la fila no dice nada y no se puede unir con nada. + var intel = new CoreEvaluationIntelligenceView + { + RulesExecuted = [new CoreRuleExecutionView { Engine = "opa", Verdict = "PASS" }], + }; + + CoreEvaluationEndpoints.MapRulesExecuted(intel).Should().BeEmpty(); + } + + [Fact] + public void UnaVersionAusenteNoSeInventaVacia() + { + var intel = new CoreEvaluationIntelligenceView { CoreVersion = "1.4.0" }; + + var versions = CoreEvaluationEndpoints.MapVersions(intel); + + versions.Should().ContainKey("core"); + versions.Should().NotContainKey("ruleset", "una clave con cadena vacía se leería como una versión llamada «»"); + versions.Should().NotContainKey("rulesetVersion"); + } + + [Fact] + public void SinInteligenciaNoSeRompeNiSeInventa() + { + // Una respuesta mínima del Core sigue produciendo un asiento válido. + var t = Pendiente(); + + t.CompleteFromCore( + new Dictionary { ["decision"] = "PASSED" }, + producerSurface: "tracker-gateway", + correlationId: null, + schemaVersion: null, + overallVerdict: null, + outcome: null, + evaluatedAtUtc: null, + rulesExecuted: CoreEvaluationEndpoints.MapRulesExecuted(null), + versions: CoreEvaluationEndpoints.MapVersions(null)) + .IsSuccess.Should().BeTrue(); + + t.Status.Should().Be("completed"); + t.RulesExecuted.Should().BeEmpty(); + t.Versions.Should().BeEmpty(); + // `correlation_id` es el id que emitió el CORE. Si no lo mandó, queda ausente: copiar aquí + // el `operation_id` del Tracker no añadiría información —ya vive en su propia columna— y + // haría indistinguible un id emitido por el Core de uno fabricado por nosotros. + t.CorrelationId.Should().BeNull(); + t.OperationId.Should().Be("tracker-abc", "el id propio del Tracker sigue donde siempre"); + } + + [Fact] + public void NoSePuedeCompletarDosVeces() + { + var t = Pendiente(); + var vacio = new Dictionary(); + + t.CompleteFromCore(vacio, "tracker-gateway", null, null, null, null, null, [], new Dictionary()) + .IsSuccess.Should().BeTrue(); + t.CompleteFromCore(vacio, "tracker-gateway", null, null, null, null, null, [], new Dictionary()) + .IsSuccess.Should().BeFalse("el guardia de `pending` del agregado sigue en pie"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationPhasePackageTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationPhasePackageTests.cs new file mode 100644 index 00000000..746c2b14 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationPhasePackageTests.cs @@ -0,0 +1,246 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Tracker.Application.Governance.ArtifactFieldSchema; +using Tracker.Domain.Sdlc.PhaseArtifact; +using Tracker.Presentation.Endpoints.Integration; +using Tracker.Presentation.Integration; +using DomainPhaseArtifact = Tracker.Domain.Sdlc.PhaseArtifact.PhaseArtifact; +using GovPhaseArtifactDto = Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactDto; + +namespace Tracker.Tests.Presentation.Integration; + +/// +/// CP-04 criterio 3 — el paquete de fase viaja al Core, y lo arma el SERVIDOR. +/// +/// Es la unión de CP-02 y CP-04: required sale del catálogo derivado del Core que +/// CP-02 cableó, presented del expediente que la gente llena. Lo que se prueba aquí es la +/// forma que el Core declara (`ArtifactContext`), y las tres decisiones que la hacen segura: +/// que el llamante no pueda declarar artefactos, que un hueco vacío no cuente como presentado, y +/// que «no pude armar el paquete» no se confunda con «este gate no exige nada». +/// +public sealed class CoreEvaluationPhasePackageTests +{ + private static readonly Guid Iniciativa = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + + private sealed class FakeProfiles : ITenantPhaseArtifactCatalog + { + private static PhaseArtifactProfileDto Perfil() => new() + { + Phase = "construction", + Source = PhaseArtifactProfileSourceKind.CoreSync, + Artifacts = + [ + new GovPhaseArtifactDto { ArtifactKind = "coverage-report", Label = "Coverage", Required = true }, + new GovPhaseArtifactDto { ArtifactKind = "cfr-metric", Label = "CFR", Required = false }, + ], + }; + + public Task> GetProfilesAsync(Guid t, CancellationToken ct) + => Task.FromResult>([Perfil()]); + + public Task GetPhaseAsync(Guid t, string phase, CancellationToken ct) + => Task.FromResult( + string.Equals(phase, "construction", StringComparison.OrdinalIgnoreCase) ? Perfil() : null); + } + + private sealed class FakeRepo(DomainPhaseArtifact? expediente) : IPhaseArtifactRepository + { + public Task GetByInitiativeAndPhaseAsync(Guid id, string phase, CancellationToken ct) + => Task.FromResult(id == Iniciativa ? expediente : null); + + public Task AddAsync(DomainPhaseArtifact a, CancellationToken ct) => Task.CompletedTask; + public Task UpdateAsync(DomainPhaseArtifact a, CancellationToken ct) => Task.CompletedTask; + } + + private static DomainPhaseArtifact Expediente(params (string Type, string? Ref, string? Content)[] items) + { + var a = DomainPhaseArtifact.CreateForPhase(Guid.NewGuid(), Iniciativa, "construction", Guid.NewGuid()).Value!; + foreach (var (type, @ref, content) in items) a.SetItem(type, null, @ref, content); + return a; + } + + private static async Task Payload( + DomainPhaseArtifact? expediente, + string? initiativeId = null, + ITenantPhaseArtifactCatalog? profiles = null) + { + var handler = new StubHandler(); + var gateway = new CoreEvaluationGateway( + new HttpClient(handler) { BaseAddress = new Uri("http://core.local/api/v1/") }, + Options.Create(new CoreEvaluationOptions + { + BaseUrl = "http://core.local/api/v1", + MockFallback = false, + RepositoryUrl = "https://github.com/beyondnetcode/evolith_tracker", + RepositoryRevision = "test", + }), + NullLogger.Instance, + capabilityProbe: null, + phaseArtifacts: new FakeRepo(expediente), + artifactProfiles: profiles ?? new FakeProfiles()); + + await gateway.EvaluateAsync( + new EvaluateContextRequest + { + TargetType = "initiative", + TargetId = "INI-1", + Phase = "f3", + InitiativeId = initiativeId ?? Iniciativa.ToString(), + Topology = "modular-monolith", + }, + Usuario(), CancellationToken.None); + + return JsonDocument.Parse(handler.RequestJson!).RootElement.Clone(); + } + + private static TrackerUserContext Usuario() + { + var ctx = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("tenant_id", Guid.NewGuid().ToString()), + new Claim("sub", Guid.NewGuid().ToString()), + new Claim("permissions", TrackerPermissions.CoreEvaluate), + ], "test")), + }; + return new TrackerUserContext( + new HttpContextAccessor { HttpContext = ctx }, + new TrackerPermissionResolver(Options.Create(new TrackerPermissionOptions()))); + } + + private sealed class StubHandler : HttpMessageHandler + { + public string? RequestJson { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage r, CancellationToken ct) + { + RequestJson = r.Content is null ? null : await r.Content.ReadAsStringAsync(ct); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """{"success":true,"data":{"overallVerdict":"PASS"}}""", Encoding.UTF8, "application/json"), + }; + } + } + + [Fact] + public async Task ManadaLoQueElEstandarExigeYLoQueSePresento() + { + var root = await Payload(Expediente(("coverage-report", "cov.xml", null))); + + var artifacts = root.GetProperty("artifacts"); + artifacts.GetProperty("required").EnumerateArray().Select(x => x.GetString()) + .Should().BeEquivalentTo(["coverage-report"], + "sólo lo `binding`: lo advisory cuenta para completitud y el Core leería lo exigido como contrato del gate"); + artifacts.GetProperty("presented").EnumerateArray().Select(x => x.GetProperty("artifactId").GetString()) + .Should().BeEquivalentTo(["coverage-report"]); + } + + [Fact] + public async Task UN_HUECO_VACIO_NO_CUENTA_COMO_PRESENTADO() + { + // Declarar como presentado un slot que alguien abrió en el formulario y dejó en blanco le + // diría al Core que el artefacto está. + var root = await Payload(Expediente(("coverage-report", null, null), ("prd", "prd.md", null))); + + root.GetProperty("artifacts").GetProperty("presented").EnumerateArray() + .Select(x => x.GetProperty("artifactId").GetString()) + .Should().BeEquivalentTo(["prd"]); + } + + [Fact] + public async Task ELCONTENIDOINLINE_NO_SALE_DEL_PERIMETRO() + { + // El Core evalúa hechos DECLARADOS. Volcar el cuerpo del artefacto aquí sacaría del + // perímetro del tenant un documento que nadie pidió mandar. + var root = await Payload(Expediente(("prd", null, "texto confidencial del PRD"))); + + var presentado = root.GetProperty("artifacts").GetProperty("presented").EnumerateArray().Single(); + presentado.GetProperty("artifactId").GetString().Should().Be("prd"); + presentado.TryGetProperty("content", out _).Should().BeFalse(); + root.GetRawText().Should().NotContain("confidencial"); + } + + [Fact] + public async Task SinExpedienteSeMandaSoloLoExigido() + { + var root = await Payload(expediente: null); + + var artifacts = root.GetProperty("artifacts"); + artifacts.GetProperty("required").GetArrayLength().Should().Be(1); + artifacts.TryGetProperty("presented", out _).Should().BeFalse( + "una lista vacía diría «no se presentó nada»; la verdad es que no hay expediente"); + } + + [Fact] + public async Task UnIdDeIniciativaOpacoNoEsUnError() + { + // El contrato declara `initiativeId` como la referencia OPACA que el Core recibe, así que + // un llamante externo puede usar su propio identificador. No hay expediente que buscar. + var root = await Payload(Expediente(("coverage-report", "cov.xml", null)), initiativeId: "INI-EXTERNA-7"); + + root.GetProperty("artifacts").TryGetProperty("presented", out _).Should().BeFalse(); + root.GetProperty("artifacts").GetProperty("required").GetArrayLength().Should().Be(1); + } + + [Fact] + public async Task SIN_NINGUNA_DE_LAS_DOS_MITADES_NO_SE_MANDA_EL_BLOQUE() + { + // Un bloque con dos listas vacías afirmaría «este gate no exige nada y no se presentó + // nada», que es una evaluación distinta de «no pude armar el paquete». + var root = await Payload(expediente: null, profiles: new FakeProfilesVacio()); + + root.TryGetProperty("artifacts", out _).Should().BeFalse(); + } + + private sealed class FakeProfilesVacio : ITenantPhaseArtifactCatalog + { + public Task> GetProfilesAsync(Guid t, CancellationToken ct) + => Task.FromResult>([]); + + public Task GetPhaseAsync(Guid t, string phase, CancellationToken ct) + => Task.FromResult(null); + } + + [Fact] + public void EL_LLAMANTE_NO_PUEDE_DECLARAR_ARTEFACTOS() + { + // La propiedad que hace segura esta función. Si `artifacts` viajara en el cuerpo, un + // llamante podría declarar como presentado un artefacto que no existe y el Core lo + // evaluaría como si estuviera. Se mira el TIPO: añadir el campo rompe esto aunque nadie + // escriba todavía una petición que lo use. + typeof(EvaluateContextRequest).GetProperties().Select(p => p.Name) + .Where(n => n.Contains("Artifact", StringComparison.OrdinalIgnoreCase) + || n.Contains("Evidence", StringComparison.OrdinalIgnoreCase)) + .Should().BeEmpty("el expediente es del Tracker; el llamante pide una evaluación, no describe el mundo"); + } + + [Fact] + public void LAS_DOS_FUENTES_SE_INYECTAN_DE_VERDAD() + { + // «Construido pero no cableado» es el defecto que esta campaña lleva corrigiendo, así que + // se comprueba que el contenedor las rellena y no que el constructor las acepta. Se + // resuelve DENTRO de un scope: el repositorio es Scoped y el cliente tipado transitorio. + var services = new ServiceCollection(); + services.AddLogging(); + services.Configure(_ => { }); + services.AddScoped(_ => new FakeRepo(null)); + services.AddSingleton(_ => new FakeProfiles()); + services.AddHttpClient(c => + c.BaseAddress = new Uri("http://core.local/api/v1/")); + + using var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var gateway = scope.ServiceProvider.GetRequiredService(); + + Campo(gateway, "phaseArtifacts").Should().NotBeNull("un despliegue que no las inyecta evalúa sin paquete de fase y nadie se entera"); + Campo(gateway, "artifactProfiles").Should().NotBeNull(); + } + + private static object? Campo(object instancia, string nombre) => + instancia.GetType() + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(f => f.Name.Contains(nombre, StringComparison.Ordinal)) + ?.GetValue(instancia); +} diff --git a/src/apps/tracker-web/src/api/types.ts b/src/apps/tracker-web/src/api/types.ts index 0cfba37e..40746b3d 100644 --- a/src/apps/tracker-web/src/api/types.ts +++ b/src/apps/tracker-web/src/api/types.ts @@ -255,11 +255,34 @@ export interface UpsertArtifactFieldSchemaRequest { customFields: CustomFieldDto[]; } +/** + * Where an artifact's canonical shape comes from — or why it has none (GAP-020). + * + * Three states rather than a nullable id, because two of them are different absences and call for + * opposite answers: `tool-output` means the Core deliberately publishes NO schema (the artifact is + * a CI run record or a coverage report, and a bespoke shape would restate what the tool already + * emits), while `none` means nothing upstream covers it and the tenant's own field schema is the + * only authority. Collapsing both into `null` would make the difference unrecoverable in the UI. + */ +export type ArtifactSchemaProvenance = 'core-schema' | 'tool-output' | 'none'; + +export interface ArtifactCanonicalSchemaDto { + /** The schema's published `$id` — not a repository path, which would break when Core moves it. */ + schemaId?: string | null; + provenance: ArtifactSchemaProvenance | string; +} + /** One Core-authoritative base artifact within a phase (read-only for the tenant). */ export interface PhaseArtifactDto { artifactKind: string; label: string; required: boolean; + /** + * The canonical shape Core publishes for this artifact (GAP-020). A pointer, never a constraint: + * per T-056 the tenant's custom fields stay the authority over content. Absent on legacy + * responses. + */ + canonicalSchema?: ArtifactCanonicalSchemaDto | null; } /** GET /api/phase-artifact-profiles — Core base artifacts per phase (PRD required in Discovery, …). */ @@ -652,6 +675,23 @@ export interface CoreEvaluationTransactionDto { /** Violaciones que BLOQUEAN: `error` y no congeladas. */ blockingViolationCount?: number; versions?: Record; + /** + * CP-07 — dónde cuelga este depósito del expediente SDLC, o ausente si no se ha adjuntado. + * Es un VÍNCULO: el veredicto de arriba sigue siendo el que el Core depositó, sin copiar ni + * reinterpretar. + */ + attachment?: CoreEvaluationAttachmentDto | null; +} + +/** CP-07 — a qué parte del expediente SDLC se adjuntó un depósito del Core. */ +export interface CoreEvaluationAttachmentDto { + initiativeId: string; + phase: string; + /** Ausente cuando el depósito habla de la fase entera y no de una pieza. */ + artifactKind?: string | null; + gateSubmissionId?: string | null; + attachedBy: string; + attachedAtUtc: string; } /** Una regla ejecutada y el motor que REALMENTE la ejecutó (vocabulario abierto). */ diff --git a/src/apps/tracker-web/src/components/screens/md3/FieldSchemaManager.tsx b/src/apps/tracker-web/src/components/screens/md3/FieldSchemaManager.tsx index be4a9f15..1f9fb668 100644 --- a/src/apps/tracker-web/src/components/screens/md3/FieldSchemaManager.tsx +++ b/src/apps/tracker-web/src/components/screens/md3/FieldSchemaManager.tsx @@ -12,7 +12,7 @@ import { usePhaseArtifactProfiles, useUpsertArtifactFieldSchema, } from '../../../api'; -import type { CustomFieldDto } from '../../../api'; +import type { ArtifactCanonicalSchemaDto, CustomFieldDto } from '../../../api'; import { Button, Card, @@ -37,10 +37,55 @@ const PHASE_LABEL: Record = { }; /** Per-artifact custom-field editor. The artifact itself is Core-defined; only fields are editable. */ -const ArtifactFieldCard: React.FC<{ artifactKind: string; label: string; required: boolean }> = ({ +/** + * El puntero a la forma canonica de un artefacto (GAP-020). Las tres procedencias se muestran + * distinto A PROPOSITO: un esquema publicado se puede abrir, una salida de herramienta NO tiene + * esquema por decision del Core, y `none` es un hueco de verdad. Un guion para los tres casos + * ocultaria justo la informacion que hace falta para saber si falta trabajo o no. + */ +const CanonicalSchemaHint: React.FC<{ schema?: ArtifactCanonicalSchemaDto | null }> = ({ schema }) => { + const { t } = useTranslation(); + if (!schema) return null; + + if (schema.provenance === 'core-schema' && schema.schemaId) { + return ( + + ); + } + + const texto = + schema.provenance === 'tool-output' + ? t('shape defined by the emitting tool', 'forma definida por la herramienta que lo emite') + : t('no upstream schema — your fields define it', 'sin esquema upstream — lo definen tus campos'); + + return ( +
+ {texto} +
+ ); +}; + +const ArtifactFieldCard: React.FC<{ + artifactKind: string; + label: string; + required: boolean; + canonicalSchema?: ArtifactCanonicalSchemaDto | null; +}> = ({ artifactKind, label, required, + canonicalSchema, }) => { const { t } = useTranslation(); const query = useArtifactFieldSchema(artifactKind); @@ -115,6 +160,11 @@ const ArtifactFieldCard: React.FC<{ artifactKind: string; label: string; require {artifactKind} {count > 0 ? ` · ${count} ${count === 1 ? t('custom field', 'campo personalizado') : t('custom fields', 'campos personalizados')}` : ''} + {/* GAP-020 — hasta aqui la tarjeta decia que un artefacto hace falta y no QUE debe + contener. El enlace apunta a la forma canonica publicada por el Core; es una + referencia y no una restriccion (T-056), por eso se ofrece y no se impone. Las dos + ausencias se distinguen: `tool-output` es deliberada, `none` es un hueco. */} +