From b4cc4466b065d34aeffbcf3d584bbd9ddae7f950 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Thu, 30 Jul 2026 22:23:40 -0500 Subject: [PATCH 1/3] feat(integration): abrir la puerta de entrada del ledger de evaluaciones (GT-604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hasta ahora los únicos escritores de `core_evaluation_transactions` eran operaciones que el TRACKER iniciaba contra el Core. 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. `POST /api/v1/core-evaluation-transactions` cierra esa mitad: * el tenant lo fija QUÉ CLAVE de máquina encajó, nunca el cuerpo. Un cuerpo que traiga `tenantId` se RECHAZA con 400 en vez de ignorarse: ignorarlo dejaría al productor creyendo que eligió destino; * atado al esquema `CoreMachine` por NOMBRE, de modo que el `dev-bypass` —que autentica cualquier petición, incluso anónima, con todos los permisos— no lo alcanza ni en local. Permiso propio `tracker:core-transaction:ingest`, que la clave lleva SIN `:read`: el Core deposita evidencia, no consulta el inventario de hallazgos y dueños del tenant; * idempotente por `(tenant, correlationId)` con índice único que lo respalda — sin él dos pasadas de CI en carrera contarían dos veces un mismo veredicto—; * el motor de cada regla se guarda VERBATIM: un motor desconocido se tolera, jamás se coacciona a `native`, porque esa sustitución sería indetectable para todo consumidor posterior; * los dos responsables siguen siendo dos: `requestedBy.actorId` es quien pidió y `violations[].accountableOwner` quien debe arreglar. Un defecto sin dueño se registra sin dueño. Estado `ingested`, distinto de `completed`: un depósito no lo inició el Tracker y llega ya terminado. Pruebas, todas SIN puerta de escape de entorno —esta suite ya tiene una clase que se auto-salta salvo `EVOLITH_CORE_LIVE=1` y reporta verde sin ejecutar: * `CoreEvaluationIngestEndpointTests` (7) — vistas en rojo con 405/404 antes de existir la ruta; * `CoreEvaluationIngestContractTests` (3) — guarda de deriva que `T-038` exige para un binding derivado a mano; verificada por mutación (quitar `Engine` del DTO la pone en rojo); * `CoreEvaluationIngestSchemaTests` (2) — vistas en rojo contra una base sin la migración. `types.ts` y la lista literal de `CoreEvaluationTransactionContractTests` se actualizan en el mismo cambio, que es justo lo que esa guarda existe para forzar. Co-Authored-By: Claude Opus 5 --- contracts/evaluation-ingest.fixture.json | 90 + .../DTOs/CoreEvaluationTransactionDto.cs | 68 + ...etCoreEvaluationTransactionQueryHandler.cs | 14 +- .../CoreEvaluationDeposit.cs | Bin 0 -> 6646 bytes .../CoreEvaluationTransaction.cs | 180 + .../CoreEvaluationTransactionProps.cs | 36 +- .../CoreEvaluationTransaction/Repositories.cs | 7 + ...031506_AddCoreEvaluationIngest.Designer.cs | 3807 +++++++++++++++++ .../20260731031506_AddCoreEvaluationIngest.cs | 198 + .../TrackerDbContextModelSnapshot.cs | 75 + .../InMemory/InMemoryRepositories.cs | 8 + ...valuationTransactionRecordConfiguration.cs | 27 + .../CoreEvaluationTransactionRecord.cs | 36 + ...eSqlCoreEvaluationTransactionRepository.cs | 96 +- .../Auth/CoreMachineAuthenticationHandler.cs | 5 + .../Auth/TrackerPermissions.cs | 9 + .../Integration/CoreEvaluationIngestDtos.cs | 180 + .../CoreEvaluationTransactionEndpoints.cs | 122 +- .../CoreEvaluationTransactionContractTests.cs | 19 + .../CoreEvaluationIngestSchemaTests.cs | 116 + .../CoreEvaluationIngestContractTests.cs | 197 + .../CoreEvaluationIngestEndpointTests.cs | 477 +++ src/apps/tracker-web/src/api/types.ts | 63 +- 23 files changed, 5795 insertions(+), 35 deletions(-) create mode 100644 contracts/evaluation-ingest.fixture.json create mode 100644 src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/CoreEvaluationDeposit.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731031506_AddCoreEvaluationIngest.Designer.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731031506_AddCoreEvaluationIngest.cs create mode 100644 src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationIngestDtos.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/CoreEvaluationIngestSchemaTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationIngestContractTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Presentation/Integration/CoreEvaluationIngestEndpointTests.cs 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/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 0000000000000000000000000000000000000000..cdb0d5c0b531d6470626ef1ae7f76bb949dfada2 GIT binary patch literal 6646 zcmb_h%WfOV70p^-Q4nS!0dgyuB!hvhkwTGexdB>~Dau(422ruQNR_5p)voHMO&bPg zJL}{Jyb0ii7lC)#@=xYVa&A?3vq_3H2Z$FQldQ+B`#9&^JB5{M%x-2j7n9&d#D$Uh zENGiqmg}+M+5PP4UeR1L5O4FRdwYscgw1#?*ue1kXJLYlo^YvxNaX?>bE#F()<%R= zo)+#S7AnhQ`UM9ZA0M+*TTCY0%+B1W%fXZH{`eR6+b{oOB4txyL?Yu{v)6@Sh2qQ> znJ^<6OTiK>pu<@wyzbpZgBIhji}~jxCY0N&V?HcWZlrcg6Frei>VU5C7iZk?v(RSZ zfmX27_-tWkq{hPL*L;?0o}3=XXY2sK|7|E{S|v*4$6~5encqK&waW4M+#bV+*g|U$ z0~T6$2bnz5X2R)$XZ_yQAndo;jAhy|VKtk|sbCL~wf`h5J%5*a~YF z=Xj%!T+Rv0#9M?HnU0+HK`0&s;zAh-L+`mtQh&nYJ#QsS$z4GXWU(-Yo2EP2>2aO* zK5Wbi4r7=Ru{McI>_s+q{2TV(?=k#Ys+w2A*fPrSnoC9`6W-&~kymyiI7o;bp+!Ln zm_0;kz@*v?I=M8(2sjj#$82sKGQ{^{!re_;vG;5&@~1do;_nZ8>n7FTAP;>^U{0Y* zD_O3Q;qaW{I9?wi+2P%xoPSc3#PtTmYLaQh0s%Ym*(%0`N_c%d)d%W@UQIhE(xT2# zr3Y02;YlM>hdWVn=&Bf@M83#(oOX;P{-QX}kPf{%XV;Nko2W zfGl~ZtRN~-6p2Fgum(=ovDjdc&zo~r8M1TgM9SvC6I7ORfw6*6B`JW=$e3wG2M+Pd zB>?Ai{_7RHjylo&pX6!)E!eXJz*b=$5?Q$tDaRP79s_64gOO1n%yuypXl{V29)J+5hvFS#5wL}kVIgQ6pGvwwVc!hk3c35L=yg(<|a zm9)qqVu(la6(|~!tuHCrvZPz760jW5cj z#7fw&A@y=_JRqI1Rvc@nEplYaxVRs;RZSK5g11gTe zI0?5KavFhS7paba2CvqM(I%T#EO|WBI=~z}A9DHBKk2CC=rdMg?D~sb3@F%2ykVOU zI5;e``|(taNRC2RB@`145$*vA;@jm_i@x5@y>-Ouq&Ri*vP)!=783|T9N>Qd;Sy|hGnHRWw{hexTRq3 z6xCyp$WCx)g}_m;#o-JIMiLmMhDC+l5EW99(OZi}0o`+Z?@w^wJ8q1@G^%tF`UK(( zN_I+bSc7-QP37RE37~sS%FPZ@)vkJ|1nRf^>D%ui;HlLB3%bgH-KFE4h%yCav;n9h z2|-f1^zs)~gipSWYJ!LFl1Mb^`VP{^xdUwU0IrYla;zI(Mi<@Cw<~xFG91DUCoDgt z3>i}2_YF0|JgPDgD9HLsV>{@groef!t%g69YygaJPYRjd=#WpF!q_I+?E@{1X= z>foFG_g+v&$;s7+KfGVE;KTmmAqvMI$NNmnf3Pwxh@4?}ReSgr0J*evCDx^=_bO8xu26s^c+JbuCBlu?aYAv>;)*aJsDLwQDMJ`&V~Q&;X98fsnfy!l6T?;FUY2De}p zb3r1m3IClvP$f-F*#<XDy<gq? z!jfEBKXx^^z`uovA_WEdSV$<8I`@(fqL(V1B8_=T?Amn1n6!}Bh7GM(nZ2c_thCd1 zcUG%*cyLpe?c$&djDq6(EeBU>{T!t{naDifAlDntA8MVRgXB!Ure9ax?(+Q>q=Oz3 zcp)B02Ez43Eh(F5YLyAD`(ed7^(shQ+U;x9L0zK^LN%63Ajwf7gpWQS9Kt)6LHL9! zCQI`YH{F>o4XnHc!jF;aQ5TMET|{Aj(1V%KY-sKJ z&CpGg&hljA0CPb!yHr3^{ePtluynnW8;Ms{y=x&vz8xZ{J!{Tae9CO8gw+ijy3f@V zr|d2zGtB`N4I*jwk#KbKP}n7-bi#{}wfg<=vfJu+TTSJSxtNay^hR|3Tny;+f!SWZN z3DETK-gwShiFqW=+!L0?1G%dqAeO`3t7h%K17q!8C+z+GD(v4l>L&$F(@v>IBt1zL zR?U4m3!>r$TGaQJaOy|8PM1{jfsm@a1+xOIL-&XtaPf73*1nYFPQ?O3Al6wX6U+`t zqEs(wOhhm@bvjo<^P;gMv;%0x1^y=k4IJyHqp3F5@(0dbX$)85vDA(En1hv|@yD1# ztgdYw|9BS)I?GaMzF4$rV5`&hrp*l-8V9W+z%vq{O6 G=Klbj_3$bH literal 0 HcmV?d00001 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 d50864f8..c45dbed8 100644 --- a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs +++ b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs @@ -8,4 +8,11 @@ public interface ICoreEvaluationTransactionRepository Task DeleteAsync(CoreEvaluationTransaction aggregate, CancellationToken cancellationToken = default); Task> GetByTenantAsync(Guid tenantId, CancellationToken cancellationToken = default); 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); } 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 c23615c1..8f356612 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs @@ -189,4 +189,12 @@ public Task> GetByTenantAsync(Guid tena public Task GetByOperationIdAsync(string operationId, CancellationToken cancellationToken = default) => 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)); } 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 9e91793d..6aa4e6d4 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 @@ -61,6 +71,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) @@ -89,7 +104,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( @@ -104,9 +137,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, @@ -124,6 +213,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 968a3ad0..12403a34 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs @@ -1,22 +1,125 @@ +using Microsoft.AspNetCore.Authorization; using Tracker.Application.Integration.CoreEvaluationTransaction.DTOs; using Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetCoreEvaluationTransaction; using Tracker.Domain.Integration.CoreEvaluationTransaction; 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 ───────────────────────────────────────────────────────────── group.MapGet("/", async ( ITrackerUserContext user, ICoreEvaluationTransactionRepository repository, CancellationToken ct) => { var items = await repository.GetByTenantAsync(user.TenantId, ct); - return Results.Ok(items.Select(ToDto)); + return Results.Ok(items.Select(CoreEvaluationTransactionDto.From)); }) .RequireTrackerPermission(TrackerPermissions.CoreTransactionRead) .WithName("ListCoreEvaluationTransactions"); @@ -33,21 +136,4 @@ public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuil .RequireTrackerPermission(TrackerPermissions.CoreTransactionRead) .WithName("GetCoreEvaluationTransaction"); } - - 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; } /** From 65820f901da2678a741d98cbc497b25b0b737c7b Mon Sep 17 00:00:00 2001 From: aarroyo Date: Thu, 30 Jul 2026 22:45:25 -0500 Subject: [PATCH 2/3] =?UTF-8?q?test(robosoft):=20probar=20el=20bucle=20com?= =?UTF-8?q?pleto=20de=20dep=C3=B3sito=20de=20evidencia=20(GT-604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `core-evidence-ingest` ejecuta la CLI de Evolith DE VERDAD contra un workspace que falla y comprueba que el veredicto acaba como fila persistida aquí. El robot no publica nada: sólo pone `EVOLITH_TRACKER_URL` y `EVOLITH_TRACKER_API_KEY` en el entorno de la CLI. Si la CLI no está cableada al cliente compartido, no aparece ninguna fila y el robot falla. Las afirmaciones que sostienen la prueba son las de NO-VACUIDAD. Un robot que comprobara sólo «existe una fila» pasaría contra un endpoint que persiste un sobre vacío, y una fila de ledger vacía es exactamente el modo de fallo del que trata esta ficha. Se exige: `rulesExecuted` no vacío y con motor en cada regla, `violations` no vacío, al menos una con `accountableOwner`, que ese dueño sea el que CODEOWNERS resuelve, y que el solicitante NUNCA se haya escrito como dueño responsable. El fixture se genera con un CODEOWNERS que casa con todo, a propósito: sin él cada hallazgo vuelve «unassigned» y la comprobación del dueño sería vacua —verde sin demostrar nada—. El `correlationId` se fija en el contexto en vez de dejarlo a la síntesis de la CLI, para unir por un id que el robot CONOCE y no por «la fila más reciente», que pasaría contra el depósito de otro. También comprueba la mitad negativa: un depósito anónimo debe recibir 401. En Development el `dev-bypass` autentica cualquier petición con todos los permisos, así que sin la vinculación por nombre de esquema cualquiera podría escribir en el ledger de evidencia del tenant que quisiera. Verificado en ambos sentidos contra un tracker-api real: 18/18 en verde con la clave correcta; con una clave que el Tracker no reconoce el depósito se rechaza, no aparece fila y el robot FALLA en el paso 4. NO se añade a la lista por defecto de `local-test.sh robosoft`, por la misma razón que dejan fuera a `core-integration` y `runtime-approvals`: necesita la CLI de Evolith y una clave CoreMachine ligada al tenant que el robot lee, y ninguna de las dos las despliega ese pipeline. Meterlo sin ellas pintaría la puerta de rojo por un motivo de entorno y no por un defecto, que es la forma más rápida de enseñar a un equipo a ignorar una puerta. Va en un objetivo propio, `robosoft-ingest`, que FALLA en voz alta si falta la CLI en vez de saltarse. Co-Authored-By: Claude Opus 5 --- product/infra/helm/local-test.sh | 36 ++- .../robots/core-evidence-ingest.robot.mjs | 289 ++++++++++++++++++ 2 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 robosoft/robots/core-evidence-ingest.robot.mjs 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 }); + } + }, +}; From 4ff90e1a1a2db748382adeb9dee9583a5b6729eb Mon Sep 17 00:00:00 2001 From: aarroyo Date: Thu, 30 Jul 2026 22:48:12 -0500 Subject: [PATCH 3/3] =?UTF-8?q?docs(adr):=20T-055=20=E2=80=94=20el=20Core?= =?UTF-8?q?=20deposita,=20el=20Tracker=20posee=20el=20ledger=20(GT-604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La ingesta de evidencia añade un segundo escritor a una tabla que ya existía, un permiso nuevo a la clave de máquina y un valor de estado nuevo. Eso es una decisión de frontera, no un detalle de implementación, y este satélite registra sus decisiones locales como `T-NNN`: sin ADR, la siguiente persona que mire `core_evaluation_transactions` no puede saber por qué las columnas de ingesta son anulables ni por qué `ingested` no es `completed`. Lo que el ADR fija, y por qué en cada caso: * el tenant sale de QUÉ CLAVE encajó y un `tenantId` en el cuerpo se rechaza con 400 en vez de ignorarse; * `:ingest` sin `:read` — el Core deposita evidencia, no consulta el inventario de hallazgos, dueños y rutas del tenant; * el motor de cada regla, verbatim: el vocabulario del Core es abierto y coaccionarlo escribiría una afirmación falsa que nadie podría detectar después; * los dos responsables en columnas distintas; * el índice único no es una optimización: sin él la idempotencia es inexigible bajo concurrencia; * sigue siendo advisory (`T-039`): aterrizar aquí no satisface ninguna compuerta. Registrado en `DECISIONS.md`/`.es.md` y en `evolith.yaml → adrRegistry`, con gemelo bilingüe. `check-bilingual-parity` en verde; `validate-docs` reporta los mismos 111 hallazgos que en `bafebd4`, todos en `docs/audit/`, ninguno de estos ficheros. Co-Authored-By: Claude Opus 5 --- DECISIONS.es.md | 1 + DECISIONS.md | 2 + ...T-055-core-initiated-evidence-ingest.es.md | 121 ++++++++++++++++++ .../T-055-core-initiated-evidence-ingest.md | 119 +++++++++++++++++ evolith.yaml | 1 + 5 files changed, 244 insertions(+) create mode 100644 docs/adrs/T-055-core-initiated-evidence-ingest.es.md create mode 100644 docs/adrs/T-055-core-initiated-evidence-ingest.md 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/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