diff --git a/DECISIONS.es.md b/DECISIONS.es.md index ed8b89f5..4b0df05a 100644 --- a/DECISIONS.es.md +++ b/DECISIONS.es.md @@ -71,3 +71,4 @@ _Nota: Las decisiones universales se heredan del Upstream Base ([evolith_arch32] | T-052 | Un solo MCP del ecosistema (el del Core); el gateway consume, no compite | Definir | Accepted | [T-052](./docs/adrs/T-052-gateway-consumes-core-single-mcp.es.md) | Elimina el servidor MCP del Tracker; el gateway es BFF agregador que consume `core-api` (REST) y `evolith-mcp` (cliente). Supera a `T-051`. Verificado en vivo. | | T-053 | Consumir la identidad UMS: JWKS/OIDC preferido, simétrico como interino | Definir | Proposed | [T-053](./docs/adrs/T-053-consume-ums-identity.es.md) | El tracker-api valida OIDC/JWKS pero el UMS no lo expone. Preferir que el UMS publique JWKS (aguas arriba); interino simétrico implementado y verificado con mock. | | T-054 | Gate de frontera en tiempo de edición: adoptar acotado, diferido a EAG-11 | Definir | Accepted | [T-054](./docs/adrs/T-054-edit-time-gate-adoption.es.md) | Adopta el edit-gate del Core pero DIFERIDO: `.claude/` está en `.gitignore`, `EAG-11` aún no da la fuente única de reglas, y el matcher puede misfirear. Activación condicionada a `EAG-11` + versionar `.claude/settings.json` + acotar rutas + vía de escape. | +| T-055 | El Core deposita sus veredictos; el Tracker posee el ledger y deriva el tenant de la clave | Definir | Accepted | [T-055](./docs/adrs/T-055-core-initiated-evidence-ingest.es.md) | Segunda dirección del tráfico de evidencia: `POST /core-evaluation-transactions` autenticado por clave de máquina atada al esquema POR NOMBRE, permiso propio `:ingest` SIN `:read`, tenant derivado de QUÉ CLAVE encajó (un `tenantId` en el cuerpo se rechaza con 400, no se ignora), idempotencia por `(tenant, correlationId)` respaldada por índice único, motor de cada regla VERBATIM (vocabulario abierto) y los dos responsables —quien pidió y quien debe arreglar— en columnas distintas. Estado `ingested`, distinto de `completed`. El DTO derivado a mano cumple `T-038` con guarda de deriva por fixture. Sigue siendo advisory (`T-039`). Cierra `GT-604`. | diff --git a/DECISIONS.md b/DECISIONS.md index 4e92ee9d..ea948131 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -79,4 +79,6 @@ _Nota: Las decisiones universales se heredan del Upstream Base ([evolith_arch32] | T-053 | Consumir la identidad UMS: JWKS/OIDC preferido, simétrico como interino | Definir | Proposed | [T-053](./docs/adrs/T-053-consume-ums-identity.md) | El tracker-api valida OIDC/JWKS pero el UMS no lo expone. Preferir que el UMS publique JWKS (aguas arriba); interino simétrico implementado y verificado con mock. | | T-054 | Gate de frontera en tiempo de edición: adoptar acotado, diferido a EAG-11 | Definir | Accepted | [T-054](./docs/adrs/T-054-edit-time-gate-adoption.md) | Adopta el edit-gate del Core pero DIFERIDO: `.claude/` está en `.gitignore`, `EAG-11` aún no da la fuente única de reglas, y el matcher puede misfirear. Activación condicionada a `EAG-11` + versionar `.claude/settings.json` + acotar rutas + vía de escape. | +| T-055 | El Core deposita sus veredictos; el Tracker posee el ledger y deriva el tenant de la clave | Definir | Accepted | [T-055](./docs/adrs/T-055-core-initiated-evidence-ingest.md) | Segunda dirección del tráfico de evidencia: `POST /core-evaluation-transactions` autenticado por clave de máquina atada al esquema POR NOMBRE, permiso propio `:ingest` SIN `:read`, tenant derivado de QUÉ CLAVE encajó (un `tenantId` en el cuerpo se rechaza con 400, no se ignora), idempotencia por `(tenant, correlationId)` respaldada por índice único, motor de cada regla VERBATIM (vocabulario abierto) y los dos responsables —quien pidió y quien debe arreglar— en columnas distintas. Estado `ingested`, distinto de `completed`. El DTO derivado a mano cumple `T-038` con guarda de deriva por fixture. Sigue siendo advisory (`T-039`). Cierra `GT-604`. | + > **Plantilla para nuevos ADRs:** Al crear un nuevo documento para "ADR Local", utilice el esquema de Frontmatter definido en los estándares de Evolith Core y ubíquelo en la carpeta de gobernanza correspondiente. diff --git a/contracts/evaluation-ingest.fixture.json b/contracts/evaluation-ingest.fixture.json new file mode 100644 index 00000000..4b68902c --- /dev/null +++ b/contracts/evaluation-ingest.fixture.json @@ -0,0 +1,90 @@ +{ + "_comment": [ + "GT-604 — cuerpo canónico de POST /api/v1/core-evaluation-transactions.", + "Conforme a EvaluationIngestPayload de @beyondnet/evolith-contracts/ingest", + "(src/packages/contracts/src/ingest/evaluation-ingest.ts en el repositorio del Core),", + "schemaVersion 1.0.0. NO lleva tenantId: el tenant lo fija la clave de máquina que", + "autentica, y un cuerpo que lo traiga se rechaza con 400.", + "Lo consume CoreEvaluationIngestContractTests, que le da la vuelta a través del DTO de", + "frontera y compara campo a campo: si el DTO deja caer rulesExecuted[].engine o renombra", + "accountableOwner a owner, esa prueba se pone roja aquí en vez de producir un ledger de", + "filas sin motor que nadie mira en un trimestre. Es la guarda que T-038 exige para un", + "binding derivado a mano." + ], + "schemaVersion": "1.0.0", + "correlationId": "cli-eval-2026-07-30T10:00:00.000Z", + "producer": { + "surface": "cli", + "version": "evolith-cli@1.2.0" + }, + "evaluatedAt": "2026-07-30T10:00:00.000Z", + "overallVerdict": "FAIL", + "outcome": "rejected", + "requestedBy": { + "actorType": "agent", + "actorId": "agent-winston", + "modelRef": "claude-opus-5", + "sessionId": "sesion-604" + }, + "repositoryRevision": { + "revision": "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c", + "repositoryRef": "https://github.com/beyondnetcode/evolith_core_demos", + "branch": "develop", + "committedAt": "2026-07-30T09:58:12.000Z", + "dirty": false + }, + "rulesExecuted": [ + { + "ruleId": "layer-boundary", + "rulesetRef": "hexagonal@1", + "engine": "native", + "verdict": "FAIL" + }, + { + "ruleId": "adr-compliance", + "engine": "opa", + "verdict": "PASS" + }, + { + "ruleId": "dependency-direction", + "engine": "enforcer", + "verdict": "WARN" + } + ], + "violations": [ + { + "ruleId": "layer-boundary", + "tool": "dependency-cruiser", + "file": "src/app/handler.ts", + "line": 42, + "column": 7, + "severity": "error", + "message": "la capa de aplicación importa infraestructura", + "adrRef": "ADR-0007", + "accountableOwner": "@beyondnetcode/platform", + "category": "architecture", + "complianceControls": ["SOC2-CC8.1"], + "fingerprint": "9f8e7d6c5b4a3928", + "frozen": false + }, + { + "ruleId": "secret-scan", + "tool": "gitleaks", + "file": "src/app/config.ts", + "severity": "error", + "message": "credencial embebida en el fuente", + "accountableOwner": "@beyondnetcode/security", + "category": "security", + "fingerprint": "1a2b3c4d5e6f7081", + "frozen": true + } + ], + "accountableOwners": ["@beyondnetcode/platform", "@beyondnetcode/security"], + "blockingViolationCount": 1, + "versions": { + "core": "1.2.0", + "ruleset": "hexagonal", + "rulesetVersion": "1.0.0", + "policy": "evolith-policies@0.9.1" + } +} diff --git a/docs/adrs/T-055-core-initiated-evidence-ingest.es.md b/docs/adrs/T-055-core-initiated-evidence-ingest.es.md new file mode 100644 index 00000000..ba6e826d --- /dev/null +++ b/docs/adrs/T-055-core-initiated-evidence-ingest.es.md @@ -0,0 +1,121 @@ +--- +adr: T-055 +title: El Core deposita sus veredictos; el Tracker posee el ledger y deriva el tenant de la clave +status: Accepted +date: 2026-07-31 +tags: [EvolithSatellite, integration, evidence, security, multi-tenancy] +authority: Evolith Core GT-604 (contrato de ingesta de evidencia), @beyondnet/evolith-contracts/ingest +relates: [T-038 binding por schema neutro, T-045 frontera de conectores, T-039 el Core recomienda y el tenant decide, CD-23 identidad de máquina] +gaps: [GT-604] +--- + +# ADR T-055 — Ingesta de evidencia iniciada por el Core: el Core deposita, el Tracker posee el ledger + +## Status + +Accepted (2026-07-31). Registra la decisión detrás del segundo escritor de +`tracker_governance.core_evaluation_transactions`, entregado para la ficha `GT-604` del Core. + +## Context + +Hasta ahora el camino de escritura de evidencia apuntaba sólo **hacia adentro**. El Tracker abría +transacciones de evaluación contra el Core (`CoreEvaluationGateway` → +`CreateCoreEvaluationTransactionCommand`) y los únicos escritores de +`core_evaluation_transactions` eran esas operaciones que el propio Tracker iniciaba. Las superficies +del Core —`evolith evaluate`, la puerta `--format drift`, la herramienta MCP `evolith-evaluate`, +cada veto de `enforce edit`— producían cada una un veredicto completo con el motor que ejecutó cada +regla, el conjunto de reglas ejecutadas, las violaciones normalizadas y el dueño de cada hallazgo +resuelto por CODEOWNERS, y luego terminaban. El veredicto se perdía. + +Eso es un **defecto de composición**: no lo encuentra ninguna revisión de un solo componente, porque +cada componente es correcto por separado. El Core produce buena evidencia; el Tracker la almacena +bien; las pruebas de ninguno de los dos notan que entre ellos no viaja nada. La premisa entera del +producto es la evidencia acumulada, y los componentes que la producen no tenían dónde depositarla. + +Tres restricciones dieron forma a la respuesta, y ninguna es estilística: + +1. **El tenant no puede venir del cuerpo de la petición.** El Core se autentica como máquina + (`CD-23`, `CoreMachineAuthenticationHandler`), y la clave de máquina es lo que ata a un llamador + con un tenant. Un `tenantId` en el cuerpo dejaría que cualquier clave válida depositara en el + ledger de cualquier tenant — justo el agujero que el handler de máquina existe para cerrar. +2. **El vocabulario de motores es abierto y debe seguir siéndolo.** `RuleExecutionRef.engine` en el + Core crece (`native`, `opa`, `enforcer`, y más a medida que se normalizan analizadores de + código). Una validación del lado del Tracker contra una lista cerrada rechazaría depósitos + válidos o, peor, coaccionaría un motor desconocido a uno conocido — escribiendo una fila que + afirma que una regla de gobernanza produjo un hallazgo que produjo un motor de políticas, una + sustitución que ningún consumidor posterior podría detectar. +3. **Hay DOS responsables y son dos personas distintas.** `requestedBy.actorId` es quien PIDIÓ la + evaluación; `violations[].accountableOwner` es quien debe ARREGLAR el hallazgo. Fundirlos deja + sin respuesta tanto «de qué agente son los veredictos que fallan» como «qué equipo posee los + fallos». + +## Decision + +**El Core deposita veredictos ya terminados por una única ruta autenticada; el Tracker posee el +ledger, deriva el tenant de la clave y guarda lo que le dieron sin reinterpretarlo.** + +En concreto: + +1. **Ruta.** `POST /api/v1/core-evaluation-transactions`, autenticada por el esquema `CoreMachine` + atado **por nombre** — de modo que el `dev-bypass` de Development, que autentica cualquier + petición (incluidas las anónimas) con todos los permisos, no la alcanza. Misma disciplina de + vinculación que `POST /runtime-approvals`. +2. **Permiso.** Uno nuevo, `tracker:core-transaction:ingest`, que la clave de máquina lleva **sin** + `tracker:core-transaction:read`. El Core deposita evidencia; no consulta el inventario de + hallazgos, dueños y rutas de fichero del tenant. Una clave filtrada que sólo puede escribir deja + rastro de todo lo que escribe; una que puede leer es un exfiltrador. +3. **Tenant.** Derivado de qué clave encajó. Un cuerpo que traiga `tenantId` se **rechaza con 400**, + nunca se ignora: ignorarlo dejaría al productor creyendo que eligió destino mientras la fila iba + a otro sitio. +4. **Idempotencia.** Por `(tenant_id, correlation_id)`, respaldada por un índice único. Un segundo + depósito actualiza la fila existente y devuelve `200` con `created: false`; nunca devuelve `409` + ni crea una segunda fila. Los reintentos de CI son normales y no pueden contar dos veces un + veredicto. El índice no es una optimización: sin él la regla es inexigible bajo concurrencia y + dos pipelines en carrera escriben dos filas para un mismo veredicto. +5. **Fidelidad.** `rulesExecuted[].engine` se guarda verbatim; los motores desconocidos se toleran y + jamás se coaccionan. Los dos responsables van en columnas distintas y un `accountableOwner` + ausente sigue ausente — un defecto sin atribuir registrado como sin atribuir es honesto; uno + inventado es una acusación. +6. **Estado.** Una fila depositada es `ingested`, no `completed`. `completed` describe una operación + que el Tracker inició y terminó; un depósito no lo inició él y llega ya terminado. Reutilizar el + valor borraría del ledger la distinción entre las dos direcciones del tráfico, que es justo la + distinción que este ADR añade. +7. **Binding.** El DTO de petición es un espejo derivado a mano de `EvaluationIngestPayload` + (`@beyondnet/evolith-contracts/ingest`) — lo que `T-038` permite, porque el paquete es sólo + TypeScript, **a condición** de que viva confinado a la frontera de adaptador y de que un chequeo + falle ante deriva. `contracts/evaluation-ingest.fixture.json` más + `CoreEvaluationIngestContractTests` son ese chequeo: se da la vuelta al fixture a través del DTO + y se compara campo a campo, así que dejar caer `engine` o renombrar `accountableOwner` a `owner` + se pone rojo en el momento del cambio. + +## Consequences + +**Lo que compra.** «Qué regla, ejecutada por qué motor, produjo qué hallazgo, de quién es, sobre qué +revisión y quién la pidió» pasa a ser una consulta en vez de una línea de consola perdida. Las tres +superficies del Core convergen en una sola forma de cable en lugar de tres mapeos parecidos. + +**Lo que cuesta.** `core_evaluation_transactions` sirve ahora a dos propósitos con una sola forma: +una operación que el Tracker inició y un veredicto que el Core depositó. Cada columna de ingesta es +anulable exactamente por eso, y las filas se distinguen por `status`. Si las dos poblaciones +divergen más, partir la tabla es el siguiente paso — no rellenar las filas antiguas, que volvería +«no consta» indistinguible de «consta así». + +**Lo que NO cambia.** `T-039` sigue en pie: un veredicto del Core depositado es **advisory**. +Aterrizar en este ledger no satisface un criterio de compuerta, no autoriza una transición de fase y +no constituye una excepción. Es evidencia, y el tenant sigue decidiendo. + +**Lo que deliberadamente no se construye.** Ninguna proyección a `EvidenceRecord`: `EAG-17` da a la +evidencia de conector una única puerta de entrada por `ConnectorEvidenceTranslators`, y hacer pasar +un veredicto del Core por un traductor construido para cargas de conector lo distorsionaría o +añadiría un `switch` sin nada en común. Si un veredicto del Core debe convertirse en un +`EvidenceRecord`, eso es un traductor y es su propia decisión. + +## Compliance + +- `CoreEvaluationIngestEndpointTests` — ruta, vinculación por clave de máquina, tenant desde la + clave, rechazo de `tenantId` en el cuerpo, idempotencia y aislamiento entre tenants. Sin puerta de + escape de entorno: exigen PostgreSQL y fallan si no lo hay. +- `CoreEvaluationIngestSchemaTests` — el índice único y los tipos de columna, contra un motor real. +- `CoreEvaluationIngestContractTests` — la guarda de deriva de `T-038`, verificada por mutación. +- `robosoft/robots/core-evidence-ingest.robot.mjs` — el bucle completo: una ejecución real de + `evolith evaluate` aterriza como fila no vacía y con dueño responsable. diff --git a/docs/adrs/T-055-core-initiated-evidence-ingest.md b/docs/adrs/T-055-core-initiated-evidence-ingest.md new file mode 100644 index 00000000..bc90141b --- /dev/null +++ b/docs/adrs/T-055-core-initiated-evidence-ingest.md @@ -0,0 +1,119 @@ +--- +adr: T-055 +title: The Core deposits its verdicts; the Tracker owns the ledger and derives the tenant from the key +status: Accepted +date: 2026-07-31 +tags: [EvolithSatellite, integration, evidence, security, multi-tenancy] +authority: Evolith Core GT-604 (evidence ingest contract), @beyondnet/evolith-contracts/ingest +relates: [T-038 neutral contract schema binding, T-045 connector boundary, T-039 core recommends tenant decides, CD-23 machine identity] +gaps: [GT-604] +--- + +# ADR T-055 — Core-initiated evidence ingest: the Core deposits, the Tracker owns the ledger + +## Status + +Accepted (2026-07-31). Registers the decision behind the second writer of +`tracker_governance.core_evaluation_transactions`, shipped for Core gap `GT-604`. + +## Context + +Until now the evidence write path pointed only **inward**. The Tracker opened evaluation +transactions against the Core (`CoreEvaluationGateway` → `CreateCoreEvaluationTransactionCommand`) +and the only writers of `core_evaluation_transactions` were those Tracker-initiated operations. The +Core's own surfaces — `evolith evaluate`, the `--format drift` gate, the MCP `evolith-evaluate` tool, +every `enforce edit` veto — each produced a complete verdict carrying the engine that ran each rule, +the executed rule set, the normalized violations and the CODEOWNERS-resolved owner of each finding, +and then exited. The verdict was gone. + +That is a **composition defect**: no single-component review finds it, because every component is +correct on its own. The Core produces good evidence; the Tracker stores evidence well; neither +side's tests notice that nothing travels between them. The product's whole premise is accumulated +evidence, and the components that produce it had nowhere to put it. + +Three constraints shaped the answer, and none of them is stylistic: + +1. **The tenant cannot come from the request body.** The Core authenticates as a machine + (`CD-23`, `CoreMachineAuthenticationHandler`), and the machine key is what binds a caller to a + tenant. A body-supplied `tenantId` would let any valid key deposit into any tenant's ledger — the + precise hole the machine-auth handler exists to close. +2. **The engine vocabulary is open and must stay open.** `RuleExecutionRef.engine` in the Core grows + (`native`, `opa`, `enforcer`, and more as source analyzers are normalized). A Tracker-side + validation against a closed list would either reject valid deposits or, worse, coerce an + unrecognised engine into a known one — writing a row claiming a governance rule produced a + finding that a policy engine produced, a substitution no later consumer could detect. +3. **There are TWO owners and they are two different people.** `requestedBy.actorId` is who ASKED + for the evaluation; `violations[].accountableOwner` is who must FIX the finding. Collapsing them + makes both "which agent's verdicts keep failing" and "which team owns the failures" + unanswerable. + +## Decision + +**The Core deposits finished verdicts through one authenticated route; the Tracker owns the ledger, +derives the tenant from the key, and stores what it was given without reinterpreting it.** + +Concretely: + +1. **Route.** `POST /api/v1/core-evaluation-transactions`, machine-authenticated with the + `CoreMachine` scheme bound **by name** — so the Development `dev-bypass`, which authenticates + every request (including anonymous ones) with every permission, cannot reach it. Same binding + discipline as `POST /runtime-approvals`. +2. **Permission.** A new `tracker:core-transaction:ingest`, carried by the machine key **without** + `tracker:core-transaction:read`. The Core deposits evidence; it does not consult the tenant's + inventory of findings, owners and file paths. A leaked key that can only write leaves a trace of + everything it writes; one that can read is an exfiltrator. +3. **Tenant.** Derived from which key matched. A body carrying `tenantId` is **rejected with 400**, + never ignored: ignoring it would leave the producer believing it chose a destination while the + row went elsewhere. +4. **Idempotency.** Keyed on `(tenant_id, correlation_id)`, backed by a unique index. A repeated + deposit updates the existing row and returns `200` with `created: false`; it never returns `409` + and never creates a second row. CI retries are normal and must not double-count a verdict. The + index is not an optimization — without it the rule is unenforceable under concurrency and two + racing pipelines write two rows for one verdict. +5. **Fidelity.** `rulesExecuted[].engine` is stored verbatim; unknown engines are tolerated and + never coerced. The two owners are stored in separate columns and an absent `accountableOwner` + stays absent — an unattributed defect recorded as unattributed is honest, an invented one is an + accusation. +6. **Status.** A deposited row is `ingested`, not `completed`. `completed` describes a + Tracker-initiated operation that finished; a deposit was not initiated here and arrives already + finished. Reusing the value would erase from the ledger the distinction between the two + directions of traffic, which is the distinction this ADR adds. +7. **Binding.** The request DTO is a hand-derived mirror of `EvaluationIngestPayload` + (`@beyondnet/evolith-contracts/ingest`) — which `T-038` permits, because the package is + TypeScript-only, **on the condition** that it stays confined to the adapter boundary and that a + check fails on drift. `contracts/evaluation-ingest.fixture.json` plus + `CoreEvaluationIngestContractTests` are that check: the fixture is round-tripped through the DTO + and compared field by field, so dropping `engine` or renaming `accountableOwner` back to `owner` + turns red at the moment of the change. + +## Consequences + +**What this buys.** "Which rule, run by which engine, produced which finding, owned by whom, on +which revision, asked for by whom" becomes a query instead of a lost console line. The three Core +surfaces converge on one wire shape rather than three similar-looking mappers. + +**What it costs.** `core_evaluation_transactions` now serves two purposes with one shape: an +operation the Tracker started, and a verdict the Core deposited. Every ingest column is nullable +for exactly that reason, and rows are told apart by `status`. If the two populations diverge +further, splitting the table is the follow-up — not back-filling the old rows, which would make "no +record" indistinguishable from "recorded as such". + +**What it does NOT change.** `T-039` still holds: a deposited Core verdict is **advisory**. Landing +in this ledger does not satisfy a gate criterion, does not authorize a phase transition and does not +constitute an exception. It is evidence, and the tenant still decides. + +**What is deliberately not built.** No projection into `EvidenceRecord`: `EAG-17` gives connector +evidence a single ingress through `ConnectorEvidenceTranslators`, and routing a Core verdict through +a translator built for connector payloads would either distort it or add a `switch` with nothing in +common. If a Core verdict should become an `EvidenceRecord`, that is a translator, and it is its own +decision. + +## Compliance + +- `CoreEvaluationIngestEndpointTests` — route, machine-key binding, tenant-from-key, body-`tenantId` + rejection, idempotency, cross-tenant isolation. No environment gate: they require PostgreSQL and + fail without it. +- `CoreEvaluationIngestSchemaTests` — the unique index and the column types, against a real engine. +- `CoreEvaluationIngestContractTests` — the `T-038` drift guard, verified by mutation. +- `robosoft/robots/core-evidence-ingest.robot.mjs` — the loop end to end: a real `evolith evaluate` + run lands as a non-empty, owner-attributed row. diff --git a/evolith.yaml b/evolith.yaml index ed949542..1c9a20b7 100644 --- a/evolith.yaml +++ b/evolith.yaml @@ -79,5 +79,6 @@ spec: - T-052 # supersedes T-051 - T-053 - T-054 + - T-055 # Core-initiated evidence ingest; tenant derived from the machine key (GT-604). localAdrTagEnforcement: enforced coverageTarget: 80 diff --git a/product/infra/helm/local-test.sh b/product/infra/helm/local-test.sh index da09acf5..f83d9ab6 100755 --- a/product/infra/helm/local-test.sh +++ b/product/infra/helm/local-test.sh @@ -455,6 +455,39 @@ robosoft() { node "$ROOT/robosoft/run.mjs" } +# GT-604 — the evidence-deposit loop, end to end: a REAL `evolith evaluate` run +# lands as a persisted, non-empty, owner-attributed row in this Tracker. +# +# It is NOT in the `robosoft` list above, and the reason is the same one that +# keeps `core-integration` and `runtime-approvals` out: it needs two things this +# pipeline does not deploy. +# +# 1. the Evolith CLI, in ROBOSOFT_EVOLITH_CLI (a binary, `npx evolith`, or a +# built `.../dist/main.js`). The Core is a separate product; +# 2. a CoreMachine key on the deployed tracker-api bound to the SAME tenant the +# robot reads as (`CoreMachine__Keys__0__TenantId` == ROBOSOFT_TENANT_ID), +# and the same secret in ROBOSOFT_CORE_MACHINE_KEY. +# +# Adding it to the default list without those two would paint the gate red for an +# environment reason rather than a defect, which is the fastest way to teach a +# team to ignore a gate. It FAILS LOUDLY here instead of skipping: this target is +# only ever invoked by someone who means to run it. +# +# ROBOSOFT_EVOLITH_CLI=/path/to/evolith bash product/infra/helm/local-test.sh robosoft-ingest +robosoft_ingest() { + if [ -z "${ROBOSOFT_EVOLITH_CLI:-}" ]; then + echo "==> ERROR: ROBOSOFT_EVOLITH_CLI no esta definido." + echo " Este robot ejecuta la CLI de Evolith de VERDAD; sin ella no prueba nada, y" + echo " saltarselo reportaria el mismo verde que haberla ejecutado." + return 1 + fi + echo "==> RoboSoft · core-evidence-ingest (GT-604) contra el despliegue local" + _pf tracker-api-evolith-tracker-api 5100 \ + env ROBOSOFT_API_BASE=http://localhost:5100 \ + ROBOSOFT_ONLY=core-evidence-ingest \ + node "$ROOT/robosoft/run.mjs" +} + case "${1:-up}" in build) build ;; up) kind_create; build; kind_load; install @@ -463,12 +496,13 @@ case "${1:-up}" in smoke) smoke ;; smoke-prod) smoke_prod ;; robosoft) robosoft ;; + robosoft-ingest) robosoft_ingest ;; verify-failclosed) verify_failclosed ;; diagnose) diagnose ;; down) down ;; kind-down) down kind delete cluster --name "$CLUSTER" ;; - *) echo "usage: $0 {up|build|install|smoke|smoke-prod|robosoft|diagnose|down|kind-down}" + *) echo "usage: $0 {up|build|install|smoke|smoke-prod|robosoft|robosoft-ingest|diagnose|down|kind-down}" echo " TRACKER_PROFILE=prod $0 up # perfil de produccion (GT-464)"; exit 1 ;; esac diff --git a/robosoft/robots/core-evidence-ingest.robot.mjs b/robosoft/robots/core-evidence-ingest.robot.mjs new file mode 100644 index 00000000..80995371 --- /dev/null +++ b/robosoft/robots/core-evidence-ingest.robot.mjs @@ -0,0 +1,289 @@ +// ----------------------------------------------------------------------------- +// RoboSoft agent · core-evidence-ingest (GT-604 — the evidence deposit path) +// +// The gap: every `evolith evaluate`, every `enforce edit` veto, every MCP +// `tools/call` and every drift-gate run produced a complete, owner-attributed, +// engine-attributed verdict and then evaporated on process exit. The Core had +// nowhere to put it, because `core_evaluation_transactions` had only +// Tracker-initiated writers. +// +// This robot proves the loop is closed, and proves it the only way that counts: +// by running the REAL `evolith evaluate` CLI against a workspace known to fail, +// with nothing but two environment variables telling it where the Tracker is, and +// then reading the row back through the human GET surface. +// +// 1. scaffold a fixture workspace that FAILS and whose findings have an owner +// (a satellite manifest + a CODEOWNERS that matches everything); +// 2. run `evolith evaluate --context --format drift` with +// EVOLITH_TRACKER_URL + EVOLITH_TRACKER_API_KEY set. Nothing in this robot +// posts the payload: if the CLI is not wired, no row appears; +// 3. read it back through `GET /core-evaluation-transactions` and assert the +// correlation matches, that `rulesExecuted` is NON-EMPTY, that at least one +// violation carries an `accountableOwner`, and that the requester and the +// accountable owner were not collapsed into one field. +// +// Step 3's non-emptiness assertions are the load-bearing ones. A robot that +// asserted only "a row exists" would pass against an endpoint that persists an +// empty envelope — and an empty ledger row is the failure mode this gap is about. +// +// It also checks the negative half: the deposit route must NOT be reachable +// without a machine key. In Development the tracker-api registers `dev-bypass`, +// which authenticates every request — including anonymous ones — with every +// permission. If the ingest route were not bound to the CoreMachine scheme by +// NAME, anyone could write into any tenant's evidence ledger locally. +// +// Requires: a running tracker-api whose CoreMachine key is bound to the tenant +// this robot reads as (ROBOSOFT_TENANT_ID), and the Evolith CLI reachable as +// ROBOSOFT_EVOLITH_CLI (default: `evolith` on PATH). Both are asserted, never +// skipped: a robot that quietly does nothing when a tool is missing reports the +// same green as one that proved the loop. +// ----------------------------------------------------------------------------- + +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { config } from '../lib/config.mjs'; + +const RUN = Date.now().toString(36); + +/** The satellite manifest the fixture declares. Minimal and deliberately incomplete. */ +const FIXTURE_MANIFEST = `apiVersion: evolith.dev/v1 +kind: Satellite +metadata: + name: robosoft-ingest-fixture + phase: F1 + architectureVersion: 0.1.0 +spec: + coreRef: + version: 1.0.0 + rulesetVersion: 1.0.0 + runtime: + language: TypeScript + framework: NestJS + runtimeVersion: "Node 22" + sdlc: + currentPhase: 2 + gates: {} +`; + +/** + * A CODEOWNERS that matches everything, so the drift gate resolves a REAL + * accountable owner. Without it every finding comes back "unassigned" and the + * owner assertion below would be vacuous — green while proving nothing. + */ +const FIXTURE_OWNER = '@beyondnetcode/robosoft'; + +/** + * Where the Evolith CLI is. Accepts an installed binary (`evolith`), a full + * command (`npx evolith`), or a built entry point (`.../dist/main.js`) — the last + * form is spawned through the current Node, because a built entry point is a + * module and not an executable, and spawning it directly fails with EACCES for a + * reason that has nothing to do with what this robot tests. + */ +function resolveCli(raw) { + const value = (raw || 'evolith').trim(); + const parts = value.split(/\s+/); + if (parts.length === 1 && /\.(c|m)?js$/.test(parts[0])) { + return { command: process.execPath, prefix: [parts[0]], label: `node ${parts[0]}` }; + } + return { command: parts[0], prefix: parts.slice(1), label: value }; +} + +export default { + name: 'core-evidence-ingest', + description: + 'Prove a real CLI evaluation lands as a persisted, non-empty, owner-attributed row in the Tracker (GT-604).', + + async run(ctx) { + const { api, step, check, info } = ctx; + + const cli = resolveCli(process.env.ROBOSOFT_EVOLITH_CLI); + const correlationId = `robosoft-gt604-${RUN}`; + const trackerBase = `${config.apiBase}${config.apiPrefix}`; + + const workdir = mkdtempSync(join(tmpdir(), 'robosoft-gt604-')); + + try { + // ── 1. A workspace that FAILS, with owners ──────────────────────────── + step('Scaffold a fixture workspace that fails, and whose findings have an owner'); + { + mkdirSync(join(workdir, '.github'), { recursive: true }); + writeFileSync(join(workdir, 'evolith.yaml'), FIXTURE_MANIFEST, 'utf-8'); + writeFileSync(join(workdir, '.github', 'CODEOWNERS'), `* ${FIXTURE_OWNER}\n`, 'utf-8'); + // The correlation id is pinned in the context rather than left to the CLI's + // synthesis, so step 3 can join on an id this robot KNOWS instead of on + // "the most recent row", which would pass against somebody else's deposit. + writeFileSync( + join(workdir, 'context.json'), + JSON.stringify({ kinds: ['gate', 'compliance'], workspaceRef: workdir, correlationId }, null, 2), + 'utf-8', + ); + check('fixture workspace prepared', true, { detail: workdir }); + } + + // ── 2. The negative half, BEFORE depositing ─────────────────────────── + step('The deposit route is NOT reachable without a machine key'); + { + const anonymous = await api.post('/core-evaluation-transactions', { + schemaVersion: '1.0.0', + correlationId: `${correlationId}-anonymous`, + producer: { surface: 'cli' }, + evaluatedAt: new Date().toISOString(), + overallVerdict: 'PASS', + outcome: 'approved', + rulesExecuted: [], + violations: [], + accountableOwners: [], + blockingViolationCount: 0, + versions: { core: '0.0.0' }, + }); + check('an unauthenticated deposit is refused (401)', anonymous.status === 401, { + detail: `status=${anonymous.status} — dev-bypass authenticates every request with every ` + + 'permission; if it reached this route, anyone could write into any tenant\'s ledger', + }); + } + + // ── 3. Run the REAL CLI, wired only by environment ──────────────────── + step('Run a real `evolith evaluate --format drift` with the Tracker configured'); + let cliRan = false; + { + const run = spawnSync( + cli.command, + [...cli.prefix, 'evaluate', '--context', join(workdir, 'context.json'), '--format', 'drift'], + { + env: { + ...process.env, + EVOLITH_TRACKER_URL: trackerBase, + EVOLITH_TRACKER_API_KEY: config.coreMachineKey, + }, + encoding: 'utf-8', + timeout: Math.max(config.timeoutMs, 120_000), + }, + ); + + if (run.error) { + check('the Evolith CLI is runnable', false, { + detail: `${cli.label}: ${run.error.message}. Set ROBOSOFT_EVOLITH_CLI to the CLI entry point.`, + }); + throw new Error(`cannot run the Evolith CLI (${cli.label})`); + } + cliRan = true; + + // The fixture is INCOMPLETE on purpose, so the gate must block. A passing + // run would deposit a row with no violations, and the owner assertion + // below would be vacuous. + check('the drift gate BLOCKED on the failing fixture', run.status !== 0, { + detail: `exit=${run.status}`, + }); + check('the gate reported blocking violations', /Blocked/i.test(String(run.stdout || '')), { + detail: String(run.stdout || '').split('\n').find((l) => /Blocked/i.test(l)) || 'no verdict line', + }); + // Nothing in this robot posted anything. If the CLI is not wired to the + // shared ingest client, the read-back below finds nothing. + info('the robot posted no payload — the row below, if any, was deposited by the CLI itself'); + } + + // ── 4. Read the row back, and demand it carry something ─────────────── + step('The verdict is a persisted, NON-EMPTY, owner-attributed row'); + { + const list = await api.get('/core-evaluation-transactions'); + if (!check('GET /core-evaluation-transactions', list.ok, { detail: `status=${list.status}` })) { + throw new Error('cannot read the ledger back'); + } + + const rows = Array.isArray(list.body) ? list.body : []; + const row = rows.find((r) => r?.correlationId === correlationId); + + check('the CLI evaluation produced a persisted row', Boolean(row), { + detail: row + ? `id=${row.id}` + : `no row with correlationId=${correlationId} among ${rows.length}. Either the CLI is not ` + + 'wired to the ingest client, or the CoreMachine key is bound to a different tenant ' + + `than ROBOSOFT_TENANT_ID (${config.tenantId}).`, + }); + if (!row) throw new Error('no ledger row for this run'); + + check('the row names the surface that deposited it', row.producerSurface === 'drift-gate', { + detail: `producerSurface=${row.producerSurface}`, + }); + + // ── the load-bearing assertions ────────────────────────────────────── + const rules = Array.isArray(row.rulesExecuted) ? row.rulesExecuted : []; + check('rulesExecuted is NON-EMPTY', rules.length > 0, { detail: `rules=${rules.length}` }); + check( + 'every executed rule carries the engine it ran on', + rules.length > 0 && rules.every((r) => typeof r.engine === 'string' && r.engine.length > 0), + { + detail: `engines=${[...new Set(rules.map((r) => r.engine))].join(',') || 'none'} — an ` + + 'engine-less row cannot answer "which engine produced this finding", which is the ' + + 'question the column exists for', + }, + ); + + const violations = Array.isArray(row.violations) ? row.violations : []; + check('violations is NON-EMPTY', violations.length > 0, { detail: `violations=${violations.length}` }); + + const owned = violations.filter((v) => typeof v.accountableOwner === 'string' && v.accountableOwner); + check('at least one violation carries an accountableOwner', owned.length > 0, { + detail: `owned=${owned.length}/${violations.length}`, + }); + check('the accountable owner is the one CODEOWNERS resolves', owned.some((v) => v.accountableOwner === FIXTURE_OWNER), { + detail: `owners=${[...new Set(owned.map((v) => v.accountableOwner))].join(',') || 'none'}`, + }); + check('the derived owner index matches the violations', Array.isArray(row.accountableOwners) && row.accountableOwners.includes(FIXTURE_OWNER), { + detail: `accountableOwners=${JSON.stringify(row.accountableOwners)}`, + }); + + // The two owners answer different questions about different people: + // requestedBy.actorId is WHO ASKED, accountableOwner is WHO MUST FIX IT. + // Collapsing them makes both unanswerable. + check( + 'the requester was never written into an accountable owner', + !row.requestedByActorId || !owned.some((v) => v.accountableOwner === row.requestedByActorId), + { detail: `requestedBy=${row.requestedByActorId ?? '(none declared)'}` }, + ); + + check('blocking violations were counted', typeof row.blockingViolationCount === 'number' && row.blockingViolationCount > 0, { + detail: `blocking=${row.blockingViolationCount}`, + }); + } + + // ── 5. Idempotency, end to end ──────────────────────────────────────── + step('Re-running the same evaluation does NOT double-count the verdict'); + if (cliRan) { + const before = await api.get('/core-evaluation-transactions'); + const countOf = (r) => (Array.isArray(r.body) ? r.body : []).filter((x) => x?.correlationId === correlationId).length; + + const again = spawnSync( + cli.command, + [...cli.prefix, 'evaluate', '--context', join(workdir, 'context.json'), '--format', 'drift'], + { + env: { + ...process.env, + EVOLITH_TRACKER_URL: trackerBase, + EVOLITH_TRACKER_API_KEY: config.coreMachineKey, + }, + encoding: 'utf-8', + timeout: Math.max(config.timeoutMs, 120_000), + }, + ); + check('the second run also completed', !again.error, { detail: `exit=${again.status}` }); + + const after = await api.get('/core-evaluation-transactions'); + check('a retried pipeline still leaves exactly ONE row', countOf(before) === 1 && countOf(after) === 1, { + detail: `before=${countOf(before)} after=${countOf(after)} — CI retries are normal and must ` + + 'not count a verdict twice', + }); + } + + step('evidence ingest verified'); + check('a real CLI evaluation lands in the Tracker as a non-empty, owner-attributed row (GT-604)', true, { + detail: `correlationId=${correlationId}`, + }); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + }, +}; 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 79c4766c..1f4357ce 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 @@ -1,3 +1,5 @@ +using Tracker.Domain.Integration.CoreEvaluationTransaction; + namespace Tracker.Application.Integration.CoreEvaluationTransaction.DTOs; public sealed class CoreEvaluationTransactionDto @@ -12,4 +14,70 @@ public sealed class CoreEvaluationTransactionDto public string? ErrorCode { get; init; } public string? ErrorMessage { get; init; } public Dictionary ResponseData { get; init; } = new(); + + // ── GT-604 — el veredicto depositado, LEGIBLE ─────────────────────────────────────────── + // Se expone entero y no como resumen: un ledger que guarda el motor de cada regla pero no deja + // leerlo no responde «qué motor produjo este hallazgo», que es la pregunta por la que la + // columna existe. Nulo en las filas anteriores a la ficha — son operaciones que el Tracker + // inició contra el Core, no veredictos que el Core depositó. + + public string? CorrelationId { get; init; } + public string? SchemaVersion { get; init; } + public string? ProducerSurface { get; init; } + public string? ProducerVersion { get; init; } + public DateTime? EvaluatedAtUtc { get; init; } + public string? OverallVerdict { get; init; } + public string? Outcome { get; init; } + + /// QUIÉN PIDIÓ la evaluación. + public string? RequestedByActorType { get; init; } + + /// QUIÉN PIDIÓ la evaluación — nunca quien debe arreglar una violación. + public string? RequestedByActorId { get; init; } + + public string? RequestedByModelRef { get; init; } + public string? RequestedBySessionId { get; init; } + + public IReadOnlyList RulesExecuted { get; init; } = []; + public IReadOnlyList Violations { get; init; } = []; + public IReadOnlyList AccountableOwners { get; init; } = []; + public int BlockingViolationCount { get; init; } + public IReadOnlyDictionary Versions { get; init; } = new Dictionary(); + + /// + /// 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 + /// se vería consultando por la superficie que no lo lleva. + /// + public static CoreEvaluationTransactionDto From( + Tracker.Domain.Integration.CoreEvaluationTransaction.CoreEvaluationTransaction transaction) + => new() + { + Id = transaction.Id, + OperationId = transaction.OperationId, + RepositoryUrl = transaction.RepositoryUrl, + RepositoryRevision = transaction.RepositoryRevision, + Status = transaction.Status, + RequestedAtUtc = transaction.RequestedAtUtc, + CompletedAtUtc = transaction.CompletedAtUtc, + ErrorCode = transaction.ErrorCode, + ErrorMessage = transaction.ErrorMessage, + ResponseData = transaction.ResponseData, + CorrelationId = transaction.CorrelationId, + SchemaVersion = transaction.SchemaVersion, + ProducerSurface = transaction.ProducerSurface, + ProducerVersion = transaction.ProducerVersion, + EvaluatedAtUtc = transaction.EvaluatedAtUtc, + OverallVerdict = transaction.OverallVerdict, + Outcome = transaction.Outcome, + RequestedByActorType = transaction.RequestedBy?.ActorType, + RequestedByActorId = transaction.RequestedBy?.ActorId, + RequestedByModelRef = transaction.RequestedBy?.ModelRef, + RequestedBySessionId = transaction.RequestedBy?.SessionId, + RulesExecuted = transaction.RulesExecuted, + Violations = transaction.Violations, + AccountableOwners = transaction.AccountableOwners, + BlockingViolationCount = transaction.BlockingViolationCount, + Versions = transaction.Versions + }; } diff --git a/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetCoreEvaluationTransaction/GetCoreEvaluationTransactionQueryHandler.cs b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetCoreEvaluationTransaction/GetCoreEvaluationTransactionQueryHandler.cs index aaec5766..0d4a3d8f 100644 --- a/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetCoreEvaluationTransaction/GetCoreEvaluationTransactionQueryHandler.cs +++ b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetCoreEvaluationTransaction/GetCoreEvaluationTransactionQueryHandler.cs @@ -22,18 +22,6 @@ public GetCoreEvaluationTransactionQueryHandler(Tracker.Domain.Integration.CoreE if (transaction is null || transaction.TenantId != request.TenantId) return null; - return new CoreEvaluationTransactionDto - { - Id = transaction.Id, - OperationId = transaction.OperationId, - RepositoryUrl = transaction.RepositoryUrl, - RepositoryRevision = transaction.RepositoryRevision, - Status = transaction.Status, - RequestedAtUtc = transaction.RequestedAtUtc, - CompletedAtUtc = transaction.CompletedAtUtc, - ErrorCode = transaction.ErrorCode, - ErrorMessage = transaction.ErrorMessage, - ResponseData = transaction.ResponseData - }; + return CoreEvaluationTransactionDto.From(transaction); } } diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationDeposit.cs b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationDeposit.cs new file mode 100644 index 00000000..cdb0d5c0 Binary files /dev/null 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 6deb0830..935595d5 100644 --- a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransaction.cs +++ b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransaction.cs @@ -42,6 +42,186 @@ public static Result Create( return Result.Success(new CoreEvaluationTransaction(props)); } + // ── GT-604 — depósito de un veredicto del Core ────────────────────────────────────────── + + /// + /// Estado de una fila DEPOSITADA. No es `completed`: `completed` describe una operación que el + /// Tracker inició contra el Core y que terminó. Un depósito no lo inició el Tracker y llega ya + /// terminado, y llamarlo igual borraría del ledger la distinción entre las dos direcciones del + /// tráfico — que es justamente lo que esta ficha añade. + /// + public const string IngestedStatus = "ingested"; + + public string? CorrelationId => Props.CorrelationId; + public string? SchemaVersion => Props.SchemaVersion; + public string? ProducerSurface => Props.ProducerSurface; + public string? ProducerVersion => Props.ProducerVersion; + public DateTime? EvaluatedAtUtc => Props.EvaluatedAtUtc; + public string? OverallVerdict => Props.OverallVerdict; + public string? Outcome => Props.Outcome; + public CoreEvaluationRequester? RequestedBy => Props.RequestedBy; + public IReadOnlyList RulesExecuted => Props.RulesExecuted; + public IReadOnlyList Violations => Props.Violations; + public IReadOnlyList AccountableOwners => Props.AccountableOwners; + public int BlockingViolationCount => Props.BlockingViolationCount; + public IReadOnlyDictionary Versions => Props.Versions; + + /// + /// Registra un veredicto que una superficie del Core deposita aquí (GT-604). + /// + /// El lo aporta el llamador desde la CLAVE que autenticó, y + /// nunca desde el cuerpo del depósito: no lo lleva. + /// + public static Result Ingest(Guid tenantId, CoreEvaluationDeposit deposit) + { + var invalido = Validate(deposit); + if (invalido is not null) + { + return Result.Failure(invalido); + } + + var props = new CoreEvaluationTransactionProps + { + Id = TrackerId.Create(), + TenantId = tenantId, + OperationId = CoreEvaluationDeposit.DeriveOperationId(tenantId, deposit.CorrelationId), + RepositoryUrl = deposit.RepositoryRevision?.RepositoryRef ?? string.Empty, + RepositoryRevision = deposit.RepositoryRevision?.Revision ?? string.Empty, + Status = IngestedStatus, + // `requested_at` es cuándo se produjo el veredicto; `completed_at`, cuándo llegó aquí. + // Ambas afirmaciones son ciertas y ninguna inventa un instante que nadie observó. + RequestedAtUtc = deposit.EvaluatedAtUtc, + CompletedAtUtc = DateTime.UtcNow, + ErrorCode = null, + ErrorMessage = null, + ResponseData = new(), + Audit = new AuditProps(ProducerActor(deposit), DateTime.UtcNow) + }; + + var transaction = new CoreEvaluationTransaction(props); + transaction.SetProps(Apply(props, deposit)); + return Result.Success(transaction); + } + + /// + /// Segunda entrega del MISMO correlationId: actualiza la fila que ya existe. + /// + /// Reintentar una pasada de CI es normal, y un reintento no puede contar dos veces un + /// veredicto. Se actualiza en vez de ignorarse porque una re-evaluación de la misma corrida + /// puede traer más reglas ejecutadas —un motor que antes no estaba disponible, por ejemplo— y + /// quedarse con la primera versión guardaría a propósito la menos informada. + /// + public Result ReceiveAgain(CoreEvaluationDeposit deposit) + { + var invalido = Validate(deposit); + if (invalido is not null) + { + return Result.Failure(invalido); + } + + if (!string.Equals(Props.CorrelationId, deposit.CorrelationId, StringComparison.Ordinal)) + { + return Result.Failure("CoreEvaluationTransaction.CorrelationMismatch"); + } + + SetProps(Apply(Props with + { + RepositoryUrl = deposit.RepositoryRevision?.RepositoryRef ?? string.Empty, + RepositoryRevision = deposit.RepositoryRevision?.Revision ?? string.Empty, + Status = IngestedStatus, + RequestedAtUtc = deposit.EvaluatedAtUtc, + CompletedAtUtc = DateTime.UtcNow, + Audit = Props.Audit.WithUpdatedBy(ProducerActor(deposit)) + }, deposit)); + + TrackingState.MarkAsDirty(); + return Result.Success(); + } + + private static CoreEvaluationTransactionProps Apply( + CoreEvaluationTransactionProps props, + CoreEvaluationDeposit deposit) + => props with + { + CorrelationId = deposit.CorrelationId, + SchemaVersion = deposit.SchemaVersion, + ProducerSurface = deposit.ProducerSurface, + ProducerVersion = deposit.ProducerVersion, + EvaluatedAtUtc = deposit.EvaluatedAtUtc, + OverallVerdict = deposit.OverallVerdict, + Outcome = deposit.Outcome, + RequestedBy = deposit.RequestedBy, + RulesExecuted = [.. deposit.RulesExecuted], + Violations = [.. deposit.Violations], + // RE-DERIVADOS, nunca copiados del cable: son índices de lo que la propia fila lleva, y + // aceptarlos tal cual dejaría al ledger enrutando por unos dueños que no son los de sus + // violaciones. `Validate` ya rechazó el depósito si el productor calculó otra cosa. + AccountableOwners = DeriveAccountableOwners(deposit.Violations), + BlockingViolationCount = DeriveBlockingCount(deposit.Violations), + Versions = new Dictionary(deposit.Versions) + }; + + /// Dueños responsables distintos y ordenados, derivados de las violaciones. + public static IReadOnlyList DeriveAccountableOwners( + IReadOnlyList violations) + => [.. violations + .Select(v => v.AccountableOwner) + .Where(o => !string.IsNullOrWhiteSpace(o)) + .Select(o => o!) + .Distinct(StringComparer.Ordinal) + .OrderBy(o => o, StringComparer.Ordinal)]; + + /// + /// Violaciones que BLOQUEAN: `error` y NO congeladas. Misma regla que `buildEnforcerEvidence` + /// (EVD-03) en el Core, para que la fila del ledger y el manifiesto de evidencia no puedan + /// discrepar sobre si una corrida bloqueó. + /// + public static int DeriveBlockingCount(IReadOnlyList violations) + => violations.Count(v => + string.Equals(v.Severity, "error", StringComparison.Ordinal) && !v.Frozen); + + private static string? Validate(CoreEvaluationDeposit deposit) + { + if (string.IsNullOrWhiteSpace(deposit.CorrelationId)) + return "CoreEvaluationTransaction.CorrelationIdRequired"; + if (string.IsNullOrWhiteSpace(deposit.SchemaVersion)) + return "CoreEvaluationTransaction.SchemaVersionRequired"; + if (string.IsNullOrWhiteSpace(deposit.ProducerSurface)) + return "CoreEvaluationTransaction.ProducerSurfaceRequired"; + if (string.IsNullOrWhiteSpace(deposit.OverallVerdict)) + return "CoreEvaluationTransaction.OverallVerdictRequired"; + if (string.IsNullOrWhiteSpace(deposit.Outcome)) + return "CoreEvaluationTransaction.OutcomeRequired"; + + if (deposit.RulesExecuted.Any(r => string.IsNullOrWhiteSpace(r.Engine))) + return "CoreEvaluationTransaction.RuleEngineRequired"; + if (deposit.Violations.Any(v => string.IsNullOrWhiteSpace(v.Fingerprint))) + return "CoreEvaluationTransaction.ViolationFingerprintRequired"; + + // Los dos campos derivados se comprueban contra lo que las violaciones dicen. Un productor + // que los calcule distinto está hablando de otra corrida, y guardar sus números junto a + // estas violaciones produciría una fila internamente contradictoria. + if (deposit.AccountableOwners.Count > 0 + && !deposit.AccountableOwners.SequenceEqual(DeriveAccountableOwners(deposit.Violations), StringComparer.Ordinal)) + { + return "CoreEvaluationTransaction.AccountableOwnersMismatch"; + } + + return deposit.BlockingViolationCount != DeriveBlockingCount(deposit.Violations) + ? "CoreEvaluationTransaction.BlockingViolationCountMismatch" + : null; + } + + /// + /// Quién consta como autor de la fila en la auditoría: la SUPERFICIE que depositó, más el + /// actor que pidió la evaluación cuando lo hay. No se colapsa en uno solo — «lo escribió la + /// CLI» y «lo pidió este agente» son dos hechos distintos y ambos se pierden al fundirlos. + /// + private static string ProducerActor(CoreEvaluationDeposit deposit) + => deposit.RequestedBy is { } r + ? $"core:{deposit.ProducerSurface}:{r.ActorId}" + : $"core:{deposit.ProducerSurface}"; + public Result Complete(Dictionary responseData) { 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 3ac7c76c..2c268b60 100644 --- a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransactionProps.cs +++ b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationTransactionProps.cs @@ -15,10 +15,44 @@ public sealed record CoreEvaluationTransactionProps : IProps public string? ErrorMessage { get; init; } public Dictionary ResponseData { get; init; } = new(); + // ── GT-604 — el depósito de un veredicto del Core ─────────────────────────────────────── + // Anulables porque la tabla ya existía y sus filas anteriores son operaciones que el TRACKER + // inició contra el Core, no veredictos que el Core depositó. Rellenarlas con un valor por + // defecto diría de ellas algo que no consta. + + /// Clave de unión del rastro y clave de idempotencia dentro del tenant (GT-604). + public string? CorrelationId { get; init; } + + public string? SchemaVersion { get; init; } + + /// Superficie que depositó el veredicto (`cli`, `mcp`, `drift-gate`, …). + public string? ProducerSurface { get; init; } + + public string? ProducerVersion { get; init; } + + /// Cuándo se produjo el veredicto, distinto de cuándo llegó. + public DateTime? EvaluatedAtUtc { get; init; } + + public string? OverallVerdict { get; init; } + public string? Outcome { get; init; } + + /// QUIÉN PIDIÓ. Nunca se confunde con el dueño responsable de una violación. + public CoreEvaluationRequester? RequestedBy { get; init; } + + public IReadOnlyList RulesExecuted { get; init; } = []; + public IReadOnlyList Violations { get; init; } = []; + public IReadOnlyList AccountableOwners { get; init; } = []; + public int BlockingViolationCount { get; init; } + public IReadOnlyDictionary Versions { get; init; } = new Dictionary(); + /// 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 { - ResponseData = new Dictionary(ResponseData) + ResponseData = new Dictionary(ResponseData), + RulesExecuted = [.. RulesExecuted], + Violations = [.. Violations], + AccountableOwners = [.. AccountableOwners], + Versions = new Dictionary(Versions) }; } diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs index 769d6209..3fc924c4 100644 --- a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs +++ b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs @@ -10,6 +10,12 @@ public interface ICoreEvaluationTransactionRepository Task GetByOperationIdAsync(string operationId, CancellationToken cancellationToken = default); /// + /// GT-604 — la búsqueda que hace exigible la idempotencia del depósito. Acotada al tenant a + /// propósito: la misma correlación bajo dos claves distintas son dos hechos distintos, y una + /// búsqueda global dejaría que el primero en llegar impidiera al otro tenant registrar el suyo. + /// + Task GetByCorrelationIdAsync(Guid tenantId, string correlationId, CancellationToken cancellationToken = default); + /// GT-615 — la linea de revisiones de un repositorio, en ORDEN DE REVISION (la mas antigua /// primero). Es el orden que necesita : el drift solo /// tiene sentido leyendo la serie hacia adelante. Es deliberadamente el inverso del listado diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731031506_AddCoreEvaluationIngest.Designer.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731031506_AddCoreEvaluationIngest.Designer.cs new file mode 100644 index 00000000..94e58aad --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731031506_AddCoreEvaluationIngest.Designer.cs @@ -0,0 +1,3807 @@ +// +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("20260731031506_AddCoreEvaluationIngest")] + partial class AddCoreEvaluationIngest + { + /// + 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("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("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("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.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("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("ApprovalsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approvals"); + + 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("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.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/20260731031506_AddCoreEvaluationIngest.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731031506_AddCoreEvaluationIngest.cs new file mode 100644 index 00000000..27dc6047 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731031506_AddCoreEvaluationIngest.cs @@ -0,0 +1,198 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + /// + public partial class AddCoreEvaluationIngest : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "accountable_owners", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "jsonb", + nullable: false, + defaultValue: "[]"); + + migrationBuilder.AddColumn( + name: "blocking_violation_count", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "correlation_id", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "evaluated_at", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "outcome", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "character varying(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "overall_verdict", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "character varying(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "producer_surface", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "character varying(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "producer_version", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "character varying(128)", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "requested_by", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "jsonb", + nullable: true); + + migrationBuilder.AddColumn( + name: "rules_executed", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "jsonb", + nullable: false, + defaultValue: "[]"); + + migrationBuilder.AddColumn( + name: "schema_version", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "character varying(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "versions", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "jsonb", + nullable: false, + defaultValue: "{}"); + + migrationBuilder.AddColumn( + name: "violations", + schema: "tracker_governance", + table: "core_evaluation_transactions", + type: "jsonb", + nullable: false, + defaultValue: "[]"); + + migrationBuilder.CreateIndex( + name: "IX_core_evaluation_transactions_tenant_id_correlation_id", + schema: "tracker_governance", + table: "core_evaluation_transactions", + columns: new[] { "tenant_id", "correlation_id" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_core_evaluation_transactions_tenant_id_correlation_id", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "accountable_owners", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "blocking_violation_count", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "correlation_id", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "evaluated_at", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "outcome", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "overall_verdict", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "producer_surface", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "producer_version", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "requested_by", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "rules_executed", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "schema_version", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "versions", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + + migrationBuilder.DropColumn( + name: "violations", + schema: "tracker_governance", + table: "core_evaluation_transactions"); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs index 893fb0e2..c121d56b 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs @@ -2481,10 +2481,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -2511,6 +2529,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("error_message"); + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + b.Property("IsDeleted") .HasColumnType("boolean") .HasColumnName("is_deleted"); @@ -2521,6 +2543,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) @@ -2537,11 +2579,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) @@ -2561,11 +2619,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs index 4849dfe4..dc72cc4e 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs @@ -191,6 +191,13 @@ public Task> GetByTenantAsync(Guid tena => Task.FromResult(_items.Values.FirstOrDefault(x => x.OperationId == operationId)); /// + /// GT-604 — acotada al tenant, igual que la de PostgreSQL. Una versión que ignorase el tenant + /// haría pasar en verde la prueba de frontera contra un repositorio que no la respeta. + /// + public Task GetByCorrelationIdAsync(Guid tenantId, string correlationId, CancellationToken cancellationToken = default) + => Task.FromResult(_items.Values.FirstOrDefault( + x => x.TenantId == tenantId && x.CorrelationId == correlationId)); + /// GT-615 — misma semantica que la implementacion Postgres: filtros opcionales combinables y /// orden de revision (mas antigua primero, desempatando por id para que la serie sea total). /// 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 d124109e..52c561d7 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 @@ -36,7 +36,34 @@ public void Configure(EntityTypeBuilder builder // token for now. builder.Ignore(x => x.RowVersion); + // ── GT-604 ────────────────────────────────────────────────────────────────────────── + builder.Property(x => x.CorrelationId).HasColumnName("correlation_id").HasMaxLength(256); + builder.Property(x => x.SchemaVersion).HasColumnName("schema_version").HasMaxLength(32); + builder.Property(x => x.ProducerSurface).HasColumnName("producer_surface").HasMaxLength(32); + builder.Property(x => x.ProducerVersion).HasColumnName("producer_version").HasMaxLength(128); + builder.Property(x => x.EvaluatedAtUtc).HasColumnName("evaluated_at"); + builder.Property(x => x.OverallVerdict).HasColumnName("overall_verdict").HasMaxLength(32); + builder.Property(x => x.Outcome).HasColumnName("outcome").HasMaxLength(32); + builder.Property(x => x.RequestedByJson).HasColumnName("requested_by").HasColumnType("jsonb"); + // Los valores por defecto son JSON VÁLIDO —`[]` y `{}`— y no la cadena vacía que EF + // propone por omisión: `ALTER TABLE ... ADD COLUMN ... jsonb NOT NULL DEFAULT ''` lo + // rechaza PostgreSQL en el momento de aplicar la migración, así que sin esto la ficha + // ni siquiera llega a la base. Además dejan a las filas ANTERIORES —operaciones que el + // Tracker inició, no depósitos— con una lista vacía, que es lo cierto de ellas. + builder.Property(x => x.RulesExecutedJson).HasColumnName("rules_executed").HasColumnType("jsonb").IsRequired().HasDefaultValue("[]"); + builder.Property(x => x.ViolationsJson).HasColumnName("violations").HasColumnType("jsonb").IsRequired().HasDefaultValue("[]"); + 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.HasIndex(x => new { x.TenantId, x.Status }); builder.HasIndex(x => x.OperationId).IsUnique(); + + // Sin este índice la idempotencia de GT-604 es INEXIGIBLE bajo concurrencia: dos trabajos + // de CI en carrera escribirían dos filas para un mismo veredicto y el ledger contaría dos + // veces lo que ocurrió una. Los índices que ya había —(tenant_id, status) y operation_id + // único— no sirven para esto. Postgres trata los NULL como distintos, así que las filas + // anteriores a la ficha (que no tienen correlación) no colisionan entre sí. + builder.HasIndex(x => new { x.TenantId, x.CorrelationId }).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 2ac2ddee..09100f74 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 @@ -18,6 +18,42 @@ public sealed class CoreEvaluationTransactionRecord : IAuditableRecord, ITenantS public string? ErrorMessage { get; set; } public string ResponseDataJson { get; set; } = "{}"; + // ── GT-604 — el veredicto depositado por una superficie del Core ──────────────────────── + // Anulables porque las filas anteriores a esta ficha son operaciones que el Tracker inició + // contra el Core, no veredictos que el Core depositó, y `audit_entries` ya enseñó lo que + // cuesta rellenar un discriminador con un valor plausible: pasa a ser indistinguible de un + // hecho observado. + + /// Clave de unión e idempotencia dentro del tenant. Único junto a `tenant_id`. + public string? CorrelationId { get; set; } + + public string? SchemaVersion { get; set; } + public string? ProducerSurface { get; set; } + public string? ProducerVersion { get; set; } + public DateTime? EvaluatedAtUtc { get; set; } + public string? OverallVerdict { get; set; } + public string? Outcome { get; set; } + + /// QUIÉN PIDIÓ la evaluación (jsonb). Nunca el dueño de una violación. + public string? RequestedByJson { get; set; } + + /// Reglas ejecutadas con SU motor real (jsonb). + public string RulesExecutedJson { get; set; } = "[]"; + + /// Violaciones normalizadas con QUIÉN DEBE ARREGLARLAS (jsonb). + public string ViolationsJson { get; set; } = "[]"; + + /// + /// Dueños responsables distintos (jsonb). Proyección derivada: existe para poder enrutar e + /// indexar por dueño sin abrir el jsonb de violaciones. + /// + public string AccountableOwnersJson { get; set; } = "[]"; + + public int BlockingViolationCount { get; set; } + + /// Versiones de lo que produjo el veredicto (jsonb) — auditoría/reproducibilidad. + 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/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs index d07aed85..adbdaf13 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs @@ -31,6 +31,16 @@ public PostgreSqlCoreEvaluationTransactionRepository(TrackerDbContext context) return record is null ? null : MapToDomain(record); } + public async Task GetByCorrelationIdAsync( + Guid tenantId, string correlationId, CancellationToken cancellationToken = default) + { + var record = await _context.CoreEvaluationTransactions + .FirstOrDefaultAsync( + x => x.TenantId == tenantId && x.CorrelationId == correlationId && !x.IsDeleted, + cancellationToken); + return record is null ? null : MapToDomain(record); + } + public async Task> GetByTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) { var records = await _context.CoreEvaluationTransactions @@ -108,6 +118,11 @@ public async Task UpdateAsync(CoreEvaluationTransaction aggregate, CancellationT record.ResponseDataJson = JsonSerializer.Serialize(aggregate.ResponseData, JsonOptions); record.UpdatedBy = aggregate.Props.Audit.UpdatedBy; record.UpdatedAtUtc = aggregate.Props.Audit.UpdatedAtUtc; + + // GT-604 — un segundo depósito de la misma correlación reescribe el veredicto. Omitir + // estas líneas dejaría la idempotencia en verde guardando SIEMPRE la primera versión, + // que es el fallo silencioso que esta ficha existe para no cometer. + ApplyIngestFields(record, aggregate); } public async Task DeleteAsync(CoreEvaluationTransaction aggregate, CancellationToken cancellationToken = default) @@ -136,7 +151,25 @@ private static CoreEvaluationTransaction MapToDomain(CoreEvaluationTransactionRe CompletedAtUtc = record.CompletedAtUtc, ErrorCode = record.ErrorCode, ErrorMessage = record.ErrorMessage, - ResponseData = DeserializeResponseData(record.ResponseDataJson) + ResponseData = DeserializeResponseData(record.ResponseDataJson), + + // GT-604 — el veredicto depositado se lee ENTERO. Que una regla pierda su motor entre + // la escritura y la lectura sería indetectable después: el ledger diría que la ejecutó + // otro motor, y nadie tendría con qué comparar. + CorrelationId = record.CorrelationId, + SchemaVersion = record.SchemaVersion, + ProducerSurface = record.ProducerSurface, + ProducerVersion = record.ProducerVersion, + EvaluatedAtUtc = record.EvaluatedAtUtc, + OverallVerdict = record.OverallVerdict, + Outcome = record.Outcome, + RequestedBy = Deserialize(record.RequestedByJson), + RulesExecuted = DeserializeList(record.RulesExecutedJson), + Violations = DeserializeList(record.ViolationsJson), + AccountableOwners = DeserializeList(record.AccountableOwnersJson), + BlockingViolationCount = record.BlockingViolationCount, + Versions = Deserialize>(record.VersionsJson) + ?? new Dictionary() }; var result = CoreEvaluationTransaction.Create( @@ -151,9 +184,65 @@ private static CoreEvaluationTransaction MapToDomain(CoreEvaluationTransactionRe return result.Value; } + /// GT-604 — proyecta el veredicto depositado sobre el registro plano. + private static void ApplyIngestFields( + CoreEvaluationTransactionRecord record, + CoreEvaluationTransaction aggregate) + { + record.CorrelationId = aggregate.CorrelationId; + record.SchemaVersion = aggregate.SchemaVersion; + record.ProducerSurface = aggregate.ProducerSurface; + record.ProducerVersion = aggregate.ProducerVersion; + record.EvaluatedAtUtc = aggregate.EvaluatedAtUtc; + record.OverallVerdict = aggregate.OverallVerdict; + record.Outcome = aggregate.Outcome; + record.RequestedByJson = aggregate.RequestedBy is null + ? null + : JsonSerializer.Serialize(aggregate.RequestedBy, JsonOptions); + record.RulesExecutedJson = JsonSerializer.Serialize(aggregate.RulesExecuted, JsonOptions); + record.ViolationsJson = JsonSerializer.Serialize(aggregate.Violations, JsonOptions); + record.AccountableOwnersJson = JsonSerializer.Serialize(aggregate.AccountableOwners, JsonOptions); + record.BlockingViolationCount = aggregate.BlockingViolationCount; + record.VersionsJson = JsonSerializer.Serialize(aggregate.Versions, JsonOptions); + } + + private static T? Deserialize(string? json) where T : class + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + private static IReadOnlyList DeserializeList(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + private static CoreEvaluationTransactionRecord MapToRecord(CoreEvaluationTransaction aggregate) { - return new CoreEvaluationTransactionRecord + var record = new CoreEvaluationTransactionRecord { Id = aggregate.Id, TenantId = aggregate.TenantId, @@ -171,6 +260,9 @@ private static CoreEvaluationTransactionRecord MapToRecord(CoreEvaluationTransac UpdatedBy = aggregate.Props.Audit.UpdatedBy, UpdatedAtUtc = aggregate.Props.Audit.UpdatedAtUtc }; + + ApplyIngestFields(record, aggregate); + return record; } private static Dictionary DeserializeResponseData(string json) diff --git a/src/apps/tracker-api/Tracker.Presentation/Auth/CoreMachineAuthenticationHandler.cs b/src/apps/tracker-api/Tracker.Presentation/Auth/CoreMachineAuthenticationHandler.cs index 813d4a29..628e4d4c 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Auth/CoreMachineAuthenticationHandler.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Auth/CoreMachineAuthenticationHandler.cs @@ -82,6 +82,11 @@ protected override Task HandleAuthenticateAsync() new("sub", $"core-machine:{match.TenantId}"), new("name", match.Description ?? "evolith-core"), new("permissions", TrackerPermissions.RuntimeApprovalSubmit), + // GT-604 — DEPOSITAR un veredicto ya producido. Se añade `:ingest` y NO `:read`: el + // Core deposita evidencia, no consulta el inventario de hallazgos, dueños y rutas de + // fichero del tenant. Una clave filtrada que pudiera leer el ledger sería un + // exfiltrador; una que sólo pueda escribir deja rastro de cada cosa que escribe. + new("permissions", TrackerPermissions.CoreTransactionIngest), }; // SEC-02 — el uso se registra SIEMPRE. Es lo que permite responder «¿ya puedo quitar la diff --git a/src/apps/tracker-api/Tracker.Presentation/Auth/TrackerPermissions.cs b/src/apps/tracker-api/Tracker.Presentation/Auth/TrackerPermissions.cs index d89ec5ad..11797b6d 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Auth/TrackerPermissions.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Auth/TrackerPermissions.cs @@ -50,6 +50,15 @@ public static class TrackerPermissions public const string AuditCreate = "tracker:audit:create"; public const string ArchitectureRead = "tracker:architecture:read"; public const string CoreTransactionRead = "tracker:core-transaction:read"; + + /// + /// GT-604 — lo que la MÁQUINA del Core puede hacer con el ledger de evaluaciones: DEPOSITAR un + /// veredicto ya producido. Nada más. Deliberadamente distinto de + /// : una clave filtrada puede ensuciar el ledger de su tenant + /// —lo que se detecta— pero no LEERLO, que es donde vive el inventario de hallazgos, dueños y + /// rutas de fichero de ese tenant. + /// + public const string CoreTransactionIngest = "tracker:core-transaction:ingest"; 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/Endpoints/Integration/CoreEvaluationIngestDtos.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationIngestDtos.cs new file mode 100644 index 00000000..67c1a4a8 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationIngestDtos.cs @@ -0,0 +1,180 @@ +using Tracker.Domain.Integration.CoreEvaluationTransaction; + +namespace Tracker.Presentation.Endpoints.Integration; + +/// +/// GT-604 — el cuerpo de POST /core-evaluation-transactions, espejo de +/// EvaluationIngestPayload (@beyondnet/evolith-contracts/ingest). +/// +/// Es un DTO derivado a mano, y `T-038` dice exactamente qué se le exige: vivir CONFINADO a +/// la frontera de adaptador y estar cubierto por un chequeo que falle ante deriva. Lo segundo lo +/// aporta CoreEvaluationIngestContractTests, que da la vuelta a un fixture del contrato a +/// través de este tipo y compara campo a campo con el JSON crudo: si alguien renombra +/// accountableOwner a owner o deja caer engine, se pone rojo aquí en vez de +/// producir un ledger de filas sin motor que nadie mira en un trimestre. +/// +/// `TenantId` es una propiedad DECLARADA a propósito aunque el contrato prohíba +/// enviarla. Sin declararla, el deserializador la descartaría en silencio y un productor que la +/// mandara creería haber elegido destino mientras la fila iba a otro. Se declara para poder +/// RECHAZARLA con un 400 explícito. +/// +public sealed class CoreEvaluationIngestRequest +{ + public string? SchemaVersion { get; init; } + public string? CorrelationId { get; init; } + public IngestProducerDto? Producer { get; init; } + public DateTimeOffset? EvaluatedAt { get; init; } + public string? OverallVerdict { get; init; } + public string? Outcome { get; init; } + + /// QUIÉN PIDIÓ. Distinto, y separado, de quien debe arreglar cada violación. + public IngestRequesterDto? RequestedBy { get; init; } + + public IngestRevisionDto? RepositoryRevision { get; init; } + public List RulesExecuted { get; init; } = []; + public List Violations { get; init; } = []; + public List AccountableOwners { get; init; } = []; + public int BlockingViolationCount { get; init; } + public Dictionary Versions { get; init; } = []; + + /// + /// PROHIBIDO en el cable. El tenant lo fija QUÉ CLAVE encajó. Declarado sólo para poder + /// rechazar el cuerpo que lo lleve; ver el resumen del tipo. + /// + public string? TenantId { get; init; } + + /// + /// Traduce el cuerpo al vocabulario del dominio. Las reglas de negocio —correlación + /// obligatoria, motor obligatorio por regla, coherencia de los campos derivados— NO se + /// duplican aquí: las aplica el agregado, que es quien las posee. + /// + public CoreEvaluationDeposit ToDeposit() => new() + { + SchemaVersion = SchemaVersion ?? string.Empty, + CorrelationId = CorrelationId ?? string.Empty, + ProducerSurface = Producer?.Surface ?? string.Empty, + ProducerVersion = Producer?.Version, + EvaluatedAtUtc = (EvaluatedAt ?? default).UtcDateTime, + OverallVerdict = OverallVerdict ?? string.Empty, + Outcome = Outcome ?? string.Empty, + RequestedBy = RequestedBy is null || string.IsNullOrWhiteSpace(RequestedBy.ActorId) + ? null + : new CoreEvaluationRequester + { + ActorType = RequestedBy.ActorType ?? string.Empty, + ActorId = RequestedBy.ActorId!, + ModelRef = RequestedBy.ModelRef, + SessionId = RequestedBy.SessionId, + }, + RepositoryRevision = RepositoryRevision is null || string.IsNullOrWhiteSpace(RepositoryRevision.Revision) + ? null + : new CoreEvaluationRevision + { + Revision = RepositoryRevision.Revision!, + RepositoryRef = RepositoryRevision.RepositoryRef, + Branch = RepositoryRevision.Branch, + CommittedAtUtc = RepositoryRevision.CommittedAt?.UtcDateTime, + Dirty = RepositoryRevision.Dirty, + }, + RulesExecuted = [.. RulesExecuted.Select(r => new CoreEvaluationRuleExecution + { + RuleId = r.RuleId ?? string.Empty, + RulesetRef = r.RulesetRef, + // VERBATIM. Un motor que el Tracker no conozca se guarda tal cual: coaccionarlo a uno + // conocido escribiría una fila afirmando algo que no ocurrió, y la sustitución sería + // indetectable para todo consumidor posterior. + Engine = r.Engine ?? string.Empty, + Verdict = r.Verdict ?? string.Empty, + })], + Violations = [.. Violations.Select(v => new CoreEvaluationViolation + { + RuleId = v.RuleId ?? string.Empty, + Tool = v.Tool ?? string.Empty, + File = v.File ?? string.Empty, + Line = v.Line, + Column = v.Column, + Severity = v.Severity ?? string.Empty, + Message = v.Message ?? string.Empty, + AdrRef = v.AdrRef, + AccountableOwner = v.AccountableOwner, + Category = v.Category, + ComplianceControls = v.ComplianceControls ?? [], + Fingerprint = v.Fingerprint ?? string.Empty, + Frozen = v.Frozen, + })], + AccountableOwners = [.. AccountableOwners], + BlockingViolationCount = BlockingViolationCount, + Versions = new Dictionary(Versions), + }; +} + +public sealed class IngestProducerDto +{ + public string? Surface { get; init; } + public string? Version { get; init; } +} + +public sealed class IngestRequesterDto +{ + public string? ActorType { get; init; } + public string? ActorId { get; init; } + public string? ModelRef { get; init; } + public string? SessionId { get; init; } +} + +public sealed class IngestRevisionDto +{ + public string? Revision { get; init; } + public string? RepositoryRef { get; init; } + public string? Branch { get; init; } + public DateTimeOffset? CommittedAt { get; init; } + public bool? Dirty { get; init; } +} + +public sealed class IngestRuleExecutionDto +{ + public string? RuleId { get; init; } + public string? RulesetRef { get; init; } + + /// El motor que REALMENTE ejecutó la regla. Vocabulario abierto. + public string? Engine { get; init; } + + public string? Verdict { get; init; } +} + +public sealed class IngestViolationDto +{ + public string? RuleId { get; init; } + public string? Tool { get; init; } + public string? File { get; init; } + public int? Line { get; init; } + public int? Column { get; init; } + public string? Severity { get; init; } + public string? Message { get; init; } + public string? AdrRef { get; init; } + + /// + /// QUIÉN DEBE ARREGLARLO. El nombre del cable es accountableOwner y no owner + /// precisamente para que nadie lo lea como el solicitante. + /// + public string? AccountableOwner { get; init; } + + public string? Category { get; init; } + public List? ComplianceControls { get; init; } + public string? Fingerprint { get; init; } + public bool Frozen { get; init; } +} + +/// +/// La respuesta. Sin envoltorio, igual que /runtime-approvals. +/// +/// Created distingue un primer depósito de una repetición idempotente, para que el +/// productor pueda registrar cuál de las dos le tocó — sin ese bit, un pipeline que reintenta no +/// puede saber si duplicó o no. +/// +public sealed class CoreEvaluationIngestResponse +{ + public string TransactionId { get; init; } = string.Empty; + public string CorrelationId { get; init; } = string.Empty; + public bool Created { get; init; } +} 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 a269a5d7..3f461dce 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Tracker.Application.Integration.CoreEvaluationTransaction.DTOs; using Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetCoreEvaluationTransaction; using Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetRepositoryRevisionTimeline; @@ -5,12 +6,115 @@ namespace Tracker.Presentation.Endpoints.Integration; +/// +/// El ledger de evaluaciones del Core, ahora en las DOS direcciones. +/// +/// Los dos GET llevaban aquí desde el principio; lo que faltaba —y es lo que `GT-604` +/// cierra— era la puerta de ENTRADA. Hasta ahora los únicos escritores de +/// core_evaluation_transactions eran operaciones que el TRACKER iniciaba contra el Core, de +/// modo que cada evolith evaluate, cada veto de enforce edit, cada tools/call +/// del MCP y cada pasada del drift-gate producían un veredicto completo —motor real, reglas +/// ejecutadas, dueño responsable— que se evaporaba al terminar el proceso. La estrategia se apoya +/// en evidencia acumulada mientras las superficies que la producen no tenían dónde depositarla. +/// +/// Dos superficies con identidades OPUESTAS, igual que en /runtime-approvals: +/// +/// POST /core-evaluation-transactions — la MÁQUINA del Core deposita un veredicto. +/// GET /core-evaluation-transactions — un HUMANO del tenant lee el ledger. +/// +/// Que la máquina no pueda leer no es una omisión: deposita evidencia, no consulta el inventario +/// de hallazgos, dueños y rutas de fichero del tenant. +/// public static class CoreEvaluationTransactionEndpoints { public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/core-evaluation-transactions").WithTags("Integration"); + // ── Canal de máquina — DEPOSITAR (GT-604) ─────────────────────────────────────────── + group.MapPost("/", async ( + CoreEvaluationIngestRequest request, + ITrackerUserContext user, + ICoreEvaluationTransactionRepository repository, + IUnitOfWork unitOfWork, + CancellationToken ct) => + { + // El tenant lo fija la CLAVE (el handler lo emite como claim), NUNCA el cuerpo. Un + // cuerpo que traiga `tenantId` se RECHAZA en vez de ignorarse: ignorarlo dejaría al + // productor creyendo que eligió destino mientras la fila iba a otro sitio. + if (request.TenantId is not null) + { + return Results.BadRequest(new + { + error = "CoreEvaluationIngest.TenantIdNotAccepted", + detail = "El tenant se deriva de la clave de máquina que autenticó. Aceptarlo " + + "del cuerpo dejaría que cualquier clave válida depositara evidencia " + + "en el ledger de cualquier tenant.", + }); + } + + var tenantId = user.TenantId; + var deposit = request.ToDeposit(); + + // Idempotencia acotada al tenant: un reintento de CI no puede contar dos veces un + // veredicto, y la misma correlación bajo otra clave es OTRO hecho, de otro tenant. + var existing = string.IsNullOrWhiteSpace(deposit.CorrelationId) + ? null + : await repository.GetByCorrelationIdAsync(tenantId, deposit.CorrelationId, ct); + + if (existing is not null) + { + var again = existing.ReceiveAgain(deposit); + if (again.IsFailure) + { + return Results.BadRequest(new { error = again.Error }); + } + + await repository.UpdateAsync(existing, ct); + await unitOfWork.SaveEntitiesAsync(ct); + + return Results.Ok(new CoreEvaluationIngestResponse + { + TransactionId = existing.Id.ToString(), + CorrelationId = existing.CorrelationId ?? deposit.CorrelationId, + // El productor merece saber que le tocó una repetición: sin este bit, un + // pipeline que reintenta no puede distinguir «se guardó» de «ya estaba». + Created = false, + }); + } + + var created = CoreEvaluationTransaction.Ingest(tenantId, deposit); + if (created.IsFailure) + { + return Results.BadRequest(new { error = created.Error }); + } + + await repository.AddAsync(created.Value!, ct); + await unitOfWork.SaveEntitiesAsync(ct); + + return Results.Ok(new CoreEvaluationIngestResponse + { + TransactionId = created.Value!.Id.ToString(), + CorrelationId = created.Value!.CorrelationId ?? deposit.CorrelationId, + Created = true, + }); + }) + .WithName("IngestCoreEvaluation") + // Vinculación por NOMBRE de esquema: es lo que impide que el `dev-bypass` —que autentica + // cualquier petición, incluso anónima, con TrackerPermissions.All— alcance esta ruta en + // local. Sin ella, cualquier petición sin credencial podría escribir en el ledger de + // evidencia del tenant que quisiera. + .RequireAuthorization(new AuthorizeAttribute + { + AuthenticationSchemes = CoreMachineAuthenticationHandler.SchemeName, + }) + .RequireTrackerPermission(TrackerPermissions.CoreTransactionIngest) + .Produces(200) + .Produces(400) + .Produces(401); + + // ── Canal humano — LEER ───────────────────────────────────────────────────────────── + // GT-615 — el listado del ledger acepta ahora los dos filtros que no tenia: // `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 @@ -25,7 +129,7 @@ public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuil if (string.IsNullOrWhiteSpace(repositoryUrl) && since is null) { var all = await repository.GetByTenantAsync(user.TenantId, ct); - return Results.Ok(all.Select(ToDto)); + return Results.Ok(all.Select(CoreEvaluationTransactionDto.From)); } // La consulta filtrada devuelve orden de revision (ascendente); este listado es el @@ -33,7 +137,7 @@ public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuil // de duplicar la consulta con el orden contrario. var items = await repository.GetRevisionTimelineAsync( user.TenantId, repositoryUrl, NormalizeToUtc(since), ct); - return Results.Ok(items.Reverse().Select(ToDto)); + return Results.Ok(items.Reverse().Select(CoreEvaluationTransactionDto.From)); }) .RequireTrackerPermission(TrackerPermissions.CoreTransactionRead) .WithName("ListCoreEvaluationTransactions") @@ -75,6 +179,7 @@ public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuil .WithName("GetCoreEvaluationTransaction"); } + /// /// `requested_at` se guarda en UTC. Un `since` que llegue con offset (o sin `Z`, que el binder /// resuelve como hora local del servidor) compararia contra otra escala y recortaria la serie @@ -88,20 +193,4 @@ public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuil var unspecified => DateTime.SpecifyKind(unspecified.Value, DateTimeKind.Utc) }; - private static CoreEvaluationTransactionDto ToDto(CoreEvaluationTransaction transaction) - { - return new CoreEvaluationTransactionDto - { - Id = transaction.Id, - OperationId = transaction.OperationId, - RepositoryUrl = transaction.RepositoryUrl, - RepositoryRevision = transaction.RepositoryRevision, - Status = transaction.Status, - RequestedAtUtc = transaction.RequestedAtUtc, - CompletedAtUtc = transaction.CompletedAtUtc, - ErrorCode = transaction.ErrorCode, - ErrorMessage = transaction.ErrorMessage, - ResponseData = transaction.ResponseData - }; - } } 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 669ab9c6..f26f34f1 100644 --- a/src/apps/tracker-api/Tracker.Tests/Application/Integration/CoreEvaluationTransactionContractTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Application/Integration/CoreEvaluationTransactionContractTests.cs @@ -44,6 +44,25 @@ public class CoreEvaluationTransactionContractTests "errorCode", "errorMessage", "responseData", + + // GT-604 — el veredicto depositado. Añadidos AQUÍ y en `types.ts` en el mismo cambio, + // que es justo lo que esta prueba existe para forzar. + "correlationId", + "schemaVersion", + "producerSurface", + "producerVersion", + "evaluatedAtUtc", + "overallVerdict", + "outcome", + "requestedByActorType", + "requestedByActorId", + "requestedByModelRef", + "requestedBySessionId", + "rulesExecuted", + "violations", + "accountableOwners", + "blockingViolationCount", + "versions", ]; /// diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/CoreEvaluationIngestSchemaTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/CoreEvaluationIngestSchemaTests.cs new file mode 100644 index 00000000..6313c465 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/CoreEvaluationIngestSchemaTests.cs @@ -0,0 +1,116 @@ +using Npgsql; + +namespace Tracker.Tests.Infrastructure.Persistence; + +/// +/// GT-604 — la idempotencia, comprobada donde de verdad se sostiene. +/// +/// La prueba de extremo a extremo demuestra que dos entregas seguidas del mismo +/// correlationId dejan UNA fila. Eso no prueba la regla bajo concurrencia: dos trabajos de +/// CI en carrera consultan a la vez, ninguno ve nada y ambos insertan. Lo único que lo impide es un +/// índice ÚNICO sobre (tenant_id, correlation_id), y su existencia no la contesta ningún +/// modelo en memoria — la contesta el motor. +/// +/// Sin puerta de escape de entorno. Esta suite ya tiene una clase que se auto-salta +/// salvo que EVOLITH_CORE_LIVE=1 esté puesto, y por tanto reporta verde en CI sin ejecutar +/// nada. Una afirmación sobre el esquema que sólo se comprueba cuando alguien se acuerda de poner +/// una variable de entorno no es evidencia. CI levanta Postgres y aplica las migraciones antes de +/// dotnet test; si no hay base, esto FALLA, que es lo correcto. +/// +public sealed class CoreEvaluationIngestSchemaTests +{ + private const string Esquema = "tracker_governance"; + private const string Tabla = "core_evaluation_transactions"; + + private static string ConnectionString => + Environment.GetEnvironmentVariable("EVOLITH_TRACKER_DB") + ?? "Host=localhost;Port=5432;Database=evolith_tracker;Username=evolith;Password=localdev"; + + private static async Task AbrirAsync() + { + var conexion = new NpgsqlConnection(ConnectionString); + await conexion.OpenAsync(); + return conexion; + } + + [Fact] + public async Task La_Idempotencia_Del_Deposito_Esta_Respaldada_Por_Un_Indice_Unico() + { + await using var conexion = await AbrirAsync(); + + await using var cmd = new NpgsqlCommand( + "SELECT indexdef FROM pg_indexes WHERE schemaname = @s AND tablename = @t", conexion); + cmd.Parameters.AddWithValue("s", Esquema); + cmd.Parameters.AddWithValue("t", Tabla); + + var definiciones = new List(); + await using (var lector = await cmd.ExecuteReaderAsync()) + { + while (await lector.ReadAsync()) + { + definiciones.Add(lector.GetString(0)); + } + } + + // Sin esto, un filtro que dejara de encontrar índices convertiría el verde en «no comprobé + // nada»: la tabla lleva índices desde su creación, así que una lista vacía es un fallo de + // la prueba, no del esquema. + definiciones.Should().NotBeEmpty( + $"la tabla {Esquema}.{Tabla} tiene índices desde su migración inicial; una lista vacía " + + "significa que esta prueba está mirando otra base o que las migraciones no se aplicaron"); + + definiciones.Should().ContainSingle(d => + d.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase) + && d.Contains("tenant_id", StringComparison.Ordinal) + && d.Contains("correlation_id", StringComparison.Ordinal), + "sin un índice único sobre (tenant_id, correlation_id) la regla de idempotencia es " + + "INEXIGIBLE bajo concurrencia y dos pasadas de CI en carrera contarían dos veces un " + + "mismo veredicto. Índices presentes: " + string.Join(" | ", definiciones)); + } + + /// + /// El motor de cada regla se guarda en jsonb y no en texto: es lo que permite consultar + /// «qué reglas ejecutó el motor X» sin volver a parsear la columna en cada consulta. Y + /// correlation_id es ANULABLE a propósito — las filas anteriores a esta ficha son + /// operaciones que el Tracker inició contra el Core, no depósitos, y no tienen correlación que + /// declarar. Rellenarlas con un valor plausible las volvería indistinguibles de un depósito. + /// + [Fact] + public async Task Las_Columnas_Del_Deposito_Existen_Con_El_Tipo_Que_Las_Hace_Consultables() + { + await using var conexion = await AbrirAsync(); + + await using var cmd = new NpgsqlCommand( + "SELECT column_name, data_type, is_nullable FROM information_schema.columns " + + "WHERE table_schema = @s AND table_name = @t", conexion); + cmd.Parameters.AddWithValue("s", Esquema); + cmd.Parameters.AddWithValue("t", Tabla); + + var columnas = new Dictionary(StringComparer.Ordinal); + await using (var lector = await cmd.ExecuteReaderAsync()) + { + while (await lector.ReadAsync()) + { + columnas[lector.GetString(0)] = (lector.GetString(1), lector.GetString(2)); + } + } + + columnas.Should().NotBeEmpty("la tabla debe existir para que esta prueba signifique algo"); + + foreach (var jsonb in new[] { "rules_executed", "violations", "accountable_owners", "versions" }) + { + columnas.Should().ContainKey(jsonb); + columnas[jsonb].Tipo.Should().Be("jsonb", $"{jsonb} debe ser consultable, no una cadena opaca"); + columnas[jsonb].Nullable.Should().Be("NO", + $"{jsonb} nunca es «no consta»: o hay elementos o hay una colección vacía"); + } + + columnas.Should().ContainKey("correlation_id"); + columnas["correlation_id"].Nullable.Should().Be("YES", + "las filas anteriores a GT-604 no son depósitos y no tienen correlación; declararla " + + "obligatoria exigiría inventarles una"); + + columnas.Should().ContainKey("blocking_violation_count"); + columnas["blocking_violation_count"].Tipo.Should().Be("integer"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationIngestContractTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationIngestContractTests.cs new file mode 100644 index 00000000..f78f31bb --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationIngestContractTests.cs @@ -0,0 +1,197 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Tracker.Presentation.Endpoints.Integration; + +namespace Tracker.Tests.Presentation.Integration; + +/// +/// GT-604 · `T-038` — la guarda del binding derivado a mano. +/// +/// `CoreEvaluationIngestRequest` es una traducción a mano de `EvaluationIngestPayload` +/// (@beyondnet/evolith-contracts/ingest). `T-038` permite exactamente eso —el paquete es +/// TypeScript y no se consume desde .NET— con dos condiciones: que viva confinado a la frontera de +/// adaptador y que un chequeo FALLE ante deriva. Esta prueba es ese chequeo. +/// +/// El método no es reimplementar el oráculo del Core en C#, que sería un segundo contrato +/// que también puede derivar. Es dar la VUELTA a un fixture del contrato a través del DTO y +/// comprobar que ningún campo del cable se perdió por el camino: si alguien deja caer +/// rulesExecuted[].engine o renombra accountableOwner a owner, esto se pone +/// rojo en el momento del cambio, en vez de producir un ledger de filas sin motor —o con el +/// solicitante escrito donde va el responsable— que nadie mira en un trimestre. +/// +/// Verificado por MUTACIÓN en ambos sentidos antes de darlo por bueno: quitar Engine +/// del DTO pone esta prueba en rojo; devolverlo, en verde. +/// +public sealed class CoreEvaluationIngestContractTests +{ + /// Mismas opciones que usa el host para el cuerpo de la petición. + private static readonly JsonSerializerOptions WireOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// + /// El fixture vive en contracts/, junto al pin de contratos del Core, y no en los + /// recursos de la suite: es una afirmación sobre la FRONTERA con el Core, y esconderla dentro + /// del proyecto de pruebas la habría dejado fuera de la vista de quien revisa esa frontera. + /// + private static string FixturePath() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "contracts", "evaluation-ingest.fixture.json"))) + { + dir = dir.Parent; + } + + dir.Should().NotBeNull("el fixture del contrato de ingesta debe existir en contracts/"); + return Path.Combine(dir!.FullName, "contracts", "evaluation-ingest.fixture.json"); + } + + private static JsonElement Fixture() + => JsonDocument.Parse(File.ReadAllText(FixturePath())).RootElement; + + /// + /// Da la vuelta al fixture a través del DTO y comprueba que TODO campo del cable sobrevive con + /// el mismo nombre y el mismo valor. + /// + [Fact] + public void El_DTO_De_Frontera_No_Pierde_Ni_Renombra_Ningun_Campo_Del_Contrato() + { + var original = Fixture(); + + var dto = JsonSerializer.Deserialize(original.GetRawText(), WireOptions); + dto.Should().NotBeNull(); + + var ida = JsonDocument.Parse(JsonSerializer.Serialize(dto, WireOptions)).RootElement; + + var perdidos = new List(); + Comparar(original, ida, "$", perdidos); + + perdidos.Should().BeEmpty( + "cada entrada de esta lista es un campo que el productor envía y este servicio tira a " + + "la basura en silencio. Campos perdidos: " + string.Join(", ", perdidos)); + } + + /// + /// El caso concreto que la ficha nombra: el motor de CADA regla, y los dos responsables por + /// separado. Se comprueba sobre el objeto de dominio y no sólo sobre el JSON, porque lo que + /// acaba en la fila es el objeto. + /// + [Fact] + public void El_Deposito_Conserva_El_Motor_Real_Y_Los_Dos_Responsables() + { + var dto = JsonSerializer.Deserialize(Fixture().GetRawText(), WireOptions)!; + + var deposit = dto.ToDeposit(); + + deposit.RulesExecuted.Select(r => r.Engine).Should() + .BeEquivalentTo(["native", "opa", "enforcer"], + "el motor que ejecutó cada regla es el campo por el que existe este contrato"); + + deposit.RequestedBy!.ActorId.Should().Be("agent-winston", "QUIÉN PIDIÓ"); + deposit.Violations.Select(v => v.AccountableOwner).Should() + .BeEquivalentTo(["@beyondnetcode/platform", "@beyondnetcode/security"], "QUIÉN DEBE ARREGLARLO"); + deposit.Violations.Should().NotContain(v => v.AccountableOwner == deposit.RequestedBy!.ActorId, + "los dos responsables son dos personas distintas y el contrato los mantiene separados"); + + deposit.Violations.Single(v => v.RuleId == "layer-boundary").ComplianceControls + .Should().BeEquivalentTo(["SOC2-CC8.1"]); + deposit.Violations.Single(v => v.RuleId == "secret-scan").Frozen.Should().BeTrue(); + } + + /// + /// El fixture es evidencia sólo si lo que afirma es cierto: se comprueba que él mismo cumple + /// las dos derivaciones del contrato. Un fixture incoherente haría pasar en verde un endpoint + /// que acepta cuerpos incoherentes. + /// + [Fact] + public void El_Fixture_Es_Coherente_Consigo_Mismo() + { + var dto = JsonSerializer.Deserialize(Fixture().GetRawText(), WireOptions)!; + var deposit = dto.ToDeposit(); + + deposit.AccountableOwners.Should().Equal( + Tracker.Domain.Integration.CoreEvaluationTransaction.CoreEvaluationTransaction + .DeriveAccountableOwners(deposit.Violations), + "`accountableOwners` es una proyección de las violaciones, no una lista independiente"); + + deposit.BlockingViolationCount.Should().Be( + Tracker.Domain.Integration.CoreEvaluationTransaction.CoreEvaluationTransaction + .DeriveBlockingCount(deposit.Violations), + "bloquean los `error` NO congelados; el fixture lleva uno de cada"); + } + + /// + /// Recorre el JSON de origen y exige que cada hoja aparezca, con el mismo valor, en el de + /// vuelta. No se compara al revés a propósito: que la vuelta lleve algo de más (un campo + /// opcional materializado) no rompe a nadie; que lleve algo de MENOS es pérdida de datos. + /// + private static void Comparar(JsonElement esperado, JsonElement obtenido, string ruta, List perdidos) + { + switch (esperado.ValueKind) + { + case JsonValueKind.Object: + foreach (var prop in esperado.EnumerateObject()) + { + // Los comentarios del fixture no son parte del contrato. + if (prop.Name.StartsWith('_')) continue; + + if (!obtenido.TryGetProperty(prop.Name, out var hijo)) + { + perdidos.Add($"{ruta}.{prop.Name}"); + continue; + } + + Comparar(prop.Value, hijo, $"{ruta}.{prop.Name}", perdidos); + } + break; + + case JsonValueKind.Array: + var esperados = esperado.EnumerateArray().ToList(); + var obtenidos = obtenido.ValueKind == JsonValueKind.Array + ? obtenido.EnumerateArray().ToList() + : []; + + if (obtenidos.Count != esperados.Count) + { + perdidos.Add($"{ruta}[] tiene {obtenidos.Count} elementos, se enviaron {esperados.Count}"); + break; + } + + for (var i = 0; i < esperados.Count; i++) + { + Comparar(esperados[i], obtenidos[i], $"{ruta}[{i}]", perdidos); + } + break; + + default: + // Las fechas se normalizan a UTC al deserializarlas, así que se comparan como + // instantes y no como texto: exigir el mismo literal declararía deriva donde sólo + // hay una representación distinta del mismo momento. + if (esperado.ValueKind == JsonValueKind.String + && DateTimeOffset.TryParse(esperado.GetString(), out var instanteEsperado) + && DateTimeOffset.TryParse(obtenido.GetString(), out var instanteObtenido)) + { + if (instanteEsperado != instanteObtenido) + { + perdidos.Add($"{ruta} = {obtenido.GetRawText()}, se envió {esperado.GetRawText()}"); + } + break; + } + + // Las cadenas se comparan DECODIFICADAS: el serializador escapa los no-ASCII + // (`ó` → `ó`), que es una diferencia de codificación y no de contenido. + // Compararlas en crudo declararía pérdida de datos donde no la hay, y una prueba + // que grita en falso se acaba desactivando. + var iguales = esperado.ValueKind == JsonValueKind.String + ? esperado.GetString() == obtenido.GetString() + : esperado.GetRawText() == obtenido.GetRawText(); + + if (!iguales) + { + perdidos.Add($"{ruta} = {obtenido.GetRawText()}, se envió {esperado.GetRawText()}"); + } + break; + } + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationIngestEndpointTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationIngestEndpointTests.cs new file mode 100644 index 00000000..e1b57891 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationIngestEndpointTests.cs @@ -0,0 +1,477 @@ +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Hosting; +using Tracker.Infrastructure.Services.Tenancy; + +namespace Tracker.Tests.Presentation.Integration; + +/// +/// GT-604 — la puerta de entrada que faltaba. Hasta ahora el camino de escritura de +/// evidencia apuntaba SÓLO hacia adentro: el Tracker abría transacciones contra el Core y el Core +/// no tenía dónde depositar sus veredictos. Cada evolith evaluate, cada veto de +/// enforce edit, cada tools/call del MCP y cada pasada del drift-gate producían un +/// veredicto completo —motor real, reglas ejecutadas, dueño responsable— que se evaporaba al +/// terminar el proceso. +/// +/// Estas pruebas corren a nivel HTTP contra el host real y no contra el handler, +/// porque lo que hay que demostrar depende del CABLEADO: que la ruta existe, que se ata al esquema +/// de máquina por NOMBRE —de modo que el dev-bypass, que autentica cualquier petición con +/// todos los permisos, no la alcanza ni en local—, y que el tenant sale de QUÉ CLAVE encajó y +/// nunca del cuerpo. Ninguna de las tres se puede afirmar probando el handler aislado. +/// +/// Sin puerta de escape de entorno, a propósito. La suite de este repositorio ya +/// tiene una clase que se auto-salta salvo que EVOLITH_CORE_LIVE=1 esté puesto y por tanto +/// reporta verde en CI sin ejecutar nada +/// (CoreEvaluationTransactionPersistenceLiveTests). Estas pruebas exigen PostgreSQL y +/// FALLAN si no lo hay: la evidencia de que una fila queda persistida no puede depender de una +/// variable de entorno que nadie pone. CI levanta el servicio `postgres` y aplica las migraciones +/// antes de `dotnet test`; en local, `EVOLITH_TRACKER_DB` apunta a donde haga falta. +/// +/// Cada prueba cuenta las filas de SU tenant antes y después: una consulta que dejara de +/// encajar con nada pasaría en verde sin haber comprobado nada. +/// +[Collection(WebHostCollection.Name)] +public sealed class CoreEvaluationIngestEndpointTests + : IClassFixture +{ + private const string Route = "/api/v1/core-evaluation-transactions"; + + private readonly CoreEvaluationIngestWebApplicationFactory _factory; + + public CoreEvaluationIngestEndpointTests(CoreEvaluationIngestWebApplicationFactory factory) + => _factory = factory; + + // ── Utilidades ────────────────────────────────────────────────────────────────────────── + + /// Cliente del canal de MÁQUINA: sólo `x-api-key`, ninguna identidad de usuario. + private HttpClient Machine(string key) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-api-key", key); + return client; + } + + /// + /// Cliente del canal HUMANO para leer de vuelta, atado a un tenant explícito. El + /// `dev-bypass` toma el tenant de `x-tenant-id`, así que esto lee exactamente el ledger del + /// tenant que se le indica — y NO el de la clave. + /// + private HttpClient Human(Guid tenantId) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-id", tenantId.ToString()); + return client; + } + + private static async Task> Ledger(HttpClient human) + { + var response = await human.GetAsync(Route); + response.StatusCode.Should().Be(HttpStatusCode.OK, "el ledger debe ser legible por un humano"); + return await response.Content.ReadFromJsonAsync>() ?? []; + } + + private static string NewCorrelationId() => $"gt604-{Guid.NewGuid():N}"; + + /// + /// Un cuerpo conforme a EvaluationIngestPayload (@beyondnet/evolith-contracts/ingest). + /// Nada de `tenantId`: el tenant lo fija la clave. + /// + private static Dictionary Payload( + string correlationId, + string requesterActorId = "agent-winston", + string ownerA = "@beyondnetcode/platform", + string ownerB = "@beyondnetcode/security", + string exoticEngine = "motor-que-el-tracker-no-conoce") + => new() + { + ["schemaVersion"] = "1.0.0", + ["correlationId"] = correlationId, + ["producer"] = new Dictionary + { + ["surface"] = "cli", + ["version"] = "evolith-cli@1.2.0", + }, + ["evaluatedAt"] = "2026-07-30T10:00:00.000Z", + ["overallVerdict"] = "FAIL", + ["outcome"] = "rejected", + ["requestedBy"] = new Dictionary + { + ["actorType"] = "agent", + ["actorId"] = requesterActorId, + ["modelRef"] = "claude-opus-5", + ["sessionId"] = "sesion-gt604", + }, + ["repositoryRevision"] = new Dictionary + { + ["revision"] = "0f1e2d3c4b5a6978", + ["repositoryRef"] = "https://github.com/beyondnetcode/evolith_core_demos", + ["branch"] = "develop", + ["dirty"] = false, + }, + ["rulesExecuted"] = new object[] + { + new Dictionary + { + ["ruleId"] = "layer-boundary", + ["rulesetRef"] = "hexagonal@1", + ["engine"] = "native", + ["verdict"] = "FAIL", + }, + new Dictionary + { + ["ruleId"] = "adr-compliance", + ["engine"] = "opa", + ["verdict"] = "PASS", + }, + new Dictionary + { + ["ruleId"] = "regla-de-un-motor-nuevo", + ["engine"] = exoticEngine, + ["verdict"] = "WARN", + }, + }, + ["violations"] = new object[] + { + new Dictionary + { + ["ruleId"] = "layer-boundary", + ["tool"] = "dependency-cruiser", + ["file"] = "src/app/handler.ts", + ["line"] = 42, + ["severity"] = "error", + ["message"] = "la capa de aplicación importa infraestructura", + ["adrRef"] = "ADR-0007", + ["accountableOwner"] = ownerA, + ["fingerprint"] = "fp-001", + ["frozen"] = false, + }, + new Dictionary + { + ["ruleId"] = "secret-scan", + ["tool"] = "gitleaks", + ["file"] = "src/app/config.ts", + ["severity"] = "error", + ["message"] = "credencial embebida", + ["accountableOwner"] = ownerB, + ["category"] = "security", + ["fingerprint"] = "fp-002", + ["frozen"] = true, + }, + }, + ["accountableOwners"] = new[] { ownerA, ownerB }.OrderBy(o => o, StringComparer.Ordinal).ToArray(), + ["blockingViolationCount"] = 1, + ["versions"] = new Dictionary + { + ["core"] = "1.2.0", + ["ruleset"] = "hexagonal", + ["rulesetVersion"] = "1.0.0", + }, + }; + + // ── El motor verdadero sobrevive ──────────────────────────────────────────────────────── + + /// + /// El criterio 1 de la ficha dice «motor real». Un mapeo que reescribiera un motor + /// desconocido a `native` escribiría una fila afirmando que una regla de gobernanza produjo un + /// hallazgo que produjo un motor de políticas, y ningún consumidor podría detectar la + /// sustitución después. Por eso se deposita un motor que el Tracker no conoce. + /// + [Fact] + public async Task Ingest_Persists_A_Row_With_The_Engine_Each_Rule_Actually_Ran_On() + { + var correlationId = NewCorrelationId(); + using var machine = Machine(CoreEvaluationIngestWebApplicationFactory.KeyA); + using var human = Human(CoreEvaluationIngestWebApplicationFactory.TenantA); + + var antes = await Ledger(human); + antes.Should().NotContain(r => r.CorrelationId == correlationId, + "la correlación es nueva; si ya estuviera, la prueba estaría midiendo otra corrida"); + + var response = await machine.PostAsJsonAsync(Route, Payload(correlationId)); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var despues = await Ledger(human); + despues.Should().HaveCount(antes.Count + 1, "un depósito nuevo es exactamente una fila nueva"); + + var fila = despues.Single(r => r.CorrelationId == correlationId); + fila.RulesExecuted.Should().HaveCount(3, "una fila de ledger vacía es el fallo que esta ficha ataca"); + fila.RulesExecuted!.Select(r => r.Engine).Should().BeEquivalentTo( + ["native", "opa", "motor-que-el-tracker-no-conoce"], + "el vocabulario de motores es ABIERTO: se tolera lo desconocido, jamás se coacciona"); + fila.RulesExecuted!.Single(r => r.RuleId == "adr-compliance").Verdict.Should().Be("PASS"); + fila.OverallVerdict.Should().Be("FAIL"); + fila.ProducerSurface.Should().Be("cli"); + fila.BlockingViolationCount.Should().Be(1, + "bloquean los `error` NO congelados: uno de los dos está congelado"); + } + + // ── Los dos responsables siguen siendo dos ────────────────────────────────────────────── + + /// + /// `requestedBy.actorId` es QUIÉN PIDIÓ; `violations[].accountableOwner` es QUIÉN DEBE + /// ARREGLARLO. Colapsarlos deja sin respuesta las dos preguntas: «de qué agente son los + /// veredictos que fallan» y «qué equipo posee los fallos». + /// + [Fact] + public async Task Ingest_Keeps_The_Requester_And_The_Accountable_Owner_Apart() + { + var correlationId = NewCorrelationId(); + using var machine = Machine(CoreEvaluationIngestWebApplicationFactory.KeyA); + using var human = Human(CoreEvaluationIngestWebApplicationFactory.TenantA); + + var antes = await Ledger(human); + + var response = await machine.PostAsJsonAsync(Route, Payload( + correlationId, + requesterActorId: "quien-pidio", + ownerA: "@equipo/quien-arregla-a", + ownerB: "@equipo/quien-arregla-b")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var despues = await Ledger(human); + despues.Should().HaveCount(antes.Count + 1); + + var fila = despues.Single(r => r.CorrelationId == correlationId); + + fila.RequestedByActorId.Should().Be("quien-pidio"); + fila.RequestedByActorType.Should().Be("agent"); + fila.Violations.Should().HaveCount(2); + fila.Violations!.Select(v => v.AccountableOwner).Should() + .BeEquivalentTo(["@equipo/quien-arregla-a", "@equipo/quien-arregla-b"]); + fila.Violations!.Should().NotContain(v => v.AccountableOwner == "quien-pidio", + "un defecto sin dueño se registra SIN dueño; rellenarlo con quien pidió sería inventar " + + "una acusación"); + fila.AccountableOwners.Should().BeEquivalentTo( + ["@equipo/quien-arregla-a", "@equipo/quien-arregla-b"]); + } + + // ── Idempotencia ──────────────────────────────────────────────────────────────────────── + + /// + /// Reintentar una pasada de CI es normal y no puede contar dos veces un veredicto. La segunda + /// entrega actualiza la fila existente y devuelve 200 con `created=false`; nunca 409 y nunca + /// una segunda fila. + /// + [Fact] + public async Task Ingest_Is_Idempotent_On_CorrelationId() + { + var correlationId = NewCorrelationId(); + using var machine = Machine(CoreEvaluationIngestWebApplicationFactory.KeyA); + using var human = Human(CoreEvaluationIngestWebApplicationFactory.TenantA); + + var antes = await Ledger(human); + + var primera = await machine.PostAsJsonAsync(Route, Payload(correlationId)); + var segunda = await machine.PostAsJsonAsync(Route, Payload(correlationId)); + + primera.StatusCode.Should().Be(HttpStatusCode.OK); + segunda.StatusCode.Should().Be(HttpStatusCode.OK, "un reintento no es un conflicto"); + + var a = await primera.Content.ReadFromJsonAsync(); + var b = await segunda.Content.ReadFromJsonAsync(); + + a!.Created.Should().BeTrue("el primer depósito crea"); + b!.Created.Should().BeFalse("el segundo es una repetición, y el productor merece saberlo"); + b.TransactionId.Should().Be(a.TransactionId); + + var despues = await Ledger(human); + despues.Should().HaveCount(antes.Count + 1, "dos entregas de un veredicto siguen siendo UN veredicto"); + despues.Count(r => r.CorrelationId == correlationId).Should().Be(1); + } + + // ── El tenant NO viaja en el cuerpo ───────────────────────────────────────────────────── + + /// + /// Aceptar un tenant del cuerpo dejaría que cualquier clave válida depositara evidencia en el + /// ledger de cualquier tenant — justo el agujero que el handler de máquina existe para cerrar. + /// Se rechaza EXPLÍCITAMENTE en vez de ignorarlo: ignorarlo dejaría al productor creyendo que + /// eligió destino. + /// + [Fact] + public async Task Ingest_Rejects_A_Body_That_Carries_A_TenantId() + { + var correlationId = NewCorrelationId(); + using var machine = Machine(CoreEvaluationIngestWebApplicationFactory.KeyA); + using var human = Human(CoreEvaluationIngestWebApplicationFactory.TenantA); + + var antes = await Ledger(human); + + var cuerpo = Payload(correlationId); + cuerpo["tenantId"] = CoreEvaluationIngestWebApplicationFactory.TenantB.ToString(); + + var response = await machine.PostAsJsonAsync(Route, cuerpo); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var despues = await Ledger(human); + despues.Should().HaveCount(antes.Count, "un cuerpo rechazado no escribe nada"); + + using var humanB = Human(CoreEvaluationIngestWebApplicationFactory.TenantB); + (await Ledger(humanB)).Should().NotContain(r => r.CorrelationId == correlationId, + "y desde luego no escribe en el tenant que el cuerpo pretendía elegir"); + } + + // ── Frontera de tenant ────────────────────────────────────────────────────────────────── + + /// + /// La misma correlación bajo dos claves distintas son dos hechos distintos, uno por tenant. La + /// idempotencia se acota al tenant que la clave fija; si fuese global, la primera entrega + /// impediría a otro tenant registrar su propio veredicto. + /// + [Fact] + public async Task Ingest_Never_Crosses_A_Tenant_Boundary() + { + var correlationId = NewCorrelationId(); + using var machineA = Machine(CoreEvaluationIngestWebApplicationFactory.KeyA); + using var machineB = Machine(CoreEvaluationIngestWebApplicationFactory.KeyB); + using var humanA = Human(CoreEvaluationIngestWebApplicationFactory.TenantA); + using var humanB = Human(CoreEvaluationIngestWebApplicationFactory.TenantB); + + var antesA = await Ledger(humanA); + var antesB = await Ledger(humanB); + + (await machineA.PostAsJsonAsync(Route, Payload(correlationId))).StatusCode + .Should().Be(HttpStatusCode.OK); + (await machineB.PostAsJsonAsync(Route, Payload(correlationId))).StatusCode + .Should().Be(HttpStatusCode.OK, "el tenant B tiene derecho a su propio veredicto"); + + var despuesA = await Ledger(humanA); + var despuesB = await Ledger(humanB); + + despuesA.Should().HaveCount(antesA.Count + 1); + despuesB.Should().HaveCount(antesB.Count + 1); + + var filaA = despuesA.Single(r => r.CorrelationId == correlationId); + var filaB = despuesB.Single(r => r.CorrelationId == correlationId); + filaB.Id.Should().NotBe(filaA.Id, "son dos filas, una por tenant"); + + despuesA.Should().NotContain(r => r.Id == filaB.Id, "el tenant A no ve el ledger del B"); + despuesB.Should().NotContain(r => r.Id == filaA.Id, "ni el B el del A"); + } + + // ── Sin credencial no se deposita ─────────────────────────────────────────────────────── + + /// + /// La fábrica arranca en Development A PROPÓSITO: es el único entorno donde el `dev-bypass` + /// —que autentica cualquier petición, incluso anónima, con TODOS los permisos— existe, y por + /// tanto el único donde su alcance a esta ruta sería observable. Una prueba en "Testing" + /// pasaría en verde sin demostrar nada. + /// + [Fact] + public async Task Ingest_Rejects_An_Unauthenticated_Post() + { + var correlationId = NewCorrelationId(); + using var anonimo = _factory.CreateClient(); + using var human = Human(CoreEvaluationIngestWebApplicationFactory.TenantA); + + var antes = await Ledger(human); + + var response = await anonimo.PostAsJsonAsync(Route, Payload(correlationId)); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "si el dev-bypass alcanzara esta ruta, en local cualquier petición anónima podría " + + "escribir en el ledger de evidencia del tenant que quisiera"); + + (await Ledger(human)).Should().HaveCount(antes.Count, "y no debe haber escrito nada"); + } + + [Fact] + public async Task Ingest_Rejects_An_Unknown_Machine_Key() + { + using var machine = Machine("clave-que-no-existe"); + + var response = await machine.PostAsJsonAsync(Route, Payload(NewCorrelationId())); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + // ── Formas de lectura ─────────────────────────────────────────────────────────────────── + + private sealed class IngestAck + { + public string TransactionId { get; set; } = string.Empty; + public string CorrelationId { get; set; } = string.Empty; + public bool Created { get; set; } + } + + private sealed class LedgerRow + { + public Guid Id { get; set; } + public string? CorrelationId { get; set; } + public string? ProducerSurface { get; set; } + public string? OverallVerdict { get; set; } + public string? Outcome { get; set; } + public string? RequestedByActorType { get; set; } + public string? RequestedByActorId { get; set; } + public int BlockingViolationCount { get; set; } + public List? AccountableOwners { get; set; } + public List? RulesExecuted { get; set; } + public List? Violations { get; set; } + } + + private sealed class RuleRow + { + public string RuleId { get; set; } = string.Empty; + public string? RulesetRef { get; set; } + public string Engine { get; set; } = string.Empty; + public string Verdict { get; set; } = string.Empty; + } + + private sealed class ViolationRow + { + public string RuleId { get; set; } = string.Empty; + public string? AccountableOwner { get; set; } + public string Severity { get; set; } = string.Empty; + public bool Frozen { get; set; } + public string Fingerprint { get; set; } = string.Empty; + } +} + +/// +/// Dos claves de máquina, dos tenants: la frontera que estas pruebas ejercen no se puede afirmar +/// con una sola. El sembrador de tenants de desarrollo se retira —hace falta Development por el +/// `dev-bypass`, no por el sembrador—. +/// +public sealed class CoreEvaluationIngestWebApplicationFactory : WebApplicationFactory +{ + public const string KeyA = "gt604-core-machine-key-tenant-a-9f8e7d6c"; + public const string KeyB = "gt604-core-machine-key-tenant-b-1a2b3c4d"; + + public static readonly Guid TenantA = Guid.Parse("604a0000-0000-0000-0000-00000000000a"); + public static readonly Guid TenantB = Guid.Parse("604b0000-0000-0000-0000-00000000000b"); + + private static string ConnectionString => + (Environment.GetEnvironmentVariable("EVOLITH_TRACKER_DB") + ?? "Host=localhost;Port=5432;Database=evolith_tracker;Username=evolith;Password=localdev") + + ";Timeout=5;Command Timeout=15"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = ConnectionString, + ["CoreMachine:Keys:0:TenantId"] = TenantA.ToString(), + ["CoreMachine:Keys:0:Key"] = KeyA, + ["CoreMachine:Keys:0:Description"] = "evolith-core (gt604, tenant A)", + ["CoreMachine:Keys:1:TenantId"] = TenantB.ToString(), + ["CoreMachine:Keys:1:Key"] = KeyB, + ["CoreMachine:Keys:1:Description"] = "evolith-core (gt604, tenant B)", + }); + }); + + builder.ConfigureTestServices(services => + { + HostIsolation.Apply(services); + + var seeder = services.FirstOrDefault(d => + d.ServiceType == typeof(IHostedService) + && d.ImplementationType == typeof(DevTenantSeedHostedService)); + if (seeder is not null) + { + services.Remove(seeder); + } + }); + } +} diff --git a/src/apps/tracker-web/src/api/types.ts b/src/apps/tracker-web/src/api/types.ts index 627093f2..738fc35d 100644 --- a/src/apps/tracker-web/src/api/types.ts +++ b/src/apps/tracker-web/src/api/types.ts @@ -558,7 +558,11 @@ export interface CoreEvaluationTransactionDto { operationId: string; repositoryUrl: string; repositoryRevision: string; - /** El agregado emite minúsculas: `pending` | `completed` | `failed`. */ + /** + * El agregado emite minúsculas: `pending` | `completed` | `failed` | `ingested`. + * `ingested` (GT-604) es una fila DEPOSITADA por una superficie del Core, no una operación + * que el Tracker inició — las dos direcciones del tráfico no se confunden. + */ status: string; requestedAtUtc: string; completedAtUtc?: string | null; @@ -566,6 +570,63 @@ export interface CoreEvaluationTransactionDto { errorMessage?: string | null; /** Carga libre que persisten los endpoints; ver `CoreEvaluationTransactionResponseData`. */ responseData?: CoreEvaluationTransactionResponseData; + + /* ── GT-604 · el veredicto depositado ───────────────────────────────────── + * Nulos en las filas anteriores a la ficha: son operaciones que el Tracker inició contra el + * Core, no veredictos que el Core depositó. Rellenarlos con un valor por defecto haría + * indistinguible «no consta» de «consta que no». + */ + + /** Clave de unión del rastro y de idempotencia dentro del tenant. */ + correlationId?: string | null; + schemaVersion?: string | null; + /** `agent-runtime` | `cli` | `core-api` | `drift-gate` | `mcp`. */ + producerSurface?: string | null; + producerVersion?: string | null; + evaluatedAtUtc?: string | null; + overallVerdict?: string | null; + outcome?: string | null; + + /** QUIÉN PIDIÓ la evaluación — nunca quien debe arreglar una violación. */ + requestedByActorType?: string | null; + requestedByActorId?: string | null; + requestedByModelRef?: string | null; + requestedBySessionId?: string | null; + + rulesExecuted?: CoreEvaluationRuleExecutionDto[]; + violations?: CoreEvaluationViolationDto[]; + /** Proyección derivada de `violations[].accountableOwner`, distinta y ordenada. */ + accountableOwners?: string[]; + /** Violaciones que BLOQUEAN: `error` y no congeladas. */ + blockingViolationCount?: number; + versions?: Record; +} + +/** Una regla ejecutada y el motor que REALMENTE la ejecutó (vocabulario abierto). */ +export interface CoreEvaluationRuleExecutionDto { + ruleId: string; + rulesetRef?: string | null; + /** `native` | `opa` | `enforcer` | cualquier motor nuevo: nunca se coacciona a uno conocido. */ + engine: string; + verdict: string; +} + +/** Una violación normalizada, con QUIÉN DEBE ARREGLARLA. */ +export interface CoreEvaluationViolationDto { + ruleId: string; + tool: string; + file: string; + line?: number | null; + column?: number | null; + severity: string; + message: string; + adrRef?: string | null; + /** El dueño CODEOWNERS del fichero infractor. Ausente = sin dueño, jamás el solicitante. */ + accountableOwner?: string | null; + category?: string | null; + complianceControls?: string[]; + fingerprint: string; + frozen: boolean; } /**