From 31843097ae09873d51f53c18f2c2c8f9f0ca2d1a Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sat, 1 Aug 2026 11:10:02 -0500 Subject: [PATCH 1/5] chore(contracts): adoptar el schema de arista de evidencia que Core acaba de publicar (GT-605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core promociono `evidence-edge` a su machine-contract set. `verify-contract-pins` lo detecto y fallo con «schema 'evidence-edge' was promoted into Core's public set but is NOT pinned here» — el guardian haciendo su trabajo: fuerza la adopcion en vez de permitir que este satelite ignore en silencio una parte del contrato. Se pinea con su sha256, y `derivedFrom` apunta al commit de Core del que se derivo, que es lo que permite auditar despues de que version salio cada pin. Por que importa mas que un pin de rutina: este satelite es .NET y por ADR T-038 no puede importar `@beyondnet/evolith-contracts`. Hasta ahora la unica forma de construir `evidence_edges` habria sido transcribir a mano la constante TypeScript `EVIDENCE_EDGE_STORAGE_CONTRACT` — la copia traducida que el ADR prohibe, y como los dos grafos de evidencia divergieron la primera vez. Con el schema pineado, la tabla se construye contra un contrato verificado byte a byte en cada CI. Verificado contra el checkout de Core con el cambio: «CONTRACT CONFORMANCE OK — 5 schema(s) match Core's machine-contract set», exit 0. Co-Authored-By: Claude Opus 5 --- contracts/evolith-core-contracts.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/contracts/evolith-core-contracts.json b/contracts/evolith-core-contracts.json index fac25c75..9d6a73bc 100644 --- a/contracts/evolith-core-contracts.json +++ b/contracts/evolith-core-contracts.json @@ -10,10 +10,10 @@ }, "derivedFrom": { "coreRepository": "beyondnetcode/evolith_arch32", - "coreCommit": "f036710d6a4f5d0a80fb4158fc301353c2e7157f", + "coreCommit": "d69e0bc7ea9690b515cff878406c4aae679f74fb", "derivedAt": "2026-08-01", - "gap": "CP-01", - "note": "Contract authority rebaselined from the deprecated npm two-schema package to Core's live four-schema machine-contract manifest." + "gap": "GT-605", + "note": "evidence-edge adopted: Core published the typed evidence-edge model as JSON Schema so this satellite, which is .NET and cannot import @beyondnet/evolith-contracts (ADR T-038), can build `evidence_edges` against the contract instead of transcribing it by hand." }, "schemas": [ { @@ -35,6 +35,11 @@ "id": "evaluation-result", "version": "1.1.0", "sha256": "b0cad39fb15f0dccd1adbcc0f59aa26aee2a0f9a13ad250ffe031db7a0875d9d" + }, + { + "id": "evidence-edge", + "version": "1.0.0", + "sha256": "c73565c3cae069f3d2e4710c49b043c57d0a579390c8591c4a5017074da15798" } ], "consumer": { From 4dbe201a2c919a5489bf6347b1d05bb8d0c536d5 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sat, 1 Aug 2026 11:52:08 -0500 Subject: [PATCH 2/5] feat(evidence): la tabla de aristas que faltaba, y con ella la pregunta que el gap no podia responder (GT-605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existian dos grafos de evidencia, cada uno sin la mitad del otro: el Core declaraba aristas tipadas que nada persistia, y este repositorio persistia `References` como `List` en jsonb cuyo unico lector no-test era un `Contains()` lineal — sin tabla, sin tipo, sin busqueda inversa y sin cota de profundidad. Con el schema ya pineado (PR #89), la mitad persistente se construye contra el contrato en vez de contra mi criterio. Que aterriza: · El modelo tipado en dominio — `EvidenceNodeRef` con su forma canonica `evidence:///`, `EvidenceEdge` y el vocabulario CERRADO de nodos y aristas, espejo de `evidence-edge.schema.json`. Lo que impide que el espejo derive no es la disciplina de quien lo edita: si el Core cambia el vocabulario cambia el sha256 y `conform` se pone rojo antes de que nadie pueda fusionar. · La tabla `evidence_edges` con las diez columnas y los TRES indices que `EVIDENCE_EDGE_STORAGE_CONTRACT` fija, generada con `dotnet ef` y no a mano. `idx_evidence_edges_to` es el que hace posible la busqueda inversa que la columna jsonb no podia servir a ningun coste. · La travesia acotada, espejo de `traverseEvidenceGraph()`. El repositorio carga el sub-grafo por niveles y devuelve ARISTAS, no nodos: asi la respuesta la produce la MISMA funcion que los tests comparan contra el contrato. Devolver nodos ya recorridos habria dejado dos semanticas de travesia sin nada que garantice que coinciden — el fallo que este gap cierra. · El backfill desde `references`. Cada referencia canonica se lee como «este registro VALIDA la cosa referenciada», la unica lectura que no inventa informacion. Lo que no parsea se QUEDA donde esta: son ids externos opacos, no son aristas, y por eso la columna sobrevive una release mas. El backfill es SQL crudo con regex y en CI correria sobre una tabla vacia — habria pasado en verde sin probar nada. Asi que se ejecuto de verdad contra un Postgres 16 en Docker, sembrado con las seis formas que importan. De 6 referencias produjo exactamente las 3 correctas: entran las canonicas con vocabulario valido, no entra el id externo opaco (`EXT-999`, que se verifico que SIGUE en `References`), no entra el `kind` inventado, no entra el autolazo, y un id con barras (`evidence://artifact/src/apps/foo.ts`) sobrevive entero en vez de trocearse por la segunda barra. Re-ejecutado da `INSERT 0 0`: idempotente por el indice de identidad. Verificado: `has-pending-model-changes` confirma modelo y snapshot consistentes; 28 tests de dominio, 5 de persistencia contra la base real, y la suite completa en 1144/1144 — cero fallos, que es la primera vez en esta linea porque los 10 que fallaban dependian de Postgres. Co-Authored-By: Claude Opus 5 --- .../Governance/EvidenceGraph/EvidenceEdge.cs | 133 + .../EvidenceGraph/EvidenceEdgeValues.cs | 95 + .../EvidenceGraph/EvidenceGraphTraversal.cs | 87 + .../EvidenceGraph/EvidenceNodeRef.cs | 66 + .../Governance/EvidenceGraph/Repositories.cs | 25 + .../DependencyInjection.cs | 2 + ...0260801164354_AddEvidenceEdges.Designer.cs | 3927 +++++++++++++++++ .../20260801164354_AddEvidenceEdges.cs | 108 + .../TrackerDbContextModelSnapshot.cs | 70 + .../EvidenceEdgeRecordConfiguration.cs | 46 + .../Governance/Entities/EvidenceEdgeRecord.cs | 30 + .../PostgreSqlEvidenceEdgeRepository.cs | 128 + .../Persistence/TrackerDbContext.cs | 3 + .../Domain/Governance/EvidenceEdgeTests.cs | 168 + .../Governance/EvidenceGraphTraversalTests.cs | 151 + .../EvidenceEdgePersistenceTests.cs | 189 + 16 files changed, 5228 insertions(+) create mode 100644 src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdge.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdgeValues.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceGraphTraversal.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceNodeRef.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/Repositories.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260801164354_AddEvidenceEdges.Designer.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260801164354_AddEvidenceEdges.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/EvidenceEdgeRecordConfiguration.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/EvidenceEdgeRecord.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlEvidenceEdgeRepository.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Domain/Governance/EvidenceEdgeTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Domain/Governance/EvidenceGraphTraversalTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/EvidenceEdgePersistenceTests.cs diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdge.cs b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdge.cs new file mode 100644 index 00000000..a1b16c36 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdge.cs @@ -0,0 +1,133 @@ +namespace Tracker.Domain.Governance.EvidenceGraph; + +/// +/// GT-605 — una arista tipada, dirigida y fechada del grafo de evidencia. +/// +/// Cierra la mitad persistente del gap. Existían dos grafos, cada uno sin la mitad del otro: +/// el Core declaraba aristas tipadas que nada persistía, y el Tracker persistía +/// List<string> que nada tipaba — sin tabla de aristas, sin tipo, sin búsqueda inversa +/// y sin cota de profundidad. La consecuencia era que «qué ADR se movió por causa de qué decisión +/// de compuerta por causa de qué turno de agente» no tenía respuesta. +/// +/// OccurredAt forma parte de la IDENTIDAD de la arista, no es metadato: la misma +/// relación afirmada en dos instantes son dos hechos del ledger y no uno. Es también lo que hace +/// el registro auditable en vez de meramente navegable — una cadena de evidencia sin tiempo no +/// puede responder «en el momento de la decisión, qué se sabía». +/// +public sealed record EvidenceEdge +{ + private EvidenceEdge( + EvidenceNodeRef from, + EvidenceNodeRef to, + string type, + DateTime occurredAtUtc, + string? assertedBy, + string? provenance) + { + From = from; + To = to; + Type = type; + OccurredAtUtc = occurredAtUtc; + AssertedBy = assertedBy; + Provenance = provenance; + } + + public EvidenceNodeRef From { get; } + + public EvidenceNodeRef To { get; } + + public string Type { get; } + + public DateTime OccurredAtUtc { get; } + + /// Quién afirmó la arista. Metadato de la afirmación: NO entra en la identidad. + public string? AssertedBy { get; } + + /// Id de correlación o etiqueta de migración. Opaco a la travesía; tampoco entra en la identidad. + public string? Provenance { get; } + + public static Result Create( + EvidenceNodeRef? from, + EvidenceNodeRef? to, + string? type, + DateTime occurredAtUtc, + string? assertedBy = null, + string? provenance = null) + { + if (from is null) return Result.Failure("EvidenceEdge.FromRequired"); + if (to is null) return Result.Failure("EvidenceEdge.ToRequired"); + + if (!EvidenceEdgeType.IsValid(type)) + return Result.Failure("EvidenceEdge.UnknownType"); + + // Un lazo no aporta ninguna travesía y sí un ciclo trivial en cada consulta. + if (from.Kind == to.Kind && from.Id == to.Id) + return Result.Failure("EvidenceEdge.SelfLoop"); + + if (occurredAtUtc == default) + return Result.Failure("EvidenceEdge.OccurredAtRequired"); + + return Result.Success(new EvidenceEdge( + from, + to, + type!, + DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), + string.IsNullOrWhiteSpace(assertedBy) ? null : assertedBy.Trim(), + string.IsNullOrWhiteSpace(provenance) ? null : provenance.Trim())); + } + + public static EvidenceEdge Rehydrate( + EvidenceNodeRef from, + EvidenceNodeRef to, + string type, + DateTime occurredAtUtc, + string? assertedBy, + string? provenance) + => new(from, to, type, occurredAtUtc, assertedBy, provenance); + + /// + /// Identidad estable, espejo de evidenceEdgeKey() del contrato. Excluye + /// y —metadatos sobre la afirmación— e + /// incluye . Es lo que hace idempotente re-afirmar una arista. + /// + public string IdentityKey() => string.Join( + '|', + From.ToCanonicalString(), + Type, + To.ToCanonicalString(), + OccurredAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture)); + + /// + /// Invierte la arista intercambiando los extremos. El TIPO se conserva: sólo cambia la + /// dirección de recorrido, no lo que la arista significa. + /// + public EvidenceEdge Inverted() => + new(To, From, Type, OccurredAtUtc, AssertedBy, Provenance); + + /// + /// GT-605 — backfill mecánico de una referencia heredada de EvidenceRecordProps.References. + /// + /// Cada referencia se lee como «este registro de evidencia VALIDA la cosa referenciada», + /// que es lo que la columna ha significado siempre en la práctica y la única lectura que no + /// inventa información. + /// + /// Devuelve null cuando la referencia no es canónica. Eso NO es un fallo: son ids + /// externos opacos, no son aristas, y la migración debe dejarlos en References. Por eso + /// la columna se conserva como proyección durante una release — borrarla antes perdería los + /// ids externos. + /// + public static EvidenceEdge? FromLegacyReference( + EvidenceNodeRef subject, + string? reference, + DateTime occurredAtUtc, + string provenance = "backfill:ReferencesJson") + { + var target = EvidenceNodeRef.Parse(reference); + if (target is null) return null; + + var created = Create( + subject, target, EvidenceEdgeType.Validates, occurredAtUtc, "evolith_tracker", provenance); + + return created.IsSuccess ? created.Value : null; + } +} diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdgeValues.cs b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdgeValues.cs new file mode 100644 index 00000000..4fe23c53 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdgeValues.cs @@ -0,0 +1,95 @@ +namespace Tracker.Domain.Governance.EvidenceGraph; + +/// +/// GT-605 — el vocabulario de nodos y aristas del grafo de evidencia. +/// +/// Esto es un ESPEJO de evidence-edge.schema.json, el schema que el Core publica en +/// su machine-contract set y que este satélite pinea en +/// contracts/evolith-core-contracts.json (id evidence-edge, sha256 +/// c73565c3…). Es un espejo y no un import porque el Tracker es .NET y por +/// ADR T-038 no puede consumir @beyondnet/evolith-contracts. +/// +/// Lo que impide que el espejo derive NO es la disciplina de quien lo edita: es la cadena +/// pin → verify-contract-pins. Si el Core cambia el vocabulario, cambia el sha256 del +/// fichero, el guardián `conform` de este repositorio se pone rojo y nadie puede fusionar hasta +/// que ambos lados se muevan juntos. Los tests de EvidenceEdgeVocabularyTests afirman esta +/// lista contra la del schema para que el fallo señale la línea exacta. +/// +public static class EvidenceNodeKind +{ + public const string Adr = "adr"; + public const string AgentTurn = "agent-turn"; + public const string Artifact = "artifact"; + public const string Evaluation = "evaluation"; + public const string EvidenceRecord = "evidence-record"; + public const string GateDecision = "gate-decision"; + public const string Initiative = "initiative"; + public const string RulesetRule = "ruleset-rule"; + + /// + /// En el MISMO orden que el `enum` del schema. El orden importa: el test de conformidad lo + /// compara posición a posición, que es lo que convierte «añadí un tipo y olvidé el otro lado» + /// en un fallo en vez de en una divergencia silenciosa. + /// + public static readonly IReadOnlyList All = + [ + Adr, AgentTurn, Artifact, Evaluation, EvidenceRecord, GateDecision, Initiative, RulesetRule, + ]; + + /// + /// CERRADO a propósito. Un `kind` abierto es exactamente cómo la columna `References` del + /// Tracker acabó sin tipar: cualquier cadena entraba, así que nadie podía consultarla. + /// + public static bool IsValid(string? kind) => kind is not null && All.Contains(kind); +} + +/// +/// GT-605 — los tipos de arista. Espejo del mismo schema. +/// +public static class EvidenceEdgeType +{ + /// `{from}` no puede considerarse completo sin `{to}`. + public const string Requires = "requires"; + + /// `{from}` es evidencia de que `{to}` se cumple. + public const string Validates = "validates"; + + /// `{from}` impide que `{to}` avance. + public const string Blocks = "blocks"; + + /// + /// `{from}` cambió a causa de `{to}`. + /// + /// Es la única adición del contrato publicado sobre las tres nativas del Core, y no es + /// decoración: el criterio de aceptación de GT-605 es la frase «qué ADR se movió POR CAUSA DE + /// qué decisión de compuerta POR CAUSA DE qué turno de agente», y ninguna de las otras tres + /// expresa causación. Sin ella la travesía que se reclama como la mitad fuerte del foso no se + /// puede escribir. + /// + public const string CausedBy = "caused_by"; + + /// Las tres nativas del Core, primero y en su orden — adoptar el contrato es un ensanchamiento. + public static readonly IReadOnlyList CoreNative = [Requires, Validates, Blocks]; + + /// El vocabulario publicado, en el MISMO orden que el `enum` del schema. + public static readonly IReadOnlyList All = [Requires, Validates, Blocks, CausedBy]; + + public static bool IsValid(string? type) => type is not null && All.Contains(type); +} + +/// +/// GT-605 — dirección de recorrido de la travesía. +/// +public static class EvidenceTraversalDirection +{ + public const string Outgoing = "outgoing"; + + /// La búsqueda inversa que la columna jsonb `List<string>` no puede servir a ningún coste. + public const string Incoming = "incoming"; + + public const string Both = "both"; + + public static readonly IReadOnlyList All = [Outgoing, Incoming, Both]; + + public static bool IsValid(string? value) => value is not null && All.Contains(value); +} diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceGraphTraversal.cs b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceGraphTraversal.cs new file mode 100644 index 00000000..4705f6c1 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceGraphTraversal.cs @@ -0,0 +1,87 @@ +namespace Tracker.Domain.Governance.EvidenceGraph; + +/// Un nodo alcanzado por la travesía, con cómo se llegó a él. +public sealed record EvidenceTraversalHit( + EvidenceNodeRef Node, + int Depth, + IReadOnlyList Path); + +/// +/// GT-605 — travesía en anchura acotada en profundidad. +/// +/// Es el espejo en C# de traverseEvidenceGraph() del contrato publicado, y existe +/// para una razón concreta: el endpoint sirve la consulta con SQL recursivo, y dos +/// implementaciones de una consulta de grafo sólo se mantienen iguales si una de ellas puede +/// ejecutarse sobre el mismo conjunto de aristas y compararse. Esta es esa otra. Los tests +/// resuelven el mismo sub-grafo por ambos caminos. +/// +/// La cota NO es una optimización, es la corrección: el ledger es append-only y una travesía +/// sin límite es trabajo sin límite sobre una tabla que sólo crece. +/// +/// Garantías, las mismas que el contrato: anchura primero —así la primera vez que se alcanza +/// un nodo es por un camino mínimo—, cada nodo aparece a lo sumo una vez, y los ciclos terminan. +/// +public static class EvidenceGraphTraversal +{ + /// Profundidad por defecto del endpoint. + public const int DefaultDepth = 2; + + /// + /// Techo duro. Un cliente que pida más no recibe un error: recibe esto, porque el límite + /// protege a la base de datos y no al llamante. + /// + public const int MaxDepth = 5; + + public static IReadOnlyList Traverse( + IReadOnlyCollection edges, + EvidenceNodeRef start, + int maxDepth, + string direction = EvidenceTraversalDirection.Outgoing, + IReadOnlyCollection? types = null) + { + var depth = Math.Clamp(maxDepth, 0, MaxDepth); + var allowed = types is { Count: > 0 } ? new HashSet(types) : new HashSet(EvidenceEdgeType.All); + + var seen = new HashSet { start.ToCanonicalString() }; + var hits = new List { new(start, 0, []) }; + var frontier = new List(hits); + + for (var d = 1; d <= depth && frontier.Count > 0; d++) + { + var next = new List(); + + foreach (var current in frontier) + { + foreach (var edge in edges) + { + if (!allowed.Contains(edge.Type)) continue; + + EvidenceNodeRef? target = null; + if (direction != EvidenceTraversalDirection.Incoming && Same(edge.From, current.Node)) + { + target = edge.To; + } + else if (direction != EvidenceTraversalDirection.Outgoing && Same(edge.To, current.Node)) + { + target = edge.From; + } + + if (target is null) continue; + + var key = target.ToCanonicalString(); + if (!seen.Add(key)) continue; + + var hit = new EvidenceTraversalHit(target, d, [.. current.Path, edge]); + hits.Add(hit); + next.Add(hit); + } + } + + frontier = next; + } + + return hits; + } + + private static bool Same(EvidenceNodeRef a, EvidenceNodeRef b) => a.Kind == b.Kind && a.Id == b.Id; +} diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceNodeRef.cs b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceNodeRef.cs new file mode 100644 index 00000000..739469a6 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceNodeRef.cs @@ -0,0 +1,66 @@ +namespace Tracker.Domain.Governance.EvidenceGraph; + +/// +/// GT-605 — un nodo del grafo de evidencia: una clase cerrada más un id único dentro de ella. +/// +/// La forma canónica en cadena es evidence://<kind>/<id>, y no es +/// decoración: es lo que hace que la columna References (List<string> en +/// jsonb) sea migrable mecánicamente en vez de una reescritura. Una referencia ya almacenada o +/// bien parsea como una de éstas —y entonces es un nodo tipado— o no parsea —y entonces es un id +/// externo opaco de los que Contains() usa para deduplicar—. No hay que adivinar nada. +/// +public sealed record EvidenceNodeRef +{ + /// Esquema de la forma canónica, espejo de EVIDENCE_REF_SCHEME. + public const string Scheme = "evidence"; + + private EvidenceNodeRef(string kind, string id) + { + Kind = kind; + Id = id; + } + + public string Kind { get; } + + public string Id { get; } + + public static Result Create(string? kind, string? id) + { + if (!EvidenceNodeKind.IsValid(kind)) + return Result.Failure("EvidenceEdge.Node.UnknownKind"); + + if (string.IsNullOrWhiteSpace(id)) + return Result.Failure("EvidenceEdge.Node.IdRequired"); + + return Result.Success(new EvidenceNodeRef(kind!, id.Trim())); + } + + /// Rehidrata desde persistencia sin revalidar: la fila ya pasó por . + public static EvidenceNodeRef Rehydrate(string kind, string id) => new(kind, id); + + /// Forma canónica evidence://<kind>/<id>. + public string ToCanonicalString() => $"{Scheme}://{Kind}/{Id}"; + + /// + /// Inversa de . Devuelve null para cualquier cadena que + /// no sea canónica — que es la respuesta correcta y no un error: las referencias heredadas que + /// no parsean son ids externos opacos, NO son aristas, y deben quedarse donde están. + /// + public static EvidenceNodeRef? Parse(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + + const string prefix = $"{Scheme}://"; + if (!value.StartsWith(prefix, StringComparison.Ordinal)) return null; + + var rest = value[prefix.Length..]; + var slash = rest.IndexOf('/'); + if (slash <= 0) return null; + + var kind = rest[..slash]; + var id = rest[(slash + 1)..]; + if (!EvidenceNodeKind.IsValid(kind) || id.Length == 0) return null; + + return new EvidenceNodeRef(kind, id); + } +} diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/Repositories.cs b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/Repositories.cs new file mode 100644 index 00000000..49d8966b --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/Repositories.cs @@ -0,0 +1,25 @@ +namespace Tracker.Domain.Governance.EvidenceGraph; + +public interface IEvidenceEdgeRepository +{ + Task AddAsync(Guid tenantId, EvidenceEdge edge, CancellationToken ct = default); + + /// + /// GT-605 — carga el sub-grafo alcanzable desde hasta + /// saltos. + /// + /// Devuelve ARISTAS y no nodos a propósito: quien llama las pasa por + /// , de modo que la respuesta del endpoint la + /// produce la MISMA función que los tests comparan contra el contrato publicado. Si el + /// repositorio devolviera nodos ya recorridos, habría dos semánticas de travesía —una en SQL y + /// otra en memoria— y nada que garantice que coinciden, que es exactamente el fallo que este + /// gap cierra. + /// + Task> LoadSubgraphAsync( + Guid tenantId, + EvidenceNodeRef start, + int maxDepth, + string direction = EvidenceTraversalDirection.Outgoing, + IReadOnlyCollection? types = null, + CancellationToken ct = default); +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs b/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs index 75212ade..74b44c8c 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Tracker.Domain.Governance.EvidenceGraph; using System.Threading.Channels; using MassTransit; using MediatR; @@ -67,6 +68,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // CD-30 — deriva en servidor que criterios incumple una compuerta, cruzando la ejecucion diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260801164354_AddEvidenceEdges.Designer.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260801164354_AddEvidenceEdges.Designer.cs new file mode 100644 index 00000000..e4a6e5d8 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260801164354_AddEvidenceEdges.Designer.cs @@ -0,0 +1,3927 @@ +// +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("20260801164354_AddEvidenceEdges")] + partial class AddEvidenceEdges + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("tracker_governance") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Artifacts.Entities.EvidenceRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdapterVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("adapter_version"); + + b.Property("ArtifactId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_type"); + + b.Property("Classification") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("classification"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentHash") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("content_hash"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Determinism") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("determinism"); + + b.Property("Dimension") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("dimension"); + + b.Property("EvidenceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("evidence_type"); + + b.Property("Immutable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("immutable"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Producer") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("producer"); + + b.Property("ReferencesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("references"); + + b.Property("RetentionPolicyRef") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("retention_policy_ref"); + + b.Property("SourceProvider") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("source_provider"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("SubmittedBy") + .HasColumnType("uuid") + .HasColumnName("submitted_by"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Classification"); + + b.HasIndex("Dimension"); + + b.HasIndex("TenantId"); + + b.HasIndex("ArtifactType", "ArtifactId"); + + b.ToTable("evidence_records", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Audit.Entities.AuditEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("ActorType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("actor_type"); + + b.Property("AgentId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("agent_id"); + + b.Property("ChangesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("session_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ActorId"); + + b.HasIndex("ActorType"); + + b.HasIndex("TenantId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_entries", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Design.Entities.TechnicalBlueprintRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IntegrationAdrRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("integration_adr_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("technical_blueprints", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.InitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalComplete") + .HasColumnType("boolean") + .HasColumnName("approval_complete"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedBy") + .HasColumnType("uuid") + .HasColumnName("approved_by"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConstituentsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("constituents"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("origin"); + + b.Property("OriginRef") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("origin_ref"); + + b.Property("ProductScopeJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("product_scope"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("RequiredApproversJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_approvers"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SupersededByInitiativeId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_initiative_id"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TypeFrozen") + .HasColumnType("boolean") + .HasColumnName("type_frozen"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code"); + + b.ToTable("initiatives", "tracker_governance", t => + { + t.HasCheckConstraint("ck_initiatives_status", "status IN ('draft', 'submitted', 'approved', 'rejected', 'consolidated')"); + }); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.OpportunityRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("ContextJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("context"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("opportunities", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CatalogReleaseRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("catalog_release", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CountryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DefaultCurrencyCode") + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("default_currency_code"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character(2)") + .HasColumnName("iso_alpha2") + .IsFixedLength(); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("iso_alpha3") + .IsFixedLength(); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumericCode") + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("numeric_code") + .IsFixedLength(); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IsoAlpha2") + .IsUnique(); + + b.HasIndex("IsoAlpha3") + .IsUnique(); + + b.ToTable("country", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CurrencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MinorUnits") + .HasColumnType("smallint") + .HasColumnName("minor_units"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("symbol"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currency", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.ExchangeRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("base_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("QuoteCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("quote_code"); + + b.Property("Rate") + .HasColumnType("numeric(20,10)") + .HasColumnName("rate"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("BaseCode", "QuoteCode", "ValidFrom") + .HasDatabaseName("ix_exchange_rate_pair"); + + b.ToTable("exchange_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LanguageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("language", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocaleProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DateFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("date_format"); + + b.Property("FirstDayOfWeek") + .HasColumnType("smallint") + .HasColumnName("first_day_of_week"); + + b.Property("Name") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumberFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("number_format"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CountryId"); + + b.ToTable("locale_profile", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scheme"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("LocationId", "Scheme") + .IsUnique(); + + b.HasIndex("Scheme", "Value") + .IsUnique(); + + b.ToTable("location_code", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.HasKey("LocationId", "Locale"); + + b.ToTable("location_i18n", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationLevelRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("Ordinal") + .HasColumnType("smallint") + .HasColumnName("ordinal"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Ordinal") + .IsUnique(); + + b.ToTable("location_level", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CatalogReleaseId") + .HasColumnType("uuid") + .HasColumnName("catalog_release_id"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LevelId") + .HasColumnType("uuid") + .HasColumnName("level_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("SupersedesId") + .HasColumnType("uuid") + .HasColumnName("supersedes_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_location_parent"); + + b.HasIndex("Path") + .HasDatabaseName("ix_location_path"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist"); + + b.HasIndex("CountryId", "ParentId") + .HasDatabaseName("ix_location_country_parent"); + + b.ToTable("location", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("OperationType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation_type"); + + b.Property("Rate") + .HasColumnType("numeric(6,4)") + .HasColumnName("rate"); + + b.Property("TaxSchemeId") + .HasColumnType("uuid") + .HasColumnName("tax_scheme_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("TaxSchemeId", "LocationId", "ValidFrom") + .HasDatabaseName("ix_tax_rate_scheme_location"); + + b.ToTable("tax_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxSchemeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Code") + .IsUnique(); + + b.ToTable("tax_scheme", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TimeZoneRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("country_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IanaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("iana_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.HasIndex("IanaId") + .IsUnique(); + + b.ToTable("time_zone", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ArtifactFieldSchemaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("artifact_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ArtifactType") + .IsUnique(); + + b.ToTable("artifact_field_schemas", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.EvidenceEdgeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AssertedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("asserted_by"); + + b.Property("EdgeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("edge_type"); + + b.Property("FromId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("from_id"); + + b.Property("FromKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("from_kind"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Provenance") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("provenance"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("to_id"); + + b.Property("ToKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("to_kind"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_from"); + + b.HasIndex("TenantId", "ToKind", "ToId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_to"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType", "ToKind", "ToId", "OccurredAtUtc") + .IsUnique() + .HasDatabaseName("ux_evidence_edges_identity"); + + b.ToTable("evidence_edges", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ExceptionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CriterionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("criterion_id"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at_utc"); + + b.Property("DecidedBy") + .HasColumnType("uuid") + .HasColumnName("decided_by"); + + b.Property("DecisionNotes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("decision_notes"); + + b.Property("ExceptionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("exception_type"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("Justification") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("justification"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("RequestedBy") + .HasColumnType("uuid") + .HasColumnName("requested_by"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_exception_requests_tenant_status"); + + b.HasIndex("TenantId", "SdlcExecutionId", "Phase") + .HasDatabaseName("ix_exception_requests_tenant_execution_phase"); + + b.ToTable("exception_requests", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("approval_ids"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DecisionBy") + .HasColumnType("uuid") + .HasColumnName("decision_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationIdsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evaluation_ids"); + + b.Property("EvidenceSnapshotRef") + .HasColumnType("text") + .HasColumnName("evidence_snapshot_ref"); + + b.Property("ExceptionIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("exception_ids"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("PolicySnapshotRef") + .HasColumnType("text") + .HasColumnName("policy_snapshot_ref"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rationale"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Phase"); + + b.HasIndex("SdlcExecutionId"); + + b.HasIndex("TenantId"); + + b.ToTable("gate_decisions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GatePolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("ApprovalPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approval_policy"); + + b.Property("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("SubjectConfidence") + .HasColumnType("double precision") + .HasColumnName("subject_confidence"); + + b.Property("SubjectKind") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("subject_kind"); + + b.Property("SubjectPayloadJson") + .HasColumnType("jsonb") + .HasColumnName("subject_payload"); + + b.Property("SubjectRef") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("subject_ref"); + + b.Property("SubjectSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("subject_summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CorrelationId") + .IsUnique() + .HasDatabaseName("ix_runtime_approvals_tenant_correlation") + .HasFilter("correlation_id IS NOT NULL"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_runtime_approvals_tenant_status"); + + b.HasIndex("TenantId", "SubjectKind", "SubjectRef") + .HasDatabaseName("ix_runtime_approvals_tenant_subject") + .HasFilter("subject_kind IS NOT NULL"); + + b.ToTable("runtime_approvals", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.SDLCExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BlockedReason") + .HasColumnType("text") + .HasColumnName("blocked_reason"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ProductCode"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "InitiativeId") + .HasDatabaseName("ix_sdlc_executions_tenant_initiative"); + + b.ToTable("sdlc_executions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeDeadLetterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("intake_dead_letter", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("Status", "NextAttemptAtUtc"); + + b.ToTable("intake_outbox", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.PpmEndpointRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("none") + .HasColumnName("auth_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FieldBindingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("field_bindings"); + + b.Property("HealthState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("health_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sync_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasColumnType("text") + .HasColumnName("secret_ref"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("TelemetryUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("telemetry_url"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ppm_endpoints", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicBaselineRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AnchoredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("anchored_at"); + + b.Property("Budget") + .IsRequired() + .HasColumnType("text") + .HasColumnName("budget"); + + b.Property("ExpectedRoi") + .IsRequired() + .HasColumnType("text") + .HasColumnName("expected_roi"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("RiskLevel") + .IsRequired() + .HasColumnType("text") + .HasColumnName("risk_level"); + + b.Property("StrategicInitiativeId") + .HasColumnType("uuid") + .HasColumnName("strategic_initiative_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("StrategicInitiativeId"); + + b.HasIndex("StrategicInitiativeId", "Version") + .IsUnique(); + + b.ToTable("strategic_baselines", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicInitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("FeasibilityJson") + .HasColumnType("jsonb") + .HasColumnName("feasibility"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("IntakeState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("intake_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("ProductCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ProductName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("product_name"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IntakeState"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ExternalId") + .IsUnique(); + + b.ToTable("strategic_initiatives", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.CoreEvaluationTransactionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountableOwnersJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("accountable_owners"); + + b.Property("BlockingViolationCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("blocking_violation_count"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CorrelationId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("ErrorCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("error_code"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("operation_id"); + + b.Property("Outcome") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("OverallVerdict") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("overall_verdict"); + + b.Property("ProducerSurface") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("producer_surface"); + + b.Property("ProducerVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("producer_version"); + + b.Property("RepositoryRevision") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("repository_revision"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedByJson") + .HasColumnType("jsonb") + .HasColumnName("requested_by"); + + b.Property("ResponseDataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_data"); + + b.Property("RulesExecutedJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("rules_executed"); + + b.Property("SchemaVersion") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("schema_version"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("VersionsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("versions"); + + b.Property("ViolationsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("violations"); + + b.HasKey("Id"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.HasIndex("TenantId", "CorrelationId") + .IsUnique(); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("core_evaluation_transactions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.ProviderConnectionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AclDirection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("acl_direction"); + + b.Property("AclFieldOwnership") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("acl_field_ownership"); + + b.Property("AclScopesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("acl_scopes"); + + b.Property("Capability") + .IsRequired() + .HasColumnType("text") + .HasColumnName("capability"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at_utc"); + + b.Property("Port") + .IsRequired() + .HasColumnType("text") + .HasColumnName("port"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("secret_ref"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SyncCursor") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("sync_cursor"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Capability"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("provider_connections", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.PublicationOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("capability"); + + b.Property("CheckIdentifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("check_identifier"); + + b.Property("ComponentIdentifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("component_identifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("last_error"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("state"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("State", "NextAttemptAtUtc"); + + b.HasIndex("AggregateId", "ComponentIdentifier", "CheckIdentifier") + .IsUnique() + .HasFilter("state = 'pending'"); + + b.ToTable("publication_outbox", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Metrics.PhaseSlaPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("SlaDays") + .HasColumnType("integer") + .HasColumnName("sla_days"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("phase_sla_policies", "tracker_metrics"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Products.Entities.ProductRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalRefsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("external_refs"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("OwnerIdentity") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("owner_identity"); + + b.Property("RepositoryAccessJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("repository_access"); + + b.Property("RepositoryUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Topology") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("topology"); + + b.Property("TopologySetJson") + .HasColumnType("jsonb") + .HasColumnName("topology_set"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("products", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.DefectRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceRefsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evidence_refs"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("Resolution") + .HasColumnType("text") + .HasColumnName("resolution"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TestCycleId") + .HasColumnType("uuid") + .HasColumnName("test_cycle_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TestCycleId"); + + b.HasIndex("ProductId", "Status"); + + b.ToTable("defects", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.TestCycleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("closed_at"); + + b.Property("ClosedBlocked") + .HasColumnType("integer") + .HasColumnName("closed_blocked"); + + b.Property("ClosedFailed") + .HasColumnType("integer") + .HasColumnName("closed_failed"); + + b.Property("ClosedPassed") + .HasColumnType("integer") + .HasColumnName("closed_passed"); + + b.Property("ClosureNote") + .HasColumnType("text") + .HasColumnName("closure_note"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Status"); + + b.ToTable("test_cycles", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Qa.Entities.TestExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("evidence_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("outcome"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TestCaseRef") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("test_case_ref"); + + b.Property("TestCycleId") + .HasColumnType("uuid") + .HasColumnName("test_cycle_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TestCycleId", "Outcome"); + + b.ToTable("test_executions", "tracker_qa"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseArtifactRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ItemsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("items"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase") + .IsUnique(); + + b.ToTable("phase_artifacts", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseProgressionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("HistoryJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("history"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("phase_progressions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc2.DeploymentRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Environment") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("environment"); + + b.Property("EvidenceRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("evidence_ref"); + + b.Property("FinishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("RollbackReason") + .HasColumnType("text") + .HasColumnName("rollback_reason"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Environment", "Outcome", "FinishedAtUtc"); + + b.ToTable("deployment_records", "tracker_release"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc2.PeerReviewRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ChangeRef") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("change_ref"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("outcome"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("reviewed_at"); + + b.Property("ReviewerId") + .HasColumnType("uuid") + .HasColumnName("reviewer_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ChangeRef"); + + b.HasIndex("TenantId"); + + b.HasIndex("ProductId", "Outcome"); + + b.ToTable("peer_reviews", "tracker_construction"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantIntelligenceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AgentRegistrationsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("agent_registrations"); + + b.Property("CorpusRefsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("corpus_refs"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelRoutingJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("model_routing"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("tenant_intelligence", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("contact_email"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("display_name"); + + b.Property("GeoCatalogRelease") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("geo_catalog_release"); + + b.Property("GeoCountryId") + .HasColumnType("uuid") + .HasColumnName("geo_country_id"); + + b.Property("GeoDisplayPath") + .HasColumnType("text") + .HasColumnName("geo_display_path"); + + b.Property("GeoUbigeo") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("geo_ubigeo"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LocalizationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("localization"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("tier"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("tenants", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany("Codes") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Navigation("Codes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260801164354_AddEvidenceEdges.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260801164354_AddEvidenceEdges.cs new file mode 100644 index 00000000..c1db6e01 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260801164354_AddEvidenceEdges.cs @@ -0,0 +1,108 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + /// + public partial class AddEvidenceEdges : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "evidence_edges", + schema: "tracker_governance", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + from_kind = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + from_id = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + edge_type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + to_kind = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + to_id = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + occurred_at = table.Column(type: "timestamp with time zone", nullable: false), + asserted_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + provenance = table.Column(type: "character varying(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_evidence_edges", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "idx_evidence_edges_from", + schema: "tracker_governance", + table: "evidence_edges", + columns: new[] { "tenant_id", "from_kind", "from_id", "edge_type" }); + + migrationBuilder.CreateIndex( + name: "idx_evidence_edges_to", + schema: "tracker_governance", + table: "evidence_edges", + columns: new[] { "tenant_id", "to_kind", "to_id", "edge_type" }); + + migrationBuilder.CreateIndex( + name: "ux_evidence_edges_identity", + schema: "tracker_governance", + table: "evidence_edges", + columns: new[] { "tenant_id", "from_kind", "from_id", "edge_type", "to_kind", "to_id", "occurred_at" }, + unique: true); + + // ── Backfill (GT-605) ──────────────────────────────────────────────────────────── + // + // `evidence_records.references` es un jsonb `List` cuyo unico lector no-test + // era un `Contains()` lineal. Cada entrada que sea una referencia CANONICA + // (`evidence:///`) se convierte en una arista: el registro de evidencia + // VALIDA la cosa referenciada. Esa es la lectura que la columna ha tenido siempre en + // la practica y la unica que no inventa informacion. + // + // Lo que NO parsea se queda donde esta. Son ids externos opacos —el uso de dedup— y + // no son aristas. Por eso la columna `references` sobrevive a esta migracion como + // proyeccion: borrarla ahora perderia esos ids. Se retira una release despues. + // + // `ON CONFLICT DO NOTHING` sobre el indice de identidad hace la migracion re-ejecutable + // sin duplicar, que es lo que permite reintentarla tras un fallo a medias. + migrationBuilder.Sql($@" + INSERT INTO tracker_governance.evidence_edges + (id, tenant_id, from_kind, from_id, edge_type, to_kind, to_id, + occurred_at, asserted_by, provenance) + SELECT + gen_random_uuid(), + e.tenant_id, + 'evidence-record', + e.id::text, + 'validates', + split_part(substring(r.ref FROM 'evidence://(.*)'), '/', 1), + substring(r.ref FROM 'evidence://[^/]+/(.*)'), + e.submitted_at, + 'evolith_tracker', + 'backfill:ReferencesJson' + FROM tracker_governance.evidence_records e + CROSS JOIN LATERAL jsonb_array_elements_text( + CASE WHEN jsonb_typeof(e.references) = 'array' THEN e.references ELSE '[]'::jsonb END + ) AS r(ref) + WHERE r.ref ~ '^evidence://[^/]+/.+$' + -- El vocabulario es CERRADO: una referencia con un `kind` inventado no es una + -- arista tipada y no entra. Coincide con `EvidenceNodeKind.All`. + AND split_part(substring(r.ref FROM 'evidence://(.*)'), '/', 1) IN + ('adr','agent-turn','artifact','evaluation','evidence-record', + 'gate-decision','initiative','ruleset-rule') + -- Un lazo no aporta travesia y si un ciclo trivial en cada consulta. + AND NOT (split_part(substring(r.ref FROM 'evidence://(.*)'), '/', 1) = 'evidence-record' + AND substring(r.ref FROM 'evidence://[^/]+/(.*)') = e.id::text) + ON CONFLICT DO NOTHING; + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "evidence_edges", + schema: "tracker_governance"); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs index 9430779a..6989fbe4 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs @@ -1413,6 +1413,76 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("artifact_field_schemas", "tracker_governance"); }); + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.EvidenceEdgeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AssertedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("asserted_by"); + + b.Property("EdgeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("edge_type"); + + b.Property("FromId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("from_id"); + + b.Property("FromKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("from_kind"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Provenance") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("provenance"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("to_id"); + + b.Property("ToKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("to_kind"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_from"); + + b.HasIndex("TenantId", "ToKind", "ToId", "EdgeType") + .HasDatabaseName("idx_evidence_edges_to"); + + b.HasIndex("TenantId", "FromKind", "FromId", "EdgeType", "ToKind", "ToId", "OccurredAtUtc") + .IsUnique() + .HasDatabaseName("ux_evidence_edges_identity"); + + b.ToTable("evidence_edges", "tracker_governance"); + }); + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ExceptionRequestRecord", b => { b.Property("Id") diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/EvidenceEdgeRecordConfiguration.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/EvidenceEdgeRecordConfiguration.cs new file mode 100644 index 00000000..c9df9ee6 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/EvidenceEdgeRecordConfiguration.cs @@ -0,0 +1,46 @@ +namespace Tracker.Infrastructure.Persistence.Governance.Configurations; + +/// +/// GT-605 — la tabla `evidence_edges`, escrita contra `EVIDENCE_EDGE_STORAGE_CONTRACT` del +/// contrato publicado por el Core. Los nombres de tabla, columnas e índices son los que esa +/// especificación fija, para que una revisión pueda diferenciarlos línea a línea. +/// +internal sealed class EvidenceEdgeRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("evidence_edges"); + + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasColumnName("id"); + + builder.Property(x => x.TenantId).HasColumnName("tenant_id").IsRequired(); + + builder.Property(x => x.FromKind).HasColumnName("from_kind").IsRequired().HasMaxLength(64); + builder.Property(x => x.FromId).HasColumnName("from_id").IsRequired().HasMaxLength(500); + builder.Property(x => x.EdgeType).HasColumnName("edge_type").IsRequired().HasMaxLength(32); + builder.Property(x => x.ToKind).HasColumnName("to_kind").IsRequired().HasMaxLength(64); + builder.Property(x => x.ToId).HasColumnName("to_id").IsRequired().HasMaxLength(500); + + builder.Property(x => x.OccurredAtUtc).HasColumnName("occurred_at").IsRequired(); + builder.Property(x => x.AssertedBy).HasColumnName("asserted_by").HasMaxLength(200); + builder.Property(x => x.Provenance).HasColumnName("provenance").HasMaxLength(500); + + // Travesía hacia delante: «¿de qué depende esto, a un salto?». + builder.HasIndex(x => new { x.TenantId, x.FromKind, x.FromId, x.EdgeType }) + .HasDatabaseName("idx_evidence_edges_from"); + + // Travesía INVERSA: «¿qué se movió por causa de esta decisión de compuerta / turno de + // agente?». Es la búsqueda que la columna jsonb `List` no puede servir a ningún + // coste, y la que hace falta para la frase que da nombre al gap. + builder.HasIndex(x => new { x.TenantId, x.ToKind, x.ToId, x.EdgeType }) + .HasDatabaseName("idx_evidence_edges_to"); + + // Identidad, espejo de `EvidenceEdge.IdentityKey()`: re-afirmar la misma arista es + // idempotente. Incluye `occurred_at` a propósito — la misma relación afirmada en dos + // instantes son dos hechos del ledger, no uno. + builder.HasIndex(x => new { x.TenantId, x.FromKind, x.FromId, x.EdgeType, x.ToKind, x.ToId, x.OccurredAtUtc }) + .HasDatabaseName("ux_evidence_edges_identity") + .IsUnique(); + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/EvidenceEdgeRecord.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/EvidenceEdgeRecord.cs new file mode 100644 index 00000000..7b307888 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/EvidenceEdgeRecord.cs @@ -0,0 +1,30 @@ +namespace Tracker.Infrastructure.Persistence.Governance.Entities; + +/// +/// GT-605 — fila de la tabla `evidence_edges`. +/// +/// Los extremos se aplanan en cuatro columnas (`from_kind`/`from_id`/`to_kind`/`to_id`) y no +/// en dos jsonb: los cuatro entran en los índices, y un jsonb monolítico los dejaría fuera — que +/// es exactamente por qué la columna `References` no podía servir ninguna consulta salvo un +/// `Contains()` lineal. +/// +/// Lleva porque toda consulta del grafo se acota por +/// tenant; sin el marcador quedaría fuera del filtro global de CD-27. +/// +public sealed class EvidenceEdgeRecord : ITenantScopedRecord +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + + public string FromKind { get; set; } = string.Empty; + public string FromId { get; set; } = string.Empty; + public string EdgeType { get; set; } = string.Empty; + public string ToKind { get; set; } = string.Empty; + public string ToId { get; set; } = string.Empty; + + /// Instante en que la relación pasó a ser cierta. Parte de la identidad de la arista. + public DateTime OccurredAtUtc { get; set; } + + public string? AssertedBy { get; set; } + public string? Provenance { get; set; } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlEvidenceEdgeRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlEvidenceEdgeRepository.cs new file mode 100644 index 00000000..76b24a35 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlEvidenceEdgeRepository.cs @@ -0,0 +1,128 @@ +using Tracker.Domain.Governance.EvidenceGraph; + +namespace Tracker.Infrastructure.Persistence.Governance; + +/// +/// GT-605 — lectura y escritura del grafo de evidencia. +/// +/// Toda consulta filtra por TenantId de forma explícita aunque el filtro global de +/// CD-27 ya cubra esta entidad: ese filtro es defensa en profundidad y no restringe cuando el +/// tenant no es resoluble, que es justo el caso del llamador máquina del Core. +/// +internal sealed class PostgreSqlEvidenceEdgeRepository : IEvidenceEdgeRepository +{ + private readonly TrackerDbContext _context; + + public PostgreSqlEvidenceEdgeRepository(TrackerDbContext context) => _context = context; + + public async Task AddAsync(Guid tenantId, EvidenceEdge edge, CancellationToken ct = default) + { + await _context.EvidenceEdges.AddAsync(ToRecord(tenantId, edge), ct); + } + + /// + /// Carga el sub-grafo alcanzable desde hasta + /// saltos, expandiendo por niveles. + /// + /// Se hace en consultas acotadas y no en un CTE recursivo a + /// propósito: el techo son 5 saltos, cada nivel golpea uno de los dos índices por igualdad, y + /// el resultado es el MISMO conjunto de aristas que + /// recorre en memoria — que es la propiedad que hace comparables las dos implementaciones. + /// Un CTE recursivo seria una tercera semantica que nadie podria contrastar contra el + /// contrato. + /// + public async Task> LoadSubgraphAsync( + Guid tenantId, + EvidenceNodeRef start, + int maxDepth, + string direction = EvidenceTraversalDirection.Outgoing, + IReadOnlyCollection? types = null, + CancellationToken ct = default) + { + var depth = Math.Clamp(maxDepth, 0, EvidenceGraphTraversal.MaxDepth); + var filtrarTipos = types is { Count: > 0 }; + var tiposPermitidos = filtrarTipos ? types!.ToArray() : []; + + var recogidas = new Dictionary(); + var frontera = new List { start }; + var vistos = new HashSet { start.ToCanonicalString() }; + + for (var d = 0; d < depth && frontera.Count > 0; d++) + { + var kinds = frontera.Select(n => n.Kind).Distinct().ToArray(); + var ids = frontera.Select(n => n.Id).Distinct().ToArray(); + + var consulta = _context.EvidenceEdges.IgnoreQueryFilters().Where(x => x.TenantId == tenantId); + + if (filtrarTipos) + { + consulta = consulta.Where(x => tiposPermitidos.Contains(x.EdgeType)); + } + + // El predicado se acota primero por (kind, id) del NIVEL, que es lo que permite a + // Postgres usar el indice; el emparejamiento exacto por par se hace despues en + // memoria, sobre un conjunto ya pequeno. + consulta = direction switch + { + EvidenceTraversalDirection.Incoming => + consulta.Where(x => kinds.Contains(x.ToKind) && ids.Contains(x.ToId)), + EvidenceTraversalDirection.Both => + consulta.Where(x => (kinds.Contains(x.FromKind) && ids.Contains(x.FromId)) + || (kinds.Contains(x.ToKind) && ids.Contains(x.ToId))), + _ => consulta.Where(x => kinds.Contains(x.FromKind) && ids.Contains(x.FromId)), + }; + + var filas = await consulta.ToListAsync(ct); + var siguiente = new List(); + + foreach (var fila in filas) + { + var arista = ToDomain(fila); + + var tocaFrontera = frontera.Any(n => + (direction != EvidenceTraversalDirection.Incoming + && n.Kind == arista.From.Kind && n.Id == arista.From.Id) + || (direction != EvidenceTraversalDirection.Outgoing + && n.Kind == arista.To.Kind && n.Id == arista.To.Id)); + + if (!tocaFrontera) continue; + + recogidas.TryAdd(arista.IdentityKey(), arista); + + foreach (var extremo in new[] { arista.From, arista.To }) + { + if (vistos.Add(extremo.ToCanonicalString())) + { + siguiente.Add(extremo); + } + } + } + + frontera = siguiente; + } + + return [.. recogidas.Values]; + } + + private static EvidenceEdgeRecord ToRecord(Guid tenantId, EvidenceEdge e) => new() + { + Id = Guid.NewGuid(), + TenantId = tenantId, + FromKind = e.From.Kind, + FromId = e.From.Id, + EdgeType = e.Type, + ToKind = e.To.Kind, + ToId = e.To.Id, + OccurredAtUtc = e.OccurredAtUtc, + AssertedBy = e.AssertedBy, + Provenance = e.Provenance, + }; + + private static EvidenceEdge ToDomain(EvidenceEdgeRecord r) => EvidenceEdge.Rehydrate( + EvidenceNodeRef.Rehydrate(r.FromKind, r.FromId), + EvidenceNodeRef.Rehydrate(r.ToKind, r.ToId), + r.EdgeType, + DateTime.SpecifyKind(r.OccurredAtUtc, DateTimeKind.Utc), + r.AssertedBy, + r.Provenance); +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/TrackerDbContext.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/TrackerDbContext.cs index 7358adf9..fdbb01f1 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/TrackerDbContext.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/TrackerDbContext.cs @@ -29,6 +29,9 @@ public sealed class TrackerDbContext : DbContext, IUnitOfWork public DbSet ArtifactFieldSchemas => Set(); public DbSet EvidenceRecords => Set(); + /// GT-605 — el grafo de evidencia, tipado y consultable en ambas direcciones. + public DbSet EvidenceEdges => Set(); + // AR-05 / QA — el contexto de calidad. Las tres entidades implementan `ITenantScopedRecord`, // asi que el filtro global por tenant las cubre automaticamente (ver `ApplyTenantFilters`): // no hace falta —ni conviene— repetir el `WHERE tenant_id` en cada consulta del repositorio. diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Governance/EvidenceEdgeTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/EvidenceEdgeTests.cs new file mode 100644 index 00000000..0ad66ded --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/EvidenceEdgeTests.cs @@ -0,0 +1,168 @@ +using Tracker.Domain.Governance.EvidenceGraph; + +namespace Tracker.Tests.Domain.Governance; + +/// +/// GT-605 — el modelo tipado de arista. +/// +/// Existían dos grafos de evidencia, cada uno sin la mitad del otro: el Core declaraba aristas +/// tipadas que nada persistía, y este repositorio persistía `List<string>` que nada tipaba. +/// Estas pruebas fijan la mitad que faltaba aquí y, sobre todo, la conformidad con el vocabulario +/// que el Core publica — porque un espejo que deriva es el problema otra vez. +/// +public class EvidenceEdgeTests +{ + private static readonly DateTime Cuando = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc); + + private static EvidenceNodeRef Nodo(string kind, string id) => EvidenceNodeRef.Create(kind, id).Value!; + + // ── El vocabulario, contra el schema pineado ───────────────────────────────────────────── + + [Fact] + public void ElVocabularioDeNodos_EsElDelSchemaPublicado_YEnSuOrden() + { + EvidenceNodeKind.All.Should().Equal( + "adr", "agent-turn", "artifact", "evaluation", + "evidence-record", "gate-decision", "initiative", "ruleset-rule"); + } + + [Fact] + public void ElVocabularioDeAristas_EsElDelSchemaPublicado_YEnSuOrden() + { + EvidenceEdgeType.All.Should().Equal("requires", "validates", "blocks", "caused_by"); + } + + [Fact] + public void LasTresNativasDelCoreVanPrimero_AsiAdoptarElContratoEsUnEnsanchamiento() + { + EvidenceEdgeType.All.Take(3).Should().Equal(EvidenceEdgeType.CoreNative, + "si el Core reordena o rompe sus tres, el sha256 del schema cambia y `conform` se pone " + + "rojo antes de que este espejo pueda mentir"); + } + + [Fact] + public void CausedBy_EsLaAdicionQueHaceEscribibleLaFraseDelGap() + { + EvidenceEdgeType.All.Should().Contain(EvidenceEdgeType.CausedBy); + EvidenceEdgeType.CoreNative.Should().NotContain(EvidenceEdgeType.CausedBy, + "ninguna de las tres nativas expresa causacion, y sin causacion la frase «que ADR se " + + "movio POR CAUSA DE que decision de compuerta» no se puede escribir"); + } + + [Fact] + public void UnKindInventado_SeRechaza() + { + EvidenceNodeKind.IsValid("pull-request").Should().BeFalse( + "un `kind` abierto es exactamente como la columna References acabo sin tipar"); + EvidenceNodeRef.Create("pull-request", "42").Error.Should().Be("EvidenceEdge.Node.UnknownKind"); + } + + // ── La forma canónica ──────────────────────────────────────────────────────────────────── + + [Fact] + public void LaFormaCanonicaVaYVuelve() + { + var nodo = Nodo(EvidenceNodeKind.Adr, "ADR-0101"); + nodo.ToCanonicalString().Should().Be("evidence://adr/ADR-0101"); + EvidenceNodeRef.Parse("evidence://adr/ADR-0101").Should().Be(nodo); + } + + [Fact] + public void UnIdConBarras_SobreviveEntero() + { + // El id puede llevar `/` (una ruta de artefacto, por ejemplo). Solo la PRIMERA barra + // separa; partir por todas trocearia el id y la referencia dejaria de resolver. + EvidenceNodeRef.Parse("evidence://artifact/src/apps/foo.ts")!.Id.Should().Be("src/apps/foo.ts"); + } + + [Theory] + [InlineData("EXT-12345")] + [InlineData("https://example.com/x")] + [InlineData("evidence://desconocido/1")] + [InlineData("evidence://adr/")] + [InlineData(null)] + public void LoQueNoEsCanonico_NoParsea_YEsoNoEsUnFallo(string? valor) + { + EvidenceNodeRef.Parse(valor).Should().BeNull( + "son ids externos opacos: no son aristas y la migracion debe DEJARLOS en References"); + } + + // ── La arista ──────────────────────────────────────────────────────────────────────────── + + [Fact] + public void UnLazo_SeRechaza() + { + var n = Nodo(EvidenceNodeKind.Adr, "ADR-0101"); + EvidenceEdge.Create(n, n, EvidenceEdgeType.Requires, Cuando) + .Error.Should().Be("EvidenceEdge.SelfLoop"); + } + + [Fact] + public void UnTipoInventado_SeRechaza() + { + EvidenceEdge.Create(Nodo(EvidenceNodeKind.Adr, "A"), Nodo(EvidenceNodeKind.Initiative, "I"), + "supersedes", Cuando).Error.Should().Be("EvidenceEdge.UnknownType"); + } + + [Fact] + public void LaIdentidadIncluyeElInstante_PorqueDosAfirmacionesSonDosHechos() + { + var a = EvidenceEdge.Create(Nodo(EvidenceNodeKind.Adr, "A"), Nodo(EvidenceNodeKind.Initiative, "I"), + EvidenceEdgeType.CausedBy, Cuando).Value!; + var b = EvidenceEdge.Create(Nodo(EvidenceNodeKind.Adr, "A"), Nodo(EvidenceNodeKind.Initiative, "I"), + EvidenceEdgeType.CausedBy, Cuando.AddSeconds(1)).Value!; + + a.IdentityKey().Should().NotBe(b.IdentityKey(), + "la misma relacion afirmada en dos instantes son dos hechos del ledger, no uno"); + } + + [Fact] + public void LaIdentidadExcluyeLosMetadatosDeLaAfirmacion() + { + var a = EvidenceEdge.Create(Nodo(EvidenceNodeKind.Adr, "A"), Nodo(EvidenceNodeKind.Initiative, "I"), + EvidenceEdgeType.CausedBy, Cuando, "ana", "corr-1").Value!; + var b = EvidenceEdge.Create(Nodo(EvidenceNodeKind.Adr, "A"), Nodo(EvidenceNodeKind.Initiative, "I"), + EvidenceEdgeType.CausedBy, Cuando, "bruno", "corr-2").Value!; + + a.IdentityKey().Should().Be(b.IdentityKey(), + "`assertedBy` y `provenance` son metadatos SOBRE la afirmacion; incluirlos haria que " + + "re-afirmar la misma arista desde otro origen creara un hecho nuevo"); + } + + [Fact] + public void InvertirIntercambiaLosExtremos_PeroConservaElTipo() + { + var e = EvidenceEdge.Create(Nodo(EvidenceNodeKind.Adr, "A"), Nodo(EvidenceNodeKind.GateDecision, "G"), + EvidenceEdgeType.CausedBy, Cuando).Value!; + var i = e.Inverted(); + + i.From.Should().Be(e.To); + i.To.Should().Be(e.From); + i.Type.Should().Be(e.Type, "solo cambia la direccion de recorrido, no lo que la arista significa"); + } + + // ── El backfill desde References ───────────────────────────────────────────────────────── + + [Fact] + public void UnaReferenciaCanonica_SeConvierteEnUnaAristaValidates() + { + var sujeto = Nodo(EvidenceNodeKind.EvidenceRecord, "rec-1"); + var arista = EvidenceEdge.FromLegacyReference(sujeto, "evidence://adr/ADR-0101", Cuando)!; + + arista.Type.Should().Be(EvidenceEdgeType.Validates, + "es lo que la columna ha significado siempre en la practica y la unica lectura que no " + + "inventa informacion"); + arista.From.Should().Be(sujeto); + arista.To.Id.Should().Be("ADR-0101"); + arista.Provenance.Should().Be("backfill:ReferencesJson"); + } + + [Fact] + public void UnaReferenciaOpaca_NoProduceArista() + { + EvidenceEdge.FromLegacyReference( + Nodo(EvidenceNodeKind.EvidenceRecord, "rec-1"), "EXT-12345", Cuando) + .Should().BeNull("los ids externos se quedan en References; por eso la columna " + + "sobrevive una release mas"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Governance/EvidenceGraphTraversalTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/EvidenceGraphTraversalTests.cs new file mode 100644 index 00000000..4cbebb60 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/EvidenceGraphTraversalTests.cs @@ -0,0 +1,151 @@ +using Tracker.Domain.Governance.EvidenceGraph; + +namespace Tracker.Tests.Domain.Governance; + +/// +/// GT-605 — la travesía acotada. +/// +/// La consulta que da nombre al gap —«qué ADR se movió por causa de qué decisión de compuerta por +/// causa de qué turno de agente»— es una travesía INVERSA de profundidad 3. Estas pruebas la +/// escriben y, con ella, las garantías que el contrato publicado exige: anchura primero, cada nodo +/// una vez, y los ciclos terminan. +/// +public class EvidenceGraphTraversalTests +{ + private static readonly DateTime T = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc); + + private static EvidenceNodeRef N(string kind, string id) => EvidenceNodeRef.Create(kind, id).Value!; + + private static EvidenceEdge E(EvidenceNodeRef from, EvidenceNodeRef to, string type) => + EvidenceEdge.Create(from, to, type, T).Value!; + + /// + /// La cadena del criterio de aceptación, montada tal cual: un turno de agente causa una + /// decisión de compuerta, que causa el movimiento de un ADR. + /// + private static (EvidenceEdge[] Aristas, EvidenceNodeRef Turno, EvidenceNodeRef Adr) Cadena() + { + var turno = N(EvidenceNodeKind.AgentTurn, "turn-1"); + var compuerta = N(EvidenceNodeKind.GateDecision, "gate-1"); + var adr = N(EvidenceNodeKind.Adr, "ADR-0101"); + + return ( + [ + E(compuerta, turno, EvidenceEdgeType.CausedBy), + E(adr, compuerta, EvidenceEdgeType.CausedBy), + ], + turno, adr); + } + + [Fact] + public void LaFraseDelGap_TieneRespuesta() + { + var (aristas, turno, adr) = Cadena(); + + // Desde el turno, hacia atrás: qué se movió por causa de él. + var alcanzados = EvidenceGraphTraversal.Traverse( + aristas, turno, maxDepth: 3, EvidenceTraversalDirection.Incoming); + + alcanzados.Select(h => h.Node.Id).Should().Contain(adr.Id, + "esta es la travesia que el gap llama la mitad fuerte del foso, y la que la columna " + + "jsonb `List` no podia servir a ningun coste"); + + var hastaElAdr = alcanzados.Single(h => h.Node.Id == adr.Id); + hastaElAdr.Depth.Should().Be(2); + hastaElAdr.Path.Should().AllSatisfy(a => a.Type.Should().Be(EvidenceEdgeType.CausedBy)); + } + + [Fact] + public void SinLaBusquedaInversa_LaPreguntaNoSeResponde() + { + var (aristas, turno, adr) = Cadena(); + + var haciaDelante = EvidenceGraphTraversal.Traverse( + aristas, turno, maxDepth: 3, EvidenceTraversalDirection.Outgoing); + + haciaDelante.Select(h => h.Node.Id).Should().NotContain(adr.Id, + "la direccion importa: preguntar hacia delante desde el turno no responde que se movio " + + "POR CAUSA de el — por eso `idx_evidence_edges_to` existe"); + } + + [Fact] + public void LaCotaDeProfundidadCorta() + { + var (aristas, turno, adr) = Cadena(); + + EvidenceGraphTraversal.Traverse(aristas, turno, maxDepth: 1, EvidenceTraversalDirection.Incoming) + .Select(h => h.Node.Id).Should().NotContain(adr.Id, + "el ADR esta a dos saltos; una cota de 1 no puede alcanzarlo"); + } + + [Fact] + public void ProfundidadCero_DevuelveSoloElNodoDePartida() + { + var (aristas, turno, _) = Cadena(); + EvidenceGraphTraversal.Traverse(aristas, turno, maxDepth: 0).Should().ContainSingle() + .Which.Node.Should().Be(turno); + } + + [Fact] + public void LaProfundidadSeRecortaAlTecho_NoSeRechaza() + { + var (aristas, turno, _) = Cadena(); + + var act = () => EvidenceGraphTraversal.Traverse(aristas, turno, maxDepth: 9999); + + act.Should().NotThrow("el techo protege a la base de datos, no al llamante: pedir de mas " + + "devuelve el maximo, no un error"); + } + + [Fact] + public void UnCicloTermina() + { + var a = N(EvidenceNodeKind.Adr, "A"); + var b = N(EvidenceNodeKind.Initiative, "B"); + var aristas = new[] { E(a, b, EvidenceEdgeType.Requires), E(b, a, EvidenceEdgeType.Requires) }; + + var alcanzados = EvidenceGraphTraversal.Traverse(aristas, a, maxDepth: EvidenceGraphTraversal.MaxDepth); + + alcanzados.Should().HaveCount(2, "cada nodo aparece a lo sumo una vez, asi que el ciclo no " + + "puede hacer crecer el resultado"); + } + + [Fact] + public void AnchuraPrimero_LaPrimeraVezQueSeAlcanzaUnNodoEsPorElCaminoMasCorto() + { + var inicio = N(EvidenceNodeKind.Initiative, "I"); + var medio = N(EvidenceNodeKind.GateDecision, "G"); + var destino = N(EvidenceNodeKind.Adr, "A"); + + var aristas = new[] + { + E(inicio, medio, EvidenceEdgeType.Requires), + E(medio, destino, EvidenceEdgeType.Requires), + E(inicio, destino, EvidenceEdgeType.Requires), // atajo de un salto + }; + + EvidenceGraphTraversal.Traverse(aristas, inicio, maxDepth: 3) + .Single(h => h.Node.Id == "A").Depth.Should().Be(1); + } + + [Fact] + public void ElFiltroDeTiposRestringe() + { + var (aristas, turno, adr) = Cadena(); + + EvidenceGraphTraversal.Traverse( + aristas, turno, maxDepth: 3, EvidenceTraversalDirection.Incoming, + types: [EvidenceEdgeType.Validates]) + .Select(h => h.Node.Id).Should().NotContain(adr.Id, + "restringir a `validates` no puede alcanzar nada por `caused_by`"); + } + + [Fact] + public void AmbasDirecciones_AlcanzanLoQueCadaUnaPorSeparado() + { + var (aristas, turno, adr) = Cadena(); + + EvidenceGraphTraversal.Traverse(aristas, turno, maxDepth: 3, EvidenceTraversalDirection.Both) + .Select(h => h.Node.Id).Should().Contain(adr.Id); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/EvidenceEdgePersistenceTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/EvidenceEdgePersistenceTests.cs new file mode 100644 index 00000000..c2d7894e --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/EvidenceEdgePersistenceTests.cs @@ -0,0 +1,189 @@ +using Microsoft.EntityFrameworkCore; +using Tracker.Domain.Governance.EvidenceGraph; +using Tracker.Infrastructure.Persistence; +using Tracker.Infrastructure.Persistence.Governance; + +namespace Tracker.Tests.Infrastructure.Persistence; + +/// +/// GT-605 — la mitad que sólo Postgres puede responder. +/// +/// Lo que se prueba aquí no se puede probar con un doble en memoria: que el índice de identidad +/// hace idempotente re-afirmar una arista, que la búsqueda INVERSA existe de verdad, y —lo más +/// importante— que el sub-grafo que el repositorio carga con SQL produce el MISMO resultado que +/// la travesía en memoria del contrato. Dos implementaciones de una consulta de grafo sólo se +/// mantienen iguales si algo las compara. +/// +/// Sigue el patrón de los *LiveTests: si Postgres no está disponible la prueba se salta. +/// +public sealed class EvidenceEdgePersistenceTests +{ + private const string ConnectionString = + "Host=localhost;Port=5432;Database=evolith_tracker;Username=evolith;Password=localdev"; + + private static readonly DateTime T = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc); + + private static TrackerDbContext? TryOpen() + { + var db = new TrackerDbContext( + new DbContextOptionsBuilder().UseNpgsql(ConnectionString).Options); + try + { + return db.Database.CanConnect() ? db : null; + } + catch + { + return null; + } + } + + private static EvidenceNodeRef N(string kind, string id) => EvidenceNodeRef.Create(kind, id).Value!; + + private static EvidenceEdge E(EvidenceNodeRef from, EvidenceNodeRef to, string type, DateTime? when = null) => + EvidenceEdge.Create(from, to, type, when ?? T).Value!; + + [Fact] + public async Task ReAfirmarLaMismaArista_LaBaseLoImpide() + { + using var db = TryOpen(); + if (db is null) return; + + var repo = new PostgreSqlEvidenceEdgeRepository(db); + var tenantId = Guid.NewGuid(); + var sufijo = Guid.NewGuid().ToString("N"); + + var arista = E(N(EvidenceNodeKind.Adr, $"ADR-{sufijo}"), + N(EvidenceNodeKind.GateDecision, $"gate-{sufijo}"), EvidenceEdgeType.CausedBy); + + await repo.AddAsync(tenantId, arista); + await db.SaveChangesAsync(); + + await repo.AddAsync(tenantId, arista); + var act = async () => await db.SaveChangesAsync(); + + await act.Should().ThrowAsync( + "`ux_evidence_edges_identity` es lo que hace idempotente re-afirmar una arista; sin el, " + + "cada reintento de una ingesta duplicaria el ledger"); + } + + [Fact] + public async Task LaMismaRelacionEnDosInstantes_SonDosHechos() + { + using var db = TryOpen(); + if (db is null) return; + + var repo = new PostgreSqlEvidenceEdgeRepository(db); + var tenantId = Guid.NewGuid(); + var sufijo = Guid.NewGuid().ToString("N"); + var a = N(EvidenceNodeKind.Adr, $"ADR-{sufijo}"); + var b = N(EvidenceNodeKind.GateDecision, $"gate-{sufijo}"); + + await repo.AddAsync(tenantId, E(a, b, EvidenceEdgeType.CausedBy, T)); + await repo.AddAsync(tenantId, E(a, b, EvidenceEdgeType.CausedBy, T.AddMinutes(1))); + + var act = async () => await db.SaveChangesAsync(); + + await act.Should().NotThrowAsync( + "`occurred_at` entra en la identidad a proposito: la misma relacion afirmada en dos " + + "instantes son dos hechos del ledger y no uno"); + } + + /// + /// El corazón del gap: la frase «qué ADR se movió por causa de qué decisión de compuerta por + /// causa de qué turno de agente», resuelta contra la base real y comparada con la travesía en + /// memoria del contrato. + /// + [Fact] + public async Task LaTravesiaInversa_CoincideConLaDelContrato() + { + using var db = TryOpen(); + if (db is null) return; + + var repo = new PostgreSqlEvidenceEdgeRepository(db); + var tenantId = Guid.NewGuid(); + var sufijo = Guid.NewGuid().ToString("N"); + + var turno = N(EvidenceNodeKind.AgentTurn, $"turn-{sufijo}"); + var compuerta = N(EvidenceNodeKind.GateDecision, $"gate-{sufijo}"); + var adr = N(EvidenceNodeKind.Adr, $"ADR-{sufijo}"); + + var enMemoria = new[] + { + E(compuerta, turno, EvidenceEdgeType.CausedBy), + E(adr, compuerta, EvidenceEdgeType.CausedBy), + }; + + foreach (var arista in enMemoria) + { + await repo.AddAsync(tenantId, arista); + } + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + + var desdeLaBase = await repo.LoadSubgraphAsync( + tenantId, turno, maxDepth: 3, EvidenceTraversalDirection.Incoming); + + // 1) El SQL trajo exactamente las aristas de la cadena. + desdeLaBase.Select(a => a.IdentityKey()).Should() + .BeEquivalentTo(enMemoria.Select(a => a.IdentityKey()), + "si el SQL trae de mas o de menos, la travesia respondera distinto que el contrato " + + "aunque la funcion de travesia sea la misma"); + + // 2) Y la travesía sobre ellas responde la pregunta del gap. + var alcanzados = EvidenceGraphTraversal.Traverse( + desdeLaBase, turno, maxDepth: 3, EvidenceTraversalDirection.Incoming); + + alcanzados.Select(h => h.Node.Id).Should().Contain(adr.Id); + alcanzados.Single(h => h.Node.Id == adr.Id).Depth.Should().Be(2); + } + + [Fact] + public async Task ElSubgrafoEstaAcotadoPorTenant() + { + using var db = TryOpen(); + if (db is null) return; + + var repo = new PostgreSqlEvidenceEdgeRepository(db); + var mio = Guid.NewGuid(); + var ajeno = Guid.NewGuid(); + var sufijo = Guid.NewGuid().ToString("N"); + + var a = N(EvidenceNodeKind.Adr, $"ADR-{sufijo}"); + var b = N(EvidenceNodeKind.GateDecision, $"gate-{sufijo}"); + + await repo.AddAsync(ajeno, E(a, b, EvidenceEdgeType.CausedBy)); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + + var visto = await repo.LoadSubgraphAsync(mio, a, maxDepth: 2); + + visto.Should().BeEmpty( + "un grafo de evidencia que cruzara tenants filtraria que decidio quien en otro cliente"); + } + + [Fact] + public async Task LaCotaDeProfundidadLimitaLoQueSeCarga() + { + using var db = TryOpen(); + if (db is null) return; + + var repo = new PostgreSqlEvidenceEdgeRepository(db); + var tenantId = Guid.NewGuid(); + var sufijo = Guid.NewGuid().ToString("N"); + + var n1 = N(EvidenceNodeKind.Initiative, $"i-{sufijo}"); + var n2 = N(EvidenceNodeKind.GateDecision, $"g-{sufijo}"); + var n3 = N(EvidenceNodeKind.Adr, $"a-{sufijo}"); + + await repo.AddAsync(tenantId, E(n1, n2, EvidenceEdgeType.Requires)); + await repo.AddAsync(tenantId, E(n2, n3, EvidenceEdgeType.Requires)); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + + var unSalto = await repo.LoadSubgraphAsync(tenantId, n1, maxDepth: 1); + + unSalto.Should().ContainSingle( + "la cota no es una optimizacion: el ledger es append-only y una travesia sin limite es " + + "trabajo sin limite sobre una tabla que solo crece"); + } +} From efce3a0b8321edfe28d9cf007177ff55210087dc Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sat, 1 Aug 2026 12:07:39 -0500 Subject: [PATCH 3/5] feat(evidence): exponer la travesia del grafo, sin reimplementarla (GT-605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/v1/initiatives/{id}/evidence-graph`, con `depth` (2 por defecto, techo 5), `direction` y `type` repetible — la firma que fija `EVIDENCE_EDGE_STORAGE_CONTRACT`. La decision que gobierna el diseno: el endpoint NO recorre el grafo. Carga aristas por el repositorio y se las pasa a `EvidenceGraphTraversal.Traverse`, la misma funcion que los tests comparan contra el contrato publicado por el Core. Un endpoint que recorriera por su cuenta habria creado una TERCERA semantica de travesia —SQL, contrato, HTTP— sin nada que garantice que las tres coinciden, que es exactamente el fallo que este gap cierra. El camino viaja con cada nodo. Sin el, la respuesta dice «este ADR esta relacionado» pero no POR QUE, y el porque es la mitad que el gap reclama. Dos codigos distintos para dos errores que parecen el mismo: · Pedir profundidad 500 devuelve 200 con la profundidad recortada al techo. El limite protege a la base de datos, no al llamante; un 400 obligaria a cada cliente a conocer un numero que puede cambiar y no haria la consulta mas barata. · Pedir `type=supersedes` devuelve 400. Filtrar en silencio a las cuatro conocidas daria un grafo que PARECE completo y no lo es. Un falso verde encontrado y corregido por el camino: `DatabaseAvailable` se asignaba en `ConfigureWebHost`, que no corre hasta que se construye el host —o sea, DESPUES de que el test lea la propiedad—. Era `false` siempre: los siete tests salian por el `return` temprano y pasaban en 5 ms sin ejercitar nada. Ahora se sondea perezosamente; tardan 1 s y dejan 21 filas en la base, que es la comprobacion de que tocan algo. Verificado contra Postgres 16 real: 7 tests de endpoint —incluido el que rehidrata las aristas que la respuesta devuelve y afirma que el recorrido en memoria da el MISMO conjunto de nodos— y la suite completa en 1151/1151. Co-Authored-By: Claude Opus 5 --- .../TrackerApiApplicationBuilderExtensions.cs | 1 + .../Governance/EvidenceGraphEndpoints.cs | 154 +++++++++ .../Integration/EvidenceGraphEndpointTests.cs | 298 ++++++++++++++++++ 3 files changed, 453 insertions(+) create mode 100644 src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/EvidenceGraphEndpoints.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Presentation/Integration/EvidenceGraphEndpointTests.cs diff --git a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs index df2a53d6..31207e8c 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs @@ -90,6 +90,7 @@ public static WebApplication MapTrackerApiSurface(this WebApplication app) api.MapUmsDirectoryEndpoints(); api.MapPhaseGateEndpoints(); api.MapRuntimeApprovalEndpoints(); + api.MapEvidenceGraphEndpoints(); api.MapExceptionRequestEndpoints(); api.MapTenantEndpoints(); api.MapTenantIntelligenceEndpoints(); diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/EvidenceGraphEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/EvidenceGraphEndpoints.cs new file mode 100644 index 00000000..71808b1a --- /dev/null +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/EvidenceGraphEndpoints.cs @@ -0,0 +1,154 @@ +using Tracker.Domain.Governance.EvidenceGraph; + +namespace Tracker.Presentation.Endpoints.Governance; + +/// +/// GT-605 — la travesía acotada del grafo de evidencia, expuesta. +/// +/// Es la superficie que responde la frase del gap: «qué ADR se movió por causa de qué +/// decisión de compuerta por causa de qué turno de agente». Requiere direction=incoming y +/// profundidad ≥ 2, que es por lo que ninguno de los dos es un detalle de la query string sino +/// parte del contrato. +/// +/// La respuesta la produce —la misma función +/// que los tests comparan contra el contrato publicado por el Core— sobre las aristas que el +/// repositorio carga. El endpoint NO reimplementa el recorrido: si lo hiciera, habría una tercera +/// semántica de travesía y nada que garantice que las tres coinciden. +/// +public static class EvidenceGraphEndpoints +{ + public static void MapEvidenceGraphEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/initiatives").WithTags("Governance"); + + group.MapGet("/{initiativeId:guid}/evidence-graph", async ( + Guid initiativeId, + int? depth, + string? direction, + string[]? type, + ITrackerUserContext user, + IEvidenceEdgeRepository edges, + CancellationToken ct) => + { + // El techo se aplica en silencio a propósito: protege a la base de datos, no al + // llamante. Devolver 400 por pedir profundidad 50 obligaría a cada cliente a conocer + // un límite que puede cambiar, y no hace la consulta más barata. + var profundidad = Math.Clamp( + depth ?? EvidenceGraphTraversal.DefaultDepth, 0, EvidenceGraphTraversal.MaxDepth); + + var sentido = direction ?? EvidenceTraversalDirection.Outgoing; + if (!EvidenceTraversalDirection.IsValid(sentido)) + { + return Results.BadRequest(new { error = "EvidenceGraph.UnknownDirection" }); + } + + // Un tipo desconocido SÍ es un 400, a diferencia de la profundidad: pedir aristas + // `supersedes` y recibir un grafo filtrado en silencio a las cuatro conocidas sería + // una respuesta que parece completa y no lo es. + var tipos = type ?? []; + foreach (var t in tipos) + { + if (!EvidenceEdgeType.IsValid(t)) + { + return Results.BadRequest(new { error = "EvidenceGraph.UnknownEdgeType", type = t }); + } + } + + var inicio = EvidenceNodeRef.Create(EvidenceNodeKind.Initiative, initiativeId.ToString()); + if (inicio.IsFailure) + { + return Results.BadRequest(new { error = inicio.Error }); + } + + var subgrafo = await edges.LoadSubgraphAsync( + user.TenantId, inicio.Value!, profundidad, sentido, tipos, ct); + + var alcanzados = EvidenceGraphTraversal.Traverse( + subgrafo, inicio.Value!, profundidad, sentido, tipos); + + return Results.Ok(new EvidenceGraphResponse + { + Start = ToNodeDto(inicio.Value!), + Depth = profundidad, + Direction = sentido, + Nodes = [.. alcanzados.Select(h => new EvidenceGraphNodeDto + { + Kind = h.Node.Kind, + Id = h.Node.Id, + Ref = h.Node.ToCanonicalString(), + Depth = h.Depth, + // El CAMINO viaja con cada nodo: sin él la respuesta dice «este ADR está + // relacionado» y no POR QUÉ, que es justo la mitad que el gap reclama. + Path = [.. h.Path.Select(ToEdgeDto)], + })], + Edges = [.. subgrafo.Select(ToEdgeDto)], + }); + }) + .WithName("GetInitiativeEvidenceGraph") + .RequireTrackerPermission(TrackerPermissions.EvidenceRead) + .Produces(200) + .Produces(400) + .Produces(401) + .Produces(403); + } + + private static EvidenceGraphNodeRefDto ToNodeDto(EvidenceNodeRef n) => new() + { + Kind = n.Kind, + Id = n.Id, + Ref = n.ToCanonicalString(), + }; + + private static EvidenceGraphEdgeDto ToEdgeDto(EvidenceEdge e) => new() + { + From = ToNodeDto(e.From), + To = ToNodeDto(e.To), + Type = e.Type, + OccurredAt = e.OccurredAtUtc, + AssertedBy = e.AssertedBy, + Provenance = e.Provenance, + }; +} + +public class EvidenceGraphNodeRefDto +{ + public string Kind { get; init; } = string.Empty; + public string Id { get; init; } = string.Empty; + + /// La forma canónica `evidence://kind/id`, para que el cliente no la reconstruya. + public string Ref { get; init; } = string.Empty; +} + +/// +/// Espeja `EvidenceEdge` del contrato publicado por el Core, con sus mismos nombres. +/// +public sealed class EvidenceGraphEdgeDto +{ + public EvidenceGraphNodeRefDto From { get; init; } = new(); + public EvidenceGraphNodeRefDto To { get; init; } = new(); + public string Type { get; init; } = string.Empty; + public DateTime OccurredAt { get; init; } + public string? AssertedBy { get; init; } + public string? Provenance { get; init; } +} + +public sealed class EvidenceGraphNodeDto : EvidenceGraphNodeRefDto +{ + /// Saltos desde el nodo de partida; el propio nodo de partida es 0. + public int Depth { get; init; } + + /// Aristas recorridas desde el inicio hasta este nodo, en orden. Vacío en profundidad 0. + public IReadOnlyList Path { get; init; } = []; +} + +public sealed class EvidenceGraphResponse +{ + public EvidenceGraphNodeRefDto Start { get; init; } = new(); + + /// La profundidad EFECTIVA, ya recortada al techo — no la que pidió el cliente. + public int Depth { get; init; } + + public string Direction { get; init; } = string.Empty; + public IReadOnlyList Nodes { get; init; } = []; + public IReadOnlyList Edges { get; init; } = []; +} diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/EvidenceGraphEndpointTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/EvidenceGraphEndpointTests.cs new file mode 100644 index 00000000..9d5770cd --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/EvidenceGraphEndpointTests.cs @@ -0,0 +1,298 @@ +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Tracker.Domain.Governance.EvidenceGraph; +using Tracker.Infrastructure.Persistence; +using Tracker.Infrastructure.Persistence.Governance; +using Tracker.Infrastructure.Services.Tenancy; + +namespace Tracker.Tests.Presentation.Integration; + +/// +/// GT-605 — la travesía por HTTP. +/// +/// La propiedad que sólo se puede afirmar aquí, a nivel de superficie: que la respuesta del +/// endpoint coincide con sobre el mismo conjunto de aristas. +/// El endpoint no reimplementa el recorrido a propósito — si lo hiciera habría una tercera +/// semántica de travesía (SQL, contrato, HTTP) y nada que garantice que las tres coinciden. +/// +[Collection(WebHostCollection.Name)] +public sealed class EvidenceGraphEndpointTests : IClassFixture +{ + private readonly EvidenceGraphWebApplicationFactory _factory; + + public EvidenceGraphEndpointTests(EvidenceGraphWebApplicationFactory factory) => _factory = factory; + + private static readonly DateTime T = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc); + + private static EvidenceNodeRef N(string kind, string id) => EvidenceNodeRef.Create(kind, id).Value!; + + /// + /// Siembra la cadena del criterio de aceptación para una iniciativa y devuelve su id: el turno + /// de agente causa la decisión de compuerta, que causa el movimiento del ADR. + /// + private async Task<(Guid InitiativeId, string AdrId)> SembrarCadenaAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var repo = new PostgreSqlEvidenceEdgeRepository(db); + + var iniciativa = Guid.NewGuid(); + var sufijo = Guid.NewGuid().ToString("N"); + var adrId = $"ADR-{sufijo}"; + + var i = N(EvidenceNodeKind.Initiative, iniciativa.ToString()); + var turno = N(EvidenceNodeKind.AgentTurn, $"turn-{sufijo}"); + var compuerta = N(EvidenceNodeKind.GateDecision, $"gate-{sufijo}"); + var adr = N(EvidenceNodeKind.Adr, adrId); + + foreach (var arista in new[] + { + EvidenceEdge.Create(i, compuerta, EvidenceEdgeType.Requires, T).Value!, + EvidenceEdge.Create(compuerta, turno, EvidenceEdgeType.CausedBy, T).Value!, + EvidenceEdge.Create(adr, compuerta, EvidenceEdgeType.CausedBy, T).Value!, + }) + { + await repo.AddAsync(EvidenceGraphWebApplicationFactory.TenantId, arista); + } + + await db.SaveChangesAsync(); + return (iniciativa, adrId); + } + + private HttpClient Cliente() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-id", EvidenceGraphWebApplicationFactory.TenantId.ToString()); + return client; + } + + [Fact] + public async Task DevuelveElSubgrafoConSusCaminos() + { + if (!_factory.DatabaseAvailable) return; + + var (iniciativa, _) = await SembrarCadenaAsync(); + using var client = Cliente(); + + var response = await client.GetAsync($"/api/v1/initiatives/{iniciativa}/evidence-graph?depth=2"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(); + body!.Start.Kind.Should().Be(EvidenceNodeKind.Initiative); + body.Depth.Should().Be(2); + body.Nodes.Should().Contain(n => n.Kind == EvidenceNodeKind.GateDecision); + + var compuerta = body.Nodes.Single(n => n.Kind == EvidenceNodeKind.GateDecision); + compuerta.Ref.Should().StartWith("evidence://gate-decision/", + "la forma canonica viaja resuelta para que el cliente no la reconstruya"); + compuerta.Path.Should().NotBeEmpty( + "sin el CAMINO la respuesta dice «esto esta relacionado» pero no POR QUE, que es " + + "justo la mitad que el gap reclama"); + } + + [Fact] + public async Task LaProfundidadPorDefectoEsDos() + { + if (!_factory.DatabaseAvailable) return; + + var (iniciativa, _) = await SembrarCadenaAsync(); + using var client = Cliente(); + + var body = await (await client.GetAsync($"/api/v1/initiatives/{iniciativa}/evidence-graph")) + .Content.ReadFromJsonAsync(); + + body!.Depth.Should().Be(EvidenceGraphTraversal.DefaultDepth); + } + + [Fact] + public async Task PedirMasProfundidadQueElTecho_DevuelveElTecho_NoUnError() + { + if (!_factory.DatabaseAvailable) return; + + var (iniciativa, _) = await SembrarCadenaAsync(); + using var client = Cliente(); + + var response = await client.GetAsync($"/api/v1/initiatives/{iniciativa}/evidence-graph?depth=500"); + + response.StatusCode.Should().Be(HttpStatusCode.OK, + "el techo protege a la base de datos, no al llamante; un 400 obligaria a cada cliente " + + "a conocer un limite que puede cambiar y no haria la consulta mas barata"); + + var body = await response.Content.ReadFromJsonAsync(); + body!.Depth.Should().Be(EvidenceGraphTraversal.MaxDepth); + } + + [Fact] + public async Task UnTipoDeAristaDesconocido_EsUnCuatrocientos() + { + if (!_factory.DatabaseAvailable) return; + + var (iniciativa, _) = await SembrarCadenaAsync(); + using var client = Cliente(); + + var response = await client.GetAsync( + $"/api/v1/initiatives/{iniciativa}/evidence-graph?type=supersedes"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, + "a diferencia de la profundidad: filtrar en silencio a las cuatro conocidas devolveria " + + "un grafo que parece completo y no lo es"); + } + + [Fact] + public async Task UnaDireccionDesconocida_EsUnCuatrocientos() + { + if (!_factory.DatabaseAvailable) return; + + var (iniciativa, _) = await SembrarCadenaAsync(); + using var client = Cliente(); + + (await client.GetAsync($"/api/v1/initiatives/{iniciativa}/evidence-graph?direction=sideways")) + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + /// + /// La propiedad central: HTTP y el contrato responden lo mismo sobre las mismas aristas. + /// + [Fact] + public async Task LaRespuestaHttpCoincideConLaTravesiaDelContrato() + { + if (!_factory.DatabaseAvailable) return; + + var (iniciativa, _) = await SembrarCadenaAsync(); + using var client = Cliente(); + + var body = await (await client.GetAsync( + $"/api/v1/initiatives/{iniciativa}/evidence-graph?depth=3&direction=both")) + .Content.ReadFromJsonAsync(); + + // Se rehidratan las aristas que el endpoint devolvió y se recorre en memoria. + var aristas = body!.Edges.Select(e => EvidenceEdge.Rehydrate( + EvidenceNodeRef.Rehydrate(e.From.Kind, e.From.Id), + EvidenceNodeRef.Rehydrate(e.To.Kind, e.To.Id), + e.Type, e.OccurredAt, e.AssertedBy, e.Provenance)).ToList(); + + var esperado = EvidenceGraphTraversal.Traverse( + aristas, N(EvidenceNodeKind.Initiative, iniciativa.ToString()), + maxDepth: 3, EvidenceTraversalDirection.Both); + + body.Nodes.Select(n => n.Ref).OrderBy(x => x).Should() + .Equal(esperado.Select(h => h.Node.ToCanonicalString()).OrderBy(x => x), + "el endpoint NO reimplementa el recorrido; si divergiera, habria una tercera " + + "semantica de travesia y nada que garantice que las tres coinciden"); + } + + [Fact] + public async Task ElGrafoEstaAcotadoPorTenant() + { + if (!_factory.DatabaseAvailable) return; + + var (iniciativa, _) = await SembrarCadenaAsync(); + + using var otro = _factory.CreateClient(); + otro.DefaultRequestHeaders.Add("x-tenant-id", Guid.NewGuid().ToString()); + + var body = await (await otro.GetAsync($"/api/v1/initiatives/{iniciativa}/evidence-graph")) + .Content.ReadFromJsonAsync(); + + body!.Nodes.Should().ContainSingle( + "otro tenant solo ve el nodo de partida: un grafo de evidencia que cruzara tenants " + + "filtraria que decidio quien en otro cliente"); + } + + private class NodeRefBody + { + public string Kind { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + public string Ref { get; set; } = string.Empty; + } + + private sealed class EdgeBody + { + public NodeRefBody From { get; set; } = new(); + public NodeRefBody To { get; set; } = new(); + public string Type { get; set; } = string.Empty; + public DateTime OccurredAt { get; set; } + public string? AssertedBy { get; set; } + public string? Provenance { get; set; } + } + + private sealed class NodeBody : NodeRefBody + { + public int Depth { get; set; } + public List Path { get; set; } = []; + } + + private sealed class GraphBody + { + public NodeRefBody Start { get; set; } = new(); + public int Depth { get; set; } + public string Direction { get; set; } = string.Empty; + public List Nodes { get; set; } = []; + public List Edges { get; set; } = []; + } +} + +/// +/// Development por el `dev-bypass`: estas pruebas afirman sobre la travesía, no sobre el perímetro +/// —que ya tiene sus propias pruebas—. +/// +public sealed class EvidenceGraphWebApplicationFactory : WebApplicationFactory +{ + public static readonly Guid TenantId = Guid.NewGuid(); + + private const string ConnectionString = + "Host=localhost;Port=5432;Database=evolith_tracker;Username=evolith;Password=localdev;Timeout=5"; + + /// + /// Se sondea PEREZOSAMENTE y no desde ConfigureWebHost. La versión anterior lo asignaba + /// allí, y `ConfigureWebHost` no corre hasta que se construye el host — es decir, DESPUÉS de + /// que el test lea la propiedad. El resultado era `false` siempre: los siete tests salían por + /// el `return` temprano y pasaban en 5 ms sin ejercitar nada. Un verde que no prueba nada es + /// peor que un rojo. + /// + private readonly Lazy _dbProbe = new(() => + { + try + { + using var probe = new TrackerDbContext( + new DbContextOptionsBuilder().UseNpgsql(ConnectionString).Options); + return probe.Database.CanConnect(); + } + catch + { + return false; + } + }); + + public bool DatabaseAvailable => _dbProbe.Value; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = ConnectionString, + }); + }); + + 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); + } + }); + } +} From 6fcb7a9fdc756fee0f603a542ef0a53d1bb5e76f Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sat, 1 Aug 2026 12:38:47 -0500 Subject: [PATCH 4/5] =?UTF-8?q?feat(transparency):=20el=20cable=20de=20fir?= =?UTF-8?q?ma=20=E2=80=94=20toda=20decision=20asentada=20emite=20un=20stat?= =?UTF-8?q?ement=20verificable=20(GT-588)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La maquinaria de firma existia y estaba probada. Lo que no existia era nada que la invocara: `AuditService` recibia el grabador como parametro OPCIONAL y `new AuditService(...)` no aparecia fuera de tests. La regla `AUD-TRANSP` castigaba un ledger que no verifica mientras nadie emitia ninguno. Esto es ese cable, en el Tracker — que es donde ya viven las decisiones de compuerta y el expediente, y donde el Core no puede estar porque ADR-0101 lo define sin estado. COMO ENGANCHA. Un DECORADOR de `IAuditEntryRepository`, no un cambio en cada llamante. Los caminos que asientan decisiones —evaluacion de compuerta, registro de aprobacion, publicacion de decision, turno de agente— ya pasan todos por esa interfaz. Enchufar ahi firma los cuatro sin tocar ninguno, y firma tambien el quinto que alguien anada manana sin acordarse de esta ficha. LO QUE EL DECORADOR NO HACE: tumbar la operacion si la firma falla. Una decision de gobierno ya tomada no puede perderse porque el volumen del ledger no este montado; eso convertiria una garantia de auditoria en un punto de caida del producto. La ausencia se nota despues y con dientes — `AUD-TRANSP-01` pone en rojo un ledger inexistente o vacio. INTEROPERABILIDAD, que es lo dificil. El Tracker firma en C# y quien verifica es `evolith audit verify`, escrito en TypeScript. Entre ambos hay cuatro capas donde un solo byte de diferencia lo rompe todo: JSON canonico (claves ordenadas en toda profundidad, `StringComparer.Ordinal` para que un locale distinto no reordene), CBOR determinista (`CborConformanceMode.Canonical`, que ordena las entradas de mapa por los bytes de la clave igual que su codificador), cabeceras COSE_Sign1 con los claims CWT `iss`/`sub` y `vds`=1, y el arbol de Merkle SHA-256 de RFC 9162. VERIFICADO CONTRA EL CLI REAL, no contra estructuras C#. Los tests generan un ledger y se lo dan a `evolith audit verify`: veredicto PASS, `Receipts verify`. Y las dos direcciones que importan mas — editar un veredicto sin volver a firmar, y borrar una entrada del MEDIO (cada linea restante sigue firmada; lo que no cuadra es la raiz del arbol, que es para lo que el arbol esta) — ponen el ledger en rojo. Un hallazgo por el camino: la primera version firmaba con `CreateDevelopment` y esperaba verde. El CLI reporto `Receipts verify` —la interoperabilidad era correcta a la primera— y aun asi `Verdict FAIL`, porque `AUD-TRANSP-04` rechaza una clave que el proceso se acuno a si mismo. La regla tenia razon y la expectativa no. Queda como test propio. DESACTIVADO POR DEFECTO. Activarlo cambia lo que el producto promete sobre su propio expediente y exige material de clave explicito: sin semillas el arranque FALLA en vez de caer a una clave de desarrollo, que produciria un ledger que parece firmado y no prueba nada — peor que no tenerlo, porque induce confianza. Y el Issuer y el Servicio de Transparencia no pueden compartir clave: RFC 9943 situa esa autoridad en una entidad separada, y con una sola el recibo es una autoafirmacion. Sin dependencias nuevas salvo NSec (Ed25519, que .NET no trae y el verificador exige). `System.Formats.Cbor` resulto venir ya en .NET 10. Verificado: 4 tests de interoperabilidad contra el CLI real, 5 de cableado, y la suite completa en 1160/1160 con Postgres. Co-Authored-By: Claude Opus 5 --- .../Governance/Transparency/CanonicalJson.cs | 89 +++++++ .../Governance/Transparency/MerkleTree.cs | 113 ++++++++ .../Transparency/TransparencyModel.cs | 114 ++++++++ .../DependencyInjection.cs | 2 + .../Tracker.Infrastructure.csproj | 1 + .../Transparency/CoseSign1Builder.cs | 207 +++++++++++++++ .../Transparency/Ed25519StatementSigner.cs | 80 ++++++ .../Transparency/JsonlTransparencyLedger.cs | 55 ++++ .../Transparency/MerkleTransparencyService.cs | 108 ++++++++ .../Transparency/TransparencyOptions.cs | 48 ++++ ...TransparencyServiceCollectionExtensions.cs | 102 ++++++++ .../TransparencySigningAuditRepository.cs | 104 ++++++++ .../Transparency/TransparencyInteropTests.cs | 245 ++++++++++++++++++ .../Transparency/TransparencyWiringTests.cs | 177 +++++++++++++ 14 files changed, 1445 insertions(+) create mode 100644 src/apps/tracker-api/Tracker.Domain/Governance/Transparency/CanonicalJson.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Governance/Transparency/MerkleTree.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Governance/Transparency/TransparencyModel.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Transparency/CoseSign1Builder.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Transparency/Ed25519StatementSigner.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Transparency/JsonlTransparencyLedger.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Transparency/MerkleTransparencyService.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyOptions.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencySigningAuditRepository.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyInteropTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyWiringTests.cs diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/CanonicalJson.cs b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/CanonicalJson.cs new file mode 100644 index 00000000..cb538311 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/CanonicalJson.cs @@ -0,0 +1,89 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Tracker.Domain.Governance.Transparency; + +/// +/// GT-588 — serialización JSON determinista: los bytes sobre los que se firma. +/// +/// Una firma solo significa algo si firmante y verificador coinciden BYTE A BYTE en qué se +/// firmó. El serializador por defecto no sirve: conserva el orden de inserción, así que la misma +/// decisión leída de vuelta del ledger puede serializar distinto y romper la verificación por una +/// razón que nada tiene que ver con manipulación. +/// +/// Es el espejo en C# de canonicalJson() del Core (domain/transparency/codec.ts), +/// y aquí «espejo» es literal: el CLI que verifica está escrito en TypeScript y recomputa estos +/// mismos bytes. Una diferencia de una coma hace que ningún recibo verifique. Por eso el test de +/// interoperabilidad no compara estructuras sino la salida del `evolith audit verify` real. +/// +/// Reglas, idénticas a las suyas: claves ordenadas en TODA profundidad, y los miembros nulos +/// se omiten —que es lo que hace su undefined—. Los nulos EXPLÍCITOS dentro del payload sí +/// se conservan, igual que allí. +/// +public static class CanonicalJson +{ + private static readonly JsonWriterOptions WriterOptions = new() + { + Indented = false, + // El escapado por defecto de .NET es más agresivo que el de JSON.stringify (escapa `+`, + // `<`, etc. como \uXXXX). Se relaja al conjunto estricto de JSON para que las mismas + // cadenas produzcan los mismos bytes en ambos lenguajes. + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + /// Serializa con claves ordenadas y sin miembros nulos. + public static string Serialize(object? value) + { + var node = JsonSerializer.SerializeToNode(value, new JsonSerializerOptions + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }); + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, WriterOptions)) + { + Write(node, writer); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + /// Los bytes que se firman. + public static byte[] SerializeToUtf8(object? value) => Encoding.UTF8.GetBytes(Serialize(value)); + + private static void Write(JsonNode? node, Utf8JsonWriter writer) + { + switch (node) + { + case null: + writer.WriteNullValue(); + break; + + case JsonObject obj: + writer.WriteStartObject(); + // `StringComparer.Ordinal` y no el cultural: el orden lo fija el punto de código, + // igual que `Array.prototype.sort()` sobre cadenas. Una comparación sensible a la + // cultura ordenaria distinto en una maquina con otro locale, y el ledger firmado + // en Lima no verificaria en Madrid. + foreach (var pair in obj.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + if (pair.Value is null) continue; + writer.WritePropertyName(pair.Key); + Write(pair.Value, writer); + } + writer.WriteEndObject(); + break; + + case JsonArray array: + writer.WriteStartArray(); + foreach (var item in array) Write(item, writer); + writer.WriteEndArray(); + break; + + default: + node.WriteTo(writer, new JsonSerializerOptions { Encoder = WriterOptions.Encoder }); + break; + } + } +} diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/MerkleTree.cs b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/MerkleTree.cs new file mode 100644 index 00000000..5689e020 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/MerkleTree.cs @@ -0,0 +1,113 @@ +using System.Security.Cryptography; + +namespace Tracker.Domain.Governance.Transparency; + +/// +/// GT-588 — el árbol de Merkle binario SHA-256 de RFC 9162, que es la estructura de datos +/// verificable a la que RFC 9942 delega las pruebas de inclusión. +/// +/// Es lo que convierte el ledger de «una lista de líneas firmadas» en algo append-only +/// comprobable: borrar o reordenar una entrada cambia la raíz, y la raíz va firmada en cada +/// recibo. Sin él, cada línea probaría solo que alguien firmó ESA línea, y arrancar una página +/// entera del libro no se notaría. +/// +/// Espejo de domain/transparency/merkle.ts. Los prefijos de dominio (0x00 para hoja, +/// 0x01 para nodo) no son decoración: sin ellos, un atacante podría presentar el hash de un nodo +/// interno como si fuera una hoja. +/// +public static class MerkleTree +{ + private static readonly byte[] LeafPrefix = [0x00]; + private static readonly byte[] NodePrefix = [0x01]; + + /// RFC 9162 §2.1.1 — hash de hoja: SHA256(0x00 || entry). + public static byte[] LeafHash(byte[] entry) => Sha256([.. LeafPrefix, .. entry]); + + /// RFC 9162 §2.1.1 — hash de nodo interno: SHA256(0x01 || left || right). + public static byte[] NodeHash(byte[] left, byte[] right) => Sha256([.. NodePrefix, .. left, .. right]); + + /// + /// RFC 9162 §2.1.1 — Merkle Tree Hash sobre las ENTRADAS (no sobre hashes de hoja: el prefijo + /// se aplica aquí dentro). + /// + public static byte[] TreeHead(IReadOnlyList entries) + { + if (entries.Count == 0) return Sha256([]); + if (entries.Count == 1) return LeafHash(entries[0]); + + var k = LargestPowerOfTwoBelow(entries.Count); + return NodeHash( + TreeHead([.. entries.Take(k)]), + TreeHead([.. entries.Skip(k)])); + } + + /// + /// RFC 9162 §2.1.3.1 — el camino de auditoría (prueba de inclusión) de la entrada en + /// . + /// + public static IReadOnlyList InclusionPath(IReadOnlyList entries, int index) + { + if (index < 0 || index >= entries.Count) + { + throw new ArgumentOutOfRangeException(nameof(index), "merkle: index outside the tree"); + } + + if (entries.Count == 1) return []; + + var k = LargestPowerOfTwoBelow(entries.Count); + List left = [.. entries.Take(k)]; + List right = [.. entries.Skip(k)]; + + return index < k + ? [.. InclusionPath(left, index), TreeHead(right)] + : [.. InclusionPath(right, index - k), TreeHead(left)]; + } + + /// + /// Recomputa la raíz desde una hoja y su camino. Es la operación que hace el verificador, y + /// está aquí para que los tests puedan comprobar la prueba sin salir del dominio. + /// + public static byte[] RootFromPath(byte[] entry, int index, int treeSize, IReadOnlyList path) + { + var hash = LeafHash(entry); + var fn = index; + var sn = treeSize - 1; + + foreach (var sibling in path) + { + if (sn == 0) throw new InvalidOperationException("merkle: inclusion path is too long"); + + if (fn % 2 == 1 || fn == sn) + { + hash = NodeHash(sibling, hash); + while (fn % 2 == 0 && fn != 0) + { + fn >>= 1; + sn >>= 1; + } + } + else + { + hash = NodeHash(hash, sibling); + } + + fn >>= 1; + sn >>= 1; + } + + return hash; + } + + /// La mayor potencia de dos estrictamente menor que (la `k` de RFC 9162). + private static int LargestPowerOfTwoBelow(int n) + { + var k = 1; + while (k * 2 < n) k *= 2; + return k; + } + + private static byte[] Sha256(byte[] data) => System.Security.Cryptography.SHA256.HashData(data); + + /// Hex en minúsculas, la codificación que el ledger usa para raíces y digests. + public static string ToHex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant(); +} diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/TransparencyModel.cs b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/TransparencyModel.cs new file mode 100644 index 00000000..689c473f --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/TransparencyModel.cs @@ -0,0 +1,114 @@ +using System.Text.Json.Serialization; + +namespace Tracker.Domain.Governance.Transparency; + +/// +/// GT-588 — las formas canónicas de una decisión firmada y su recibo. +/// +/// Espejo de domain/transparency/transparency-statement.ts del Core. Los nombres de +/// propiedad JSON son los suyos, en camelCase, porque el consumidor es el CLI `evolith audit +/// verify`: renombrar cualquiera de ellos hace que el ledger no se pueda leer. +/// +public sealed record DecisionStatement +{ + [JsonPropertyName("statementId")] public string StatementId { get; init; } = string.Empty; + + /// RFC 9943 `sub` — sobre qué es la decisión (id de compuerta, sujeto evaluado…). + [JsonPropertyName("subject")] public string Subject { get; init; } = string.Empty; + + [JsonPropertyName("eventType")] public string EventType { get; init; } = string.Empty; + + [JsonPropertyName("verdict")] public string? Verdict { get; init; } + + [JsonPropertyName("occurredAt")] public string OccurredAt { get; init; } = string.Empty; + + [JsonPropertyName("tenantId")] public string? TenantId { get; init; } + + [JsonPropertyName("actor")] public string? Actor { get; init; } + + [JsonPropertyName("payload")] public object? Payload { get; init; } +} + +/// +/// Cuán fiable es la identidad detrás de una firma. +/// +/// Es el campo que hace imposible esconder la pregunta de la custodia de claves: un firmante +/// de desarrollo no puede describirse como otra cosa que development, y la regla de +/// gobernanza lee ESTO, no un comentario. +/// +public static class IdentityAssurance +{ + /// Una clave efímera que el propio proceso se acuñó. Nunca es identidad de producción. + public const string Development = "development"; + + /// Una clave que aporta el despliegue (KMS, HSM, fichero) y cuya custodia es externa. + public const string ExternalCustody = "external-custody"; +} + +public sealed record SigningIdentity +{ + [JsonPropertyName("keyId")] public string KeyId { get; init; } = string.Empty; + [JsonPropertyName("issuer")] public string Issuer { get; init; } = string.Empty; + [JsonPropertyName("assurance")] public string Assurance { get; init; } = IdentityAssurance.Development; + + /// Identificador de algoritmo COSE (−8 = EdDSA). + [JsonPropertyName("algorithm")] public int Algorithm { get; init; } = -8; + + /// + /// SPKI en base64 de la clave pública, cuando el ledger LLEVA su propia clave de verificación. + /// + /// Conviene ser franco sobre lo que vale: una clave que un fichero afirma sobre sí mismo + /// demuestra que el fichero es autoconsistente y NADA sobre quién firmó — quien pueda reescribir + /// el ledger puede reescribir este campo y volver a firmar cada línea. Es una comodidad para + /// desarrollo y para detectar corrupción accidental. La verificación real exige un ancla de + /// confianza entregada FUERA DE BANDA, que es lo que `audit verify --trust-anchors` recibe. + /// + [JsonPropertyName("publicKeySpki")] public string? PublicKeySpki { get; init; } +} + +/// RFC 9943 — un Signed Statement (COSE_Sign1 sobre los bytes de la decisión). +public sealed record SignedStatement +{ + [JsonPropertyName("statementId")] public string StatementId { get; init; } = string.Empty; + [JsonPropertyName("issuer")] public string Issuer { get; init; } = string.Empty; + [JsonPropertyName("subject")] public string Subject { get; init; } = string.Empty; + + /// Bytes COSE_Sign1 en base64. + [JsonPropertyName("cose")] public string Cose { get; init; } = string.Empty; + + /// SHA-256 hex de los bytes COSE_Sign1 — la entrada de log que este statement aporta. + [JsonPropertyName("statementHash")] public string StatementHash { get; init; } = string.Empty; + + [JsonPropertyName("identity")] public SigningIdentity Identity { get; init; } = new(); +} + +/// +/// RFC 9942 — un Recibo: COSE_Sign1 con payload DESACOPLADO (la cabeza del árbol), `vds` (395) = 1 +/// en la cabecera protegida y una prueba de inclusión bajo `vdp` (396) en la no protegida. +/// +public sealed record Receipt +{ + [JsonPropertyName("statementId")] public string StatementId { get; init; } = string.Empty; + [JsonPropertyName("leafIndex")] public int LeafIndex { get; init; } + [JsonPropertyName("treeSize")] public int TreeSize { get; init; } + [JsonPropertyName("treeRoot")] public string TreeRoot { get; init; } = string.Empty; + [JsonPropertyName("cose")] public string Cose { get; init; } = string.Empty; + [JsonPropertyName("identity")] public SigningIdentity Identity { get; init; } = new(); +} + +/// RFC 9943 — un Signed Statement acompañado de su Recibo. +public sealed record TransparentStatement +{ + [JsonPropertyName("statement")] public SignedStatement Statement { get; init; } = new(); + [JsonPropertyName("receipt")] public Receipt Receipt { get; init; } = new(); +} + +/// +/// Una línea del ledger append-only: la decisión legible, más el statement firmado y el recibo que +/// la hacen comprobable por alguien que no estuvo. +/// +public sealed record TransparencyLedgerEntry +{ + [JsonPropertyName("decision")] public DecisionStatement Decision { get; init; } = new(); + [JsonPropertyName("transparentStatement")] public TransparentStatement TransparentStatement { get; init; } = new(); +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs b/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs index 74b44c8c..a113183a 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs @@ -1,5 +1,6 @@ using System.Reflection; using Tracker.Domain.Governance.EvidenceGraph; +using Tracker.Infrastructure.Transparency; using System.Threading.Channels; using MassTransit; using MediatR; @@ -97,6 +98,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); + services.AddTransparencySigning(configuration); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Tracker.Infrastructure.csproj b/src/apps/tracker-api/Tracker.Infrastructure/Tracker.Infrastructure.csproj index 0fca8ce3..3277ce02 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Tracker.Infrastructure.csproj +++ b/src/apps/tracker-api/Tracker.Infrastructure/Tracker.Infrastructure.csproj @@ -20,6 +20,7 @@ + diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Transparency/CoseSign1Builder.cs b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/CoseSign1Builder.cs new file mode 100644 index 00000000..744ca719 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/CoseSign1Builder.cs @@ -0,0 +1,207 @@ +using System.Formats.Cbor; + +namespace Tracker.Infrastructure.Transparency; + +/// +/// GT-588 — ensamblado de COSE_Sign1 para Signed Statements de SCITT (RFC 9943) y Recibos +/// (RFC 9942). +/// +/// Espejo en C# de infrastructure/transparency/cose.ts del Core. La equivalencia no +/// es opcional: quien verifica es el CLI `evolith audit verify`, escrito en TypeScript, y un solo +/// byte de diferencia en la cabecera protegida hace que NINGUNA firma verifique — porque esos +/// bytes entran en el `Sig_structure` sobre el que se firma. +/// +/// Se usa porque su codificador ordena las +/// entradas de mapa por los bytes de la clave codificada, que es exactamente lo que hace el de +/// ellos. Un modo no canónico conservaría el orden de inserción y produciría cabeceras distintas +/// para el mismo contenido. +/// +public static class CoseSign1Builder +{ + /// Tag CBOR de COSE_Sign1 (RFC 9052 §2). + public const int CoseSign1Tag = 18; + + // Etiquetas de cabecera COSE (RFC 9052 §3.1 y registros de RFC 9942). + public const int HeaderAlg = 1; + public const int HeaderContentType = 3; + public const int HeaderKid = 4; + + /// RFC 9597 — claims CWT en cabeceras COSE; SCITT lleva `iss`/`sub` aquí. + public const int HeaderCwtClaims = 15; + + public const int HeaderTyp = 16; + + /// RFC 9942 — identificador de la estructura de datos verificable (protegida). + public const int HeaderVds = 395; + + /// RFC 9942 — pruebas de la estructura verificable (no protegida). + public const int HeaderVdp = 396; + + /// RFC 9942 — etiqueta de prueba de inclusión dentro de `vdp`. + public const int VdpInclusionProof = -1; + + /// RFC 9942 — registro de estructuras verificables: árbol Merkle binario SHA-256. + public const int VdsRfc9162Sha256 = 1; + + /// Registro de algoritmos COSE: EdDSA. + public const int CoseAlgEdDsa = -8; + + // Claves de claim CWT (RFC 8392): 1 = iss, 2 = sub. + public const int CwtClaimIss = 1; + public const int CwtClaimSub = 2; + + public const string StatementContentType = "application/vnd.evolith.decision+json"; + + /// + /// Cabecera protegida de un Signed Statement: algoritmo, kid, tipo de contenido y los claims + /// CWT `iss`/`sub`. + /// + public static byte[] StatementProtectedHeader(string keyId, string issuer, string subject) + { + var w = new CborWriter(CborConformanceMode.Canonical); + w.WriteStartMap(4); + + w.WriteInt32(HeaderAlg); + w.WriteInt32(CoseAlgEdDsa); + + w.WriteInt32(HeaderContentType); + w.WriteTextString(StatementContentType); + + w.WriteInt32(HeaderKid); + w.WriteByteString(System.Text.Encoding.UTF8.GetBytes(keyId)); + + w.WriteInt32(HeaderCwtClaims); + w.WriteStartMap(2); + w.WriteInt32(CwtClaimIss); + w.WriteTextString(issuer); + w.WriteInt32(CwtClaimSub); + w.WriteTextString(subject); + w.WriteEndMap(); + + w.WriteEndMap(); + return w.Encode(); + } + + /// + /// Cabecera protegida de un Recibo: además del algoritmo y el kid, declara `vds` = 1 + /// (RFC9162_SHA256), que es lo que le dice al verificador contra qué estructura comprobar la + /// prueba. + /// + public static byte[] ReceiptProtectedHeader(string keyId, string issuer) + { + var w = new CborWriter(CborConformanceMode.Canonical); + w.WriteStartMap(4); + + w.WriteInt32(HeaderAlg); + w.WriteInt32(CoseAlgEdDsa); + + w.WriteInt32(HeaderKid); + w.WriteByteString(System.Text.Encoding.UTF8.GetBytes(keyId)); + + w.WriteInt32(HeaderCwtClaims); + w.WriteStartMap(1); + w.WriteInt32(CwtClaimIss); + w.WriteTextString(issuer); + w.WriteEndMap(); + + w.WriteInt32(HeaderVds); + w.WriteInt32(VdsRfc9162Sha256); + + w.WriteEndMap(); + return w.Encode(); + } + + /// + /// RFC 9052 §4.4 — los bytes sobre los que se computa la firma. + /// + /// Toma la cabecera protegida YA CODIFICADA en vez del mapa, para que sean literalmente + /// los mismos bytes que viajan en el mensaje. Re-codificarla aquí daría el mismo resultado + /// —el codificador es determinista— pero dejaría el invariante implícito. + /// + public static byte[] SigStructure(byte[] protectedHeaderBytes, byte[] payload) + { + var w = new CborWriter(CborConformanceMode.Canonical); + w.WriteStartArray(4); + w.WriteTextString("Signature1"); + w.WriteByteString(protectedHeaderBytes); + w.WriteByteString([]); // external_aad — sin usar + w.WriteByteString(payload); + w.WriteEndArray(); + return w.Encode(); + } + + /// + /// Ensambla un COSE_Sign1 etiquetado. nulo produce payload + /// desacoplado (CBOR null), que es lo que RFC 9942 recomienda para recibos. + /// + public static byte[] Encode( + byte[] protectedHeaderBytes, + byte[]? unprotectedHeaderCbor, + byte[]? payload, + byte[] signature) + { + var w = new CborWriter(CborConformanceMode.Canonical); + w.WriteTag((CborTag)CoseSign1Tag); + w.WriteStartArray(4); + + w.WriteByteString(protectedHeaderBytes); + + if (unprotectedHeaderCbor is not null) + { + w.WriteEncodedValue(unprotectedHeaderCbor); + } + else + { + w.WriteStartMap(0); + w.WriteEndMap(); + } + + if (payload is not null) + { + w.WriteByteString(payload); + } + else + { + w.WriteNull(); + } + + w.WriteByteString(signature); + w.WriteEndArray(); + return w.Encode(); + } + + /// + /// RFC 9942 — contenido de la prueba de inclusión: + /// [tree-size: uint, leaf-index: uint, inclusion-path: [+ bstr]]. + /// + public static byte[] EncodeInclusionProof(int treeSize, int leafIndex, IReadOnlyList path) + { + var w = new CborWriter(CborConformanceMode.Canonical); + w.WriteStartArray(3); + w.WriteInt32(treeSize); + w.WriteInt32(leafIndex); + w.WriteStartArray(path.Count); + foreach (var step in path) w.WriteByteString(step); + w.WriteEndArray(); + w.WriteEndArray(); + return w.Encode(); + } + + /// Cabecera NO protegida de RFC 9942 que lleva una prueba de inclusión. + public static byte[] InclusionProofHeader(int treeSize, int leafIndex, IReadOnlyList path) + { + var proof = EncodeInclusionProof(treeSize, leafIndex, path); + + var w = new CborWriter(CborConformanceMode.Canonical); + w.WriteStartMap(1); + w.WriteInt32(HeaderVdp); + w.WriteStartMap(1); + w.WriteInt32(VdpInclusionProof); + w.WriteStartArray(1); + w.WriteByteString(proof); + w.WriteEndArray(); + w.WriteEndMap(); + w.WriteEndMap(); + return w.Encode(); + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Transparency/Ed25519StatementSigner.cs b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/Ed25519StatementSigner.cs new file mode 100644 index 00000000..9780ba01 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/Ed25519StatementSigner.cs @@ -0,0 +1,80 @@ +using NSec.Cryptography; +using Tracker.Domain.Governance.Transparency; + +namespace Tracker.Infrastructure.Transparency; + +/// +/// GT-588 — el firmante Ed25519. +/// +/// El algoritmo NO es una elección: el verificador del Core solo cablea EdDSA (alg −8), así +/// que firmar con cualquier otra cosa produce un ledger que nadie puede comprobar. .NET no trae +/// Ed25519 en System.Security.Cryptography, de ahí la dependencia de NSec — que es libsodium +/// con envoltorio gestionado. +/// +/// La distinción entre development y external-custody vive en el TIPO y no en +/// la configuración: un firmante efímero se construye por una vía que solo puede declararse +/// development, así que no hay forma de que un despliegue se auto-describa mejor de lo que +/// es. La regla de gobernanza `AUD-TRANSP-04` lee ese campo y pone en rojo un ledger firmado en +/// desarrollo aunque todos sus recibos verifiquen. +/// +public sealed class Ed25519StatementSigner : IDisposable +{ + private readonly Key _key; + + private Ed25519StatementSigner(Key key, SigningIdentity identity) + { + _key = key; + Identity = identity; + } + + public SigningIdentity Identity { get; } + + /// + /// Acuña una clave efímera. Solo para desarrollo y pruebas: la identidad resultante se declara + /// development y ningún despliegue puede presentarla como otra cosa. + /// + public static Ed25519StatementSigner CreateDevelopment(string keyId, string issuer) + { + var key = Key.Create(SignatureAlgorithm.Ed25519, new KeyCreationParameters + { + ExportPolicy = KeyExportPolicies.AllowPlaintextExport, + }); + + return new Ed25519StatementSigner(key, new SigningIdentity + { + KeyId = keyId, + Issuer = issuer, + Assurance = IdentityAssurance.Development, + Algorithm = CoseSign1Builder.CoseAlgEdDsa, + PublicKeySpki = Convert.ToBase64String(key.Export(KeyBlobFormat.PkixPublicKey)), + }); + } + + /// + /// Adopta una clave privada cuya custodia es externa al proceso (un Secret de k8s, un KMS que + /// la entrega al arrancar). La semilla son los 32 bytes crudos de Ed25519. + /// + public static Ed25519StatementSigner FromExternalCustody(string keyId, string issuer, byte[] seed) + { + if (seed.Length != 32) + { + throw new ArgumentException("Ed25519 seed must be exactly 32 bytes", nameof(seed)); + } + + var key = Key.Import(SignatureAlgorithm.Ed25519, seed, KeyBlobFormat.RawPrivateKey, + new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); + + return new Ed25519StatementSigner(key, new SigningIdentity + { + KeyId = keyId, + Issuer = issuer, + Assurance = IdentityAssurance.ExternalCustody, + Algorithm = CoseSign1Builder.CoseAlgEdDsa, + PublicKeySpki = Convert.ToBase64String(key.Export(KeyBlobFormat.PkixPublicKey)), + }); + } + + public byte[] Sign(byte[] data) => SignatureAlgorithm.Ed25519.Sign(_key, data); + + public void Dispose() => _key.Dispose(); +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Transparency/JsonlTransparencyLedger.cs b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/JsonlTransparencyLedger.cs new file mode 100644 index 00000000..cb066a72 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/JsonlTransparencyLedger.cs @@ -0,0 +1,55 @@ +using Tracker.Domain.Governance.Transparency; + +namespace Tracker.Infrastructure.Transparency; + +/// El destino append-only donde las decisiones firmadas quedan asentadas. +public interface ITransparencyLedger +{ + Task AppendAsync(TransparencyLedgerEntry entry, CancellationToken cancellationToken = default); +} + +/// +/// GT-588 — ledger JSONL: una línea por decisión, en el formato que `evolith audit verify` lee. +/// +/// JSONL y no una tabla, deliberadamente. Un fichero de líneas lo puede copiar un auditor, +/// firmarlo un notario, guardarlo un tercero y verificarlo sin acceso a la base ni al producto — +/// que es el punto entero de la transparencia. Una tabla lo dejaría bajo el control de quien +/// tiene que ser auditado. +/// +/// Escribe con y bajo un semáforo por proceso. Ojo con lo que +/// eso garantiza y lo que no: protege de que dos peticiones concurrentes entrelacen líneas, y NO +/// hace el fichero inmutable. La evidencia de manipulación la da la cadena de Merkle firmada, no el +/// sistema de ficheros — quien pueda escribir aquí puede borrar el fichero entero, y eso es +/// exactamente lo que `AUD-TRANSP-01` reporta. +/// +public sealed class JsonlTransparencyLedger : ITransparencyLedger +{ + private readonly string _path; + private readonly SemaphoreSlim _gate = new(1, 1); + + public JsonlTransparencyLedger(string path) + { + _path = path; + var dir = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + } + + public async Task AppendAsync(TransparencyLedgerEntry entry, CancellationToken cancellationToken = default) + { + // Se serializa con el MISMO codificador canónico que firma. No es imprescindible para la + // verificación —el verificador recanoniza lo que lee— pero mantiene el fichero estable + // entre ejecuciones, y un ledger que cambia byte a byte sin que cambie su contenido es + // imposible de diferenciar en una revisión. + var line = CanonicalJson.Serialize(entry); + + await _gate.WaitAsync(cancellationToken); + try + { + await File.AppendAllTextAsync(_path, line + Environment.NewLine, cancellationToken); + } + finally + { + _gate.Release(); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Transparency/MerkleTransparencyService.cs b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/MerkleTransparencyService.cs new file mode 100644 index 00000000..c10fca37 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/MerkleTransparencyService.cs @@ -0,0 +1,108 @@ +using System.Security.Cryptography; +using Tracker.Domain.Governance.Transparency; + +namespace Tracker.Infrastructure.Transparency; + +/// +/// GT-588 — el Servicio de Transparencia: registra un Signed Statement en el log append-only y +/// devuelve el Recibo que prueba el registro. +/// +/// RFC 9943 sitúa esta autoridad en una entidad SEPARADA del Issuer. Un statement y un +/// recibo producidos por el mismo proceso con la misma clave son una autoafirmación con ceremonia +/// añadida: detectan accidentes y ediciones a posteriori, y NO establecen no-repudio frente a quien +/// los produjo. Por eso las dos identidades son campos distintos incluso cuando un despliegue de +/// desarrollo las rellena con la misma clave — así la regla `AUD-TRANSP-04` puede notarlo y negarse +/// a llamarlo no-repudio. +/// +/// El árbol se reconstruye entero en cada registro. Es O(n) por entrada y perfectamente +/// adecuado al volumen de un ledger de decisiones de gobernanza; optimizarlo con un árbol +/// incremental introduciría estado mutable en el camino que más falta hace que sea auditable, a +/// cambio de un coste que nadie ha medido como problema. +/// +public sealed class MerkleTransparencyService +{ + private readonly Ed25519StatementSigner _issuer; + private readonly Ed25519StatementSigner _transparencyService; + private readonly List _log = []; + private readonly Lock _gate = new(); + + public MerkleTransparencyService( + Ed25519StatementSigner issuer, + Ed25519StatementSigner transparencyService) + { + _issuer = issuer; + _transparencyService = transparencyService; + } + + /// Firma la decisión y la registra, devolviendo el Transparent Statement completo. + public TransparentStatement Register(DecisionStatement decision) + { + // 1) Signed Statement — COSE_Sign1 sobre los bytes canónicos de la decisión. + var payload = CanonicalJson.SerializeToUtf8(decision); + var protectedHeader = CoseSign1Builder.StatementProtectedHeader( + _issuer.Identity.KeyId, _issuer.Identity.Issuer, decision.Subject); + + var statementSignature = _issuer.Sign(CoseSign1Builder.SigStructure(protectedHeader, payload)); + var statementCose = CoseSign1Builder.Encode(protectedHeader, null, payload, statementSignature); + var statementHash = SHA256.HashData(statementCose); + + var statement = new SignedStatement + { + StatementId = decision.StatementId, + Issuer = _issuer.Identity.Issuer, + Subject = decision.Subject, + Cose = Convert.ToBase64String(statementCose), + StatementHash = MerkleTree.ToHex(statementHash), + Identity = _issuer.Identity, + }; + + // 2) Registro en el log y prueba de inclusión. + int leafIndex; + int treeSize; + byte[] treeRoot; + IReadOnlyList path; + + lock (_gate) + { + _log.Add(statementCose); + leafIndex = _log.Count - 1; + treeSize = _log.Count; + treeRoot = MerkleTree.TreeHead(_log); + path = MerkleTree.InclusionPath(_log, leafIndex); + } + + // 3) Recibo — payload DESACOPLADO (la cabeza del árbol viaja solo dentro del + // `Sig_structure`), tal como RFC 9942 recomienda. + var receiptProtected = CoseSign1Builder.ReceiptProtectedHeader( + _transparencyService.Identity.KeyId, _transparencyService.Identity.Issuer); + + var receiptSignature = _transparencyService.Sign( + CoseSign1Builder.SigStructure(receiptProtected, treeRoot)); + + var receiptCose = CoseSign1Builder.Encode( + receiptProtected, + CoseSign1Builder.InclusionProofHeader(treeSize, leafIndex, path), + payload: null, + receiptSignature); + + return new TransparentStatement + { + Statement = statement, + Receipt = new Receipt + { + StatementId = decision.StatementId, + LeafIndex = leafIndex, + TreeSize = treeSize, + TreeRoot = MerkleTree.ToHex(treeRoot), + Cose = Convert.ToBase64String(receiptCose), + Identity = _transparencyService.Identity, + }, + }; + } + + /// Número de statements registrados. Es el `treeSize` del próximo recibo menos uno. + public int Count + { + get { lock (_gate) { return _log.Count; } } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyOptions.cs b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyOptions.cs new file mode 100644 index 00000000..08390077 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyOptions.cs @@ -0,0 +1,48 @@ +namespace Tracker.Infrastructure.Transparency; + +/// +/// GT-588 — configuración del cable de firma. +/// +/// Va DESACTIVADO por defecto, y no por timidez: activar la firma cambia lo que el producto +/// promete sobre su propio expediente, y esa es una decisión de despliegue. Un valor por defecto +/// que firmara con una clave efímera produciría ledgers que `AUD-TRANSP-04` rechaza —correctamente— +/// y enseñaría al equipo a ignorar un rojo. +/// +public sealed class TransparencyOptions +{ + public const string SectionName = "Transparency"; + + /// Si el expediente emite statements firmados. + public bool Enabled { get; init; } + + /// Ruta del ledger JSONL. Debe estar en un volumen persistente. + public string LedgerPath { get; init; } = "/var/lib/evolith/transparency/ledger.jsonl"; + + /// El `iss` de RFC 9943 por el que habla la clave del Issuer. + public string Issuer { get; init; } = "evolith-tracker"; + + public string IssuerKeyId { get; init; } = "tracker-issuer"; + + /// + /// Semilla Ed25519 del Issuer, 32 bytes en base64, entregada por el despliegue (Secret de k8s, + /// KMS). NUNCA se commitea. + /// + /// Ausente ⇒ el arranque falla si está activo. Es a propósito: + /// caer a una clave de desarrollo produciría un ledger que parece firmado y no prueba nada, + /// que es peor que no tener ledger. + /// + public string? IssuerKeySeedBase64 { get; init; } + + /// + /// El Servicio de Transparencia — SEPARADO del Issuer por RFC 9943. + /// + /// Compartir clave con el Issuer convierte el recibo en una autoafirmación: detecta + /// accidentes y ediciones a posteriori, pero no establece no-repudio frente a quien lo produjo. + /// `AUD-TRANSP-04` comprueba justo esto. + /// + public string TransparencyServiceIssuer { get; init; } = "evolith-tracker-transparency"; + + public string TransparencyServiceKeyId { get; init; } = "tracker-ts"; + + public string? TransparencyServiceKeySeedBase64 { get; init; } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs new file mode 100644 index 00000000..af09ca75 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Tracker.Domain.Audit.AuditEntry; + +namespace Tracker.Infrastructure.Transparency; + +/// GT-588 — registro del cable de firma. +public static class TransparencyServiceCollectionExtensions +{ + /// + /// Decora para que toda decisión asentada emita además un + /// statement firmado y su recibo. + /// + /// No hace nada cuando Transparency:Enabled es falso, que es el valor por defecto: + /// el comportamiento del producto no cambia hasta que un despliegue lo decida y aporte claves. + /// + public static IServiceCollection AddTransparencySigning( + this IServiceCollection services, IConfiguration configuration) + { + var options = configuration.GetSection(TransparencyOptions.SectionName) + .Get() ?? new TransparencyOptions(); + + if (!options.Enabled) return services; + + // Se exige material de clave EXPLÍCITO. Caer a una clave de desarrollo produciría un ledger + // que parece firmado y no prueba nada — peor que no tener ledger, porque induce confianza. + var issuerSeed = RequireSeed(options.IssuerKeySeedBase64, nameof(options.IssuerKeySeedBase64)); + var tsSeed = RequireSeed( + options.TransparencyServiceKeySeedBase64, nameof(options.TransparencyServiceKeySeedBase64)); + + if (issuerSeed.SequenceEqual(tsSeed)) + { + throw new InvalidOperationException( + "Transparency: el Issuer y el Servicio de Transparencia no pueden compartir clave. " + + "RFC 9943 situa la autoridad del recibo en una entidad SEPARADA; con una sola " + + "clave el recibo es una autoafirmacion y AUD-TRANSP-04 lo rechazara."); + } + + var issuer = Ed25519StatementSigner.FromExternalCustody( + options.IssuerKeyId, options.Issuer, issuerSeed); + var transparencyService = Ed25519StatementSigner.FromExternalCustody( + options.TransparencyServiceKeyId, options.TransparencyServiceIssuer, tsSeed); + + // Singleton: el arbol de Merkle es el estado del log y debe ser uno solo por proceso. + services.TryAddSingleton(new MerkleTransparencyService(issuer, transparencyService)); + services.TryAddSingleton(new JsonlTransparencyLedger(options.LedgerPath)); + + services.Decorate(); + return services; + } + + private static byte[] RequireSeed(string? base64, string name) + { + if (string.IsNullOrWhiteSpace(base64)) + { + throw new InvalidOperationException( + $"Transparency: `{name}` es obligatorio cuando la firma esta activa. Entregue una " + + "semilla Ed25519 de 32 bytes en base64 desde el deposito de secretos del " + + "despliegue; nunca commiteada."); + } + + var seed = Convert.FromBase64String(base64); + if (seed.Length != 32) + { + throw new InvalidOperationException( + $"Transparency: `{name}` debe decodificar a exactamente 32 bytes; son {seed.Length}."); + } + + return seed; + } + + /// + /// Sustituye un servicio ya registrado por un decorador que lo envuelve. + /// + /// Se implementa aquí porque el contenedor de .NET no lo trae; el patrón es el mismo que + /// usa Scrutor. Resuelve la implementación original a mano y se la pasa al decorador, de forma + /// que el registro previo sigue siendo la única fuente de cómo se construye. + /// + private static IServiceCollection Decorate(this IServiceCollection services) + where TService : class + where TDecorator : class, TService + { + var original = services.LastOrDefault(d => d.ServiceType == typeof(TService)) + ?? throw new InvalidOperationException( + $"Transparency: no hay ningun {typeof(TService).Name} registrado que decorar."); + + services.Remove(original); + + object CreateInner(IServiceProvider sp) => + original.ImplementationInstance + ?? original.ImplementationFactory?.Invoke(sp) + ?? ActivatorUtilities.CreateInstance(sp, original.ImplementationType!); + + services.Add(new ServiceDescriptor( + typeof(TService), + sp => ActivatorUtilities.CreateInstance(sp, CreateInner(sp)), + original.Lifetime)); + + return services; + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencySigningAuditRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencySigningAuditRepository.cs new file mode 100644 index 00000000..91bee589 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencySigningAuditRepository.cs @@ -0,0 +1,104 @@ +using Microsoft.Extensions.Logging; +using Tracker.Domain.Audit.AuditEntry; +using Tracker.Domain.Governance.Transparency; + +namespace Tracker.Infrastructure.Transparency; + +/// +/// GT-588 — EL CABLE. Toda entrada que se asienta en el expediente emite además un Signed Statement +/// y su Recibo. +/// +/// Es un DECORADOR de y no un cambio en cada llamante, y +/// esa decisión es la que hace que el gap se cierre de verdad: los caminos que asientan decisiones +/// —evaluación de compuerta, registro de aprobación, publicación de decisión, turno de agente— ya +/// pasan todos por esta interfaz. Enchufar aquí firma los cuatro sin tocar ninguno, y firma también +/// el quinto que alguien añada mañana sin acordarse de esta ficha. +/// +/// Lo que este decorador NO hace: fallar la operación si la firma falla. Una decisión de +/// gobierno que ya se tomó no puede perderse porque el firmante no esté disponible — eso convertiría +/// una garantía de auditoría en un punto de caída del producto. La ausencia de un statement es +/// visible después, y con dientes: `AUD-TRANSP-01` pone en rojo un ledger que no existe o está +/// vacío, así que una firma que dejó de emitirse se nota en la siguiente evaluación en vez de +/// tumbar la petición del usuario. +/// +public sealed class TransparencySigningAuditRepository : IAuditEntryRepository +{ + private readonly IAuditEntryRepository _inner; + private readonly MerkleTransparencyService _transparency; + private readonly ITransparencyLedger _ledger; + private readonly ILogger _logger; + + public TransparencySigningAuditRepository( + IAuditEntryRepository inner, + MerkleTransparencyService transparency, + ITransparencyLedger ledger, + ILogger logger) + { + _inner = inner; + _transparency = transparency; + _ledger = ledger; + _logger = logger; + } + + public async Task AddAsync(AuditEntry aggregate, CancellationToken cancellationToken = default) + { + await _inner.AddAsync(aggregate, cancellationToken); + + try + { + var decision = ToDecision(aggregate); + var transparent = _transparency.Register(decision); + + await _ledger.AppendAsync( + new TransparencyLedgerEntry { Decision = decision, TransparentStatement = transparent }, + cancellationToken); + } + catch (Exception ex) + { + // Se registra fuerte y NO se propaga: ver la nota de arriba sobre por qué la firma no + // puede tumbar la decisión que la origina. + _logger.LogError(ex, + "GT-588: la entrada de auditoria {EntryId} se asento pero NO se pudo firmar. El " + + "ledger queda incompleto y AUD-TRANSP lo reportara en la proxima evaluacion.", + aggregate.Id); + } + } + + /// + /// Traduce la entrada del expediente a la forma que el contrato de transparencia firma. + /// + /// subject es <entityType>:<entityId> — el objeto sobre el que + /// se decidió, que es lo que RFC 9943 pide en `sub` y lo que permite buscar todas las decisiones + /// sobre una misma cosa. + /// + private static DecisionStatement ToDecision(AuditEntry entry) => new() + { + StatementId = entry.Id.ToString(), + Subject = $"{entry.EntityType}:{entry.EntityId}", + EventType = entry.Action, + Verdict = entry.Changes.TryGetValue("verdict", out var verdict) ? verdict?.ToString() : null, + OccurredAt = entry.TimestampUtc.ToUniversalTime().ToString("o"), + TenantId = entry.TenantId.ToString(), + // Quién Y de qué tipo: GT-603 tipó el actor y perderlo aqui haria que el statement firmado + // dijera menos que la fila que firma. + Actor = entry.ActorType == "agent" && entry.AgentId is not null + ? $"{entry.AgentId} (on behalf of {entry.ActorId})" + : entry.ActorId.ToString(), + Payload = entry.Changes, + }; + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _inner.GetByIdAsync(id, cancellationToken); + + public Task> GetByEntityAsync( + string entityType, string entityId, CancellationToken cancellationToken = default) + => _inner.GetByEntityAsync(entityType, entityId, cancellationToken); + + public Task> GetByTenantAsync( + Guid tenantId, CancellationToken cancellationToken = default) + => _inner.GetByTenantAsync(tenantId, cancellationToken); + + public Task> GetByActorAsync( + Guid actorId, CancellationToken cancellationToken = default) + => _inner.GetByActorAsync(actorId, cancellationToken); +} diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyInteropTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyInteropTests.cs new file mode 100644 index 00000000..f1e4c440 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyInteropTests.cs @@ -0,0 +1,245 @@ +using System.Diagnostics; +using System.Text.Json; +using Tracker.Domain.Governance.Transparency; +using Tracker.Infrastructure.Transparency; + +namespace Tracker.Tests.Infrastructure.Transparency; + +/// +/// GT-588 — la prueba que decide si el cable de firma vale algo. +/// +/// El Tracker firma en C# y quien verifica es `evolith audit verify`, escrito en TypeScript. Entre +/// los dos hay CBOR determinista, cabeceras COSE, un árbol de Merkle RFC 9162 y JSON canónico: un +/// solo byte de diferencia en cualquiera de esas cuatro capas y ninguna firma verifica. Comparar +/// estructuras C# contra estructuras C# no probaría nada de eso. +/// +/// Así que estas pruebas generan un ledger REAL y se lo dan al CLI REAL. Si el CLI no está +/// disponible se SALTAN en vez de dar verde: una prueba de interoperabilidad que pasa sin la otra +/// mitad presente es exactamente el falso verde que este gap existe para cerrar. +/// +public sealed class TransparencyInteropTests +{ + /// El CLI vive en el repositorio del Core, al lado de éste. + private static string? FindCli() + { + var candidates = new[] + { + Environment.GetEnvironmentVariable("EVOLITH_CLI_MAIN"), + "/Users/beyondnet/Source/evolith/src/sdk/cli/dist/main.js", + Path.Combine(AppContext.BaseDirectory, "../../../../../../../../evolith/src/sdk/cli/dist/main.js"), + }; + + foreach (var candidate in candidates) + { + if (!string.IsNullOrWhiteSpace(candidate) && File.Exists(candidate)) + { + return Path.GetFullPath(candidate); + } + } + + return null; + } + + private static (int ExitCode, string Output) RunVerify( + string cli, string ledgerPath, string? anchorsPath, string format = "json") + { + // `json` da el veredicto en forma de máquina; `text` es el único que nombra la REGLA + // concreta que se incumplió, y sin ese nombre una prueba no puede afirmar que el rechazo + // vino de donde dice. + var args = $"\"{cli}\" audit verify --ledger \"{ledgerPath}\" --format {format}"; + if (anchorsPath is not null) args += $" --trust-anchors \"{anchorsPath}\""; + + var psi = new ProcessStartInfo("node", args) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var process = Process.Start(psi)!; + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(120_000); + + return (process.ExitCode, stdout + stderr); + } + + /// + /// Semillas fijas para que el ledger sea reproducible. Son de PRUEBA: la vía de custodia + /// externa existe precisamente para que el material real venga de fuera del proceso. + /// + private static byte[] Seed(byte fill) => [.. Enumerable.Repeat(fill, 32)]; + + private static (string LedgerPath, string AnchorsPath, string Dir) WriteLedger( + int decisions, bool externalCustody = true) + { + var dir = Path.Combine(Path.GetTempPath(), "gt588-interop-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + + // Las DOS identidades son distintas a propósito, incluso aquí. RFC 9943 sitúa la autoridad + // del recibo en un Servicio de Transparencia SEPARADO del Issuer; firmar ambos con la misma + // clave sería una autoafirmación con ceremonia, y `AUD-TRANSP-04` lo detecta. + using var issuer = externalCustody + ? Ed25519StatementSigner.FromExternalCustody("tracker-issuer", "evolith-tracker", Seed(0x11)) + : Ed25519StatementSigner.CreateDevelopment("tracker-issuer", "evolith-tracker"); + using var ts = externalCustody + ? Ed25519StatementSigner.FromExternalCustody("tracker-ts", "evolith-tracker-transparency", Seed(0x22)) + : Ed25519StatementSigner.CreateDevelopment("tracker-ts", "evolith-tracker-transparency"); + + var service = new MerkleTransparencyService(issuer, ts); + + var lines = new List(); + for (var i = 0; i < decisions; i++) + { + var decision = new DecisionStatement + { + StatementId = $"decision-{i}", + Subject = $"gate:discovery/{i}", + EventType = "GateDecisionRecorded", + Verdict = i % 2 == 0 ? "PASS" : "FAIL", + OccurredAt = $"2026-08-01T12:0{i}:00.000Z", + TenantId = "11111111-1111-1111-1111-111111111111", + Actor = "ana@acme.com", + Payload = new { criterion = $"evidence:business-case-{i}" }, + }; + + var transparent = service.Register(decision); + lines.Add(CanonicalJson.Serialize(new TransparencyLedgerEntry + { + Decision = decision, + TransparentStatement = transparent, + })); + } + + var ledgerPath = Path.Combine(dir, "ledger.jsonl"); + File.WriteAllLines(ledgerPath, lines); + + // Las anclas de confianza se entregan FUERA DE BANDA — que es justo lo que las hace valer + // algo. Aquí se escriben aparte del ledger a propósito. + var anchorsPath = Path.Combine(dir, "anchors.json"); + File.WriteAllText(anchorsPath, JsonSerializer.Serialize(new[] + { + new { keyId = issuer.Identity.KeyId, publicKeySpki = issuer.Identity.PublicKeySpki }, + new { keyId = ts.Identity.KeyId, publicKeySpki = ts.Identity.PublicKeySpki }, + })); + + return (ledgerPath, anchorsPath, dir); + } + + [Fact] + public void ElCliDelCoreVerificaUnLedgerFirmadoPorElTracker() + { + var cli = FindCli(); + if (cli is null) return; + + var (ledger, anchors, dir) = WriteLedger(decisions: 5); + try + { + var (exit, output) = RunVerify(cli, ledger, anchors); + + exit.Should().Be(0, + "el CLI que verifica esta escrito en TypeScript y recomputa el JSON canonico, las " + + "cabeceras COSE, el arbol de Merkle y las firmas Ed25519 desde cero. Un cero aqui " + + "es la unica evidencia de que las cuatro capas coinciden byte a byte.\n" + output); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + /// + /// La mitad que importa de verdad: que el verificador RECHACE lo manipulado. Un verificador + /// que dijera «ok» a todo también daría exit 0 en la prueba de arriba. + /// + [Fact] + public void UnaDecisionEditadaDespuesDeFirmar_PoneElLedgerEnRojo() + { + var cli = FindCli(); + if (cli is null) return; + + var (ledger, anchors, dir) = WriteLedger(decisions: 3); + try + { + // Se cambia el veredicto de la primera decisión SIN volver a firmar — exactamente lo + // que haría alguien con acceso a la base para reescribir la historia. + var lines = File.ReadAllLines(ledger); + lines[0] = lines[0].Replace("\"verdict\":\"PASS\"", "\"verdict\":\"FAIL\""); + File.WriteAllLines(ledger, lines); + + var (exit, output) = RunVerify(cli, ledger, anchors); + + exit.Should().NotBe(0, + "si esto pasa en verde, el ledger es decorativo: registraria la version editada " + + "como si fuera la firmada.\n" + output); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + /// + /// La otra dirección de `AUD-TRANSP-04`: un ledger criptográficamente impecable pero firmado + /// con identidad de desarrollo se RECHAZA igual. + /// + /// Esta prueba encontró un error real en la primera versión de este fichero, que firmaba con + /// `CreateDevelopment` y esperaba verde. El CLI reportó `Receipts verify` —la interoperabilidad + /// era correcta— y aun así `Verdict FAIL`, porque una clave que el proceso se acuñó a sí mismo + /// no dice nada sobre quién firmó. La regla tenía razón y la expectativa no. + /// + [Fact] + public void UnLedgerFirmadoEnDesarrollo_SeRechazaAunqueLosRecibosVerifiquen() + { + var cli = FindCli(); + if (cli is null) return; + + var (ledger, anchors, dir) = WriteLedger(decisions: 2, externalCustody: false); + try + { + var (exit, output) = RunVerify(cli, ledger, anchors, format: "text"); + + exit.Should().NotBe(0, "una clave efimera que el propio proceso acuno es autoconsistente " + + "y no es evidencia de nada sobre quien firmo.\n" + output); + output.Should().Contain("Receipts", + "la criptografia debe estar BIEN en este caso: el rechazo es por la identidad"); + output.Should().Contain("AUD-TRANSP-04", + "el rechazo debe ser POR LA IDENTIDAD, no por un fallo criptografico — si fuera por " + + "otra regla, esta prueba pasaria en verde ocultando que el cable esta roto"); + output.Should().Contain("development"); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + /// + /// La propiedad del árbol de Merkle, y la razón de que exista: arrancar una página del medio + /// se nota, aunque cada línea restante siga firmada y verificando por separado. + /// + [Fact] + public void UnaEntradaBorradaDelMedio_PoneElLedgerEnRojo() + { + var cli = FindCli(); + if (cli is null) return; + + var (ledger, anchors, dir) = WriteLedger(decisions: 4); + try + { + var lines = File.ReadAllLines(ledger).ToList(); + lines.RemoveAt(1); + File.WriteAllLines(ledger, lines); + + var (exit, output) = RunVerify(cli, ledger, anchors); + + exit.Should().NotBe(0, + "cada linea que queda sigue firmada y verifica por si sola; lo que no cuadra es la " + + "raiz del arbol, que es para lo que el arbol esta.\n" + output); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyWiringTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyWiringTests.cs new file mode 100644 index 00000000..423bce22 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyWiringTests.cs @@ -0,0 +1,177 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Tracker.Domain.Audit.AuditEntry; +using Tracker.Domain.Governance.Transparency; +using Tracker.Infrastructure.Transparency; + +namespace Tracker.Tests.Infrastructure.Transparency; + +/// +/// GT-588 — el CABLE, que es lo que faltaba. +/// +/// La maquinaria de firma existía desde antes y estaba probada; lo que no existía era nada que la +/// invocara. `AuditService` recibía el grabador como parámetro opcional y nadie se lo pasaba, así +/// que la regla de gobernanza castigaba un ledger que no verifica mientras nadie emitía ninguno. +/// +/// Estas pruebas afirman el enchufe: que asentar una entrada en el expediente produce un statement +/// firmado, y que activarlo o no es una decisión de despliegue explícita. +/// +public sealed class TransparencyWiringTests +{ + private static byte[] Seed(byte fill) => [.. Enumerable.Repeat(fill, 32)]; + + private static IConfiguration Config(Dictionary values) => + new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + + private static AuditEntry Entrada(string action = "gate:approved") => + AuditEntry.Create( + Guid.NewGuid(), "GateSubmission", Guid.NewGuid().ToString(), action, + Guid.NewGuid(), AuditActor.Human(), + new Dictionary { ["verdict"] = "APPROVED" }).Value!; + + [Fact] + public void DesactivadoPorDefecto_NoDecoraNada() + { + var services = new ServiceCollection(); + services.AddScoped(); + services.AddTransparencySigning(Config([])); + + using var sp = services.BuildServiceProvider(); + sp.GetRequiredService().Should().BeOfType( + "activar la firma cambia lo que el producto promete sobre su propio expediente, y eso " + + "es una decision de despliegue, no un valor por defecto"); + } + + [Fact] + public void ActivadoSinClaves_FallaAlArrancar() + { + var services = new ServiceCollection(); + services.AddScoped(); + + var act = () => services.AddTransparencySigning( + Config(new() { ["Transparency:Enabled"] = "true" })); + + act.Should().Throw() + .WithMessage("*obligatorio*", + "caer a una clave de desarrollo produciria un ledger que PARECE firmado y no prueba " + + "nada, que es peor que no tener ledger porque induce confianza"); + } + + [Fact] + public void ElIssuerYElServicioDeTransparencia_NoPuedenCompartirClave() + { + var services = new ServiceCollection(); + services.AddScoped(); + + var misma = Convert.ToBase64String(Seed(0x33)); + var act = () => services.AddTransparencySigning(Config(new() + { + ["Transparency:Enabled"] = "true", + ["Transparency:IssuerKeySeedBase64"] = misma, + ["Transparency:TransparencyServiceKeySeedBase64"] = misma, + })); + + act.Should().Throw() + .WithMessage("*SEPARADA*", + "con una sola clave el recibo es una autoafirmacion: detecta accidentes y ediciones " + + "a posteriori, y NO establece no-repudio frente a quien lo produjo"); + } + + [Fact] + public async Task ActivadoConClaves_AsentarUnaEntradaEmiteUnStatementFirmado() + { + var ledgerPath = Path.Combine( + Path.GetTempPath(), "gt588-wiring-" + Guid.NewGuid().ToString("N"), "ledger.jsonl"); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddScoped(); + services.AddTransparencySigning(Config(new() + { + ["Transparency:Enabled"] = "true", + ["Transparency:LedgerPath"] = ledgerPath, + ["Transparency:IssuerKeySeedBase64"] = Convert.ToBase64String(Seed(0x44)), + ["Transparency:TransparencyServiceKeySeedBase64"] = Convert.ToBase64String(Seed(0x55)), + })); + + using var sp = services.BuildServiceProvider(); + var repo = sp.GetRequiredService(); + + repo.Should().BeOfType(); + + await repo.AddAsync(Entrada()); + await repo.AddAsync(Entrada("gate:rejected")); + + try + { + File.Exists(ledgerPath).Should().BeTrue(); + var lines = File.ReadAllLines(ledgerPath); + lines.Should().HaveCount(2, "una linea por decision asentada"); + + // La forma la valida el CLI en `TransparencyInteropTests`; aquí basta con que el + // statement firmado y su recibo estén presentes y encadenados. + foreach (var line in lines) + { + line.Should().Contain("\"transparentStatement\""); + line.Should().Contain("\"receipt\""); + line.Should().Contain("\"external-custody\"", + "la identidad debe declararse por lo que es; `development` la rechaza AUD-TRANSP-04"); + } + } + finally + { + Directory.Delete(Path.GetDirectoryName(ledgerPath)!, recursive: true); + } + } + + /// + /// Una decisión de gobierno YA TOMADA no puede perderse porque el firmante falle: eso + /// convertiría una garantía de auditoría en un punto de caída del producto. La ausencia se nota + /// después, y con dientes — `AUD-TRANSP-01` pone en rojo un ledger que no existe. + /// + [Fact] + public async Task SiLaFirmaFalla_LaDecisionSeAsientaIgual() + { + var inner = new FakeAuditRepository(); + using var issuer = Ed25519StatementSigner.FromExternalCustody("i", "evolith-tracker", Seed(0x66)); + using var ts = Ed25519StatementSigner.FromExternalCustody("t", "evolith-tracker-ts", Seed(0x77)); + + var repo = new TransparencySigningAuditRepository( + inner, + new MerkleTransparencyService(issuer, ts), + new ExplodingLedger(), + NullLogger.Instance); + + var act = async () => await repo.AddAsync(Entrada()); + + await act.Should().NotThrowAsync(); + inner.Added.Should().ContainSingle("la decision se asento aunque el ledger reventara"); + } + + private sealed class FakeAuditRepository : IAuditEntryRepository + { + public List Added { get; } = []; + + public Task AddAsync(AuditEntry aggregate, CancellationToken cancellationToken = default) + { + Added.Add(aggregate); + return Task.CompletedTask; + } + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) + => Task.FromResult(null); + public Task> GetByEntityAsync(string t, string i, CancellationToken ct = default) + => Task.FromResult>([]); + public Task> GetByTenantAsync(Guid t, CancellationToken ct = default) + => Task.FromResult>([]); + public Task> GetByActorAsync(Guid a, CancellationToken ct = default) + => Task.FromResult>([]); + } + + private sealed class ExplodingLedger : ITransparencyLedger + { + public Task AppendAsync(TransparencyLedgerEntry entry, CancellationToken ct = default) + => throw new IOException("el volumen del ledger no esta montado"); + } +} From 9a7aae6f87103d98b3f6731aa13a4b3b46bc2cb1 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sat, 1 Aug 2026 13:21:41 -0500 Subject: [PATCH 5/5] fix(ci): las pruebas de interoperabilidad se saltaban en silencio, y ADR T-056 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOS COSAS, y la primera es un fallo que introduje hoy. 1) EL FALSO VERDE. `TransparencyInteropTests` hacia `return` cuando no encontraba el CLI del Core, y el job `Backend` de CI solo hace checkout del Tracker. Es decir: las cuatro pruebas que justifican todo el cable de firma se saltaban en el pipeline y este reportaba verde sin haber comprobado una sola firma. Escribi el comentario diciendo que saltar sin la otra mitad presente es el falso verde que este gap existe para cerrar, y acto seguido hice exactamente eso. Ahora FALLAN. Solo hay dos desenlaces: verde porque se verifico, o rojo. Y una ruta EXPLICITA que no existe tambien falla, en vez de caer al descubrimiento automatico. Lo descubri escribiendo la prueba de este mismo arreglo: apunte `EVOLITH_CLI_MAIN` a una ruta inexistente esperando rojo y salio VERDE, porque el fallback encontro el CLI de mi maquina. Verificar contra un CLI distinto del que se pidio es peor que no verificar, porque el resultado parece valido. Se anade el job `transparency-interop`, que hace checkout de ambos repositorios, construye el CLI del Core y comprueba que expone `audit verify` ANTES de correr los tests — asi un CLI construido pero incompleto da un mensaje claro en vez de un fallo de proceso dentro de una asercion. Va en job propio porque construir el Core es caro y no tiene por que frenar al resto; `Backend` excluye la categoria. Verificadas las tres condiciones a mano: sin CLI da 4/4 en rojo, con CLI 4/4 en verde, y el filtro deja el job rapido en 1156 en vez de 1160. 2) ADR T-056 — las tres capas, ratificadas hoy al decidir donde vive la firma: · El sellado no lleva logica ni opinion. `payload` es opaco; lo unico que se fija es la regla de ESCRITURA. Por eso la interoperabilidad C#/TypeScript NO crea un problema de estandarizacion por tenant: un notario sella documentos distintos con un procedimiento invariable. · La validacion de contenido la configura el tenant. En cuanto la semantica de un cliente llega al motor como codigo, el motor es un catalogo de casos particulares y cada cliente nuevo es una release. · La IA propone, nunca decide. Un decisor probabilistico vuelve la garantia incomprobable: dos ejecuciones podrian diferir y nadie sabria cual vale. Se registra porque el error que previene es uno que un ingeniero razonable cometeria a proposito, creyendo que simplifica. Inventario regenerado (56 decisiones, 50 tablas — la tabla de aristas de GT-605 entro en el mapa de esquemas). `validate-docs` en verde sobre 357 ficheros. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 69 ++++++++++- DECISIONS.es.md | 1 + DECISIONS.md | 1 + README.es.md | 2 +- README.md | 2 +- docs/adrs/T-056-three-layer-separation.es.md | 107 ++++++++++++++++++ docs/adrs/T-056-three-layer-separation.md | 104 +++++++++++++++++ .../tracker-postgresql-data-design.es.md | 4 +- .../design/tracker-postgresql-data-design.md | 4 +- .../Transparency/TransparencyInteropTests.cs | 69 +++++++---- 10 files changed, 331 insertions(+), 32 deletions(-) create mode 100644 docs/adrs/T-056-three-layer-separation.es.md create mode 100644 docs/adrs/T-056-three-layer-separation.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4928c1b..fd2089da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,11 @@ jobs: done - name: Test - run: dotnet test Tracker.sln --no-build -c Release --logger "trx;LogFileName=test-results.trx" + # GT-588 — se EXCLUYE la categoria `Interop`, que necesita el CLI del Core construido. + # Corre en su propio job (`transparency-interop`), que si lo aporta. Sin esta exclusion, + # este job seria rojo; con el `return` silencioso que tenia antes, era verde sin comprobar + # nada, que es peor. + run: dotnet test Tracker.sln --no-build -c Release --filter "Category!=Interop" --logger "trx;LogFileName=test-results.trx" - name: Smoke — la aplicacion arranca LIMPIA contra Postgres # Anadido tras la regresion del 2026-07-19: el filtro global por tenant de CD-27 @@ -113,6 +117,69 @@ jobs: path: src/apps/tracker-api/**/TestResults/*.trx if-no-files-found: ignore + # GT-588 — la unica prueba de que el firmante C# y el verificador TypeScript producen y leen los + # MISMOS bytes. Entre ambos hay JSON canonico, CBOR determinista, cabeceras COSE y un arbol de + # Merkle: cuatro capas donde un solo byte de diferencia lo rompe todo, y ninguna de ellas se + # comprueba comparando estructuras C# contra estructuras C#. + # + # Va en su propio job porque necesita construir el CLI del Core, que es caro y no tiene por que + # frenar al resto. Y MUERDE: los tests fallan —no se saltan— cuando no encuentran el CLI, porque + # una prueba de interoperabilidad que pasa sin la otra mitad presente reporta como verificado algo + # que nadie miro. + transparency-interop: + name: Transparency interop (C# firma · TypeScript verifica) + runs-on: ubuntu-latest + + env: + DOTNET_NOLOGO: "true" + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + + steps: + - name: Checkout Tracker + uses: actions/checkout@v4 + with: + path: tracker + + - name: Checkout Evolith Core + uses: actions/checkout@v4 + with: + repository: beyondnetcode/evolith_arch32 + token: ${{ secrets.CORE_REPO_TOKEN }} + path: core + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: core/package-lock.json + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Build the Core CLI (the verifier) + working-directory: core + run: | + npm ci + npm run build + + - name: Verify the CLI exposes `audit verify` + working-directory: core + run: | + set -euo pipefail + # Se comprueba ANTES de correr los tests para que un CLI construido pero sin el comando + # de un mensaje claro, en vez de un fallo de proceso dentro de una asercion. + test -f src/sdk/cli/dist/main.js + node src/sdk/cli/dist/main.js audit verify --help > /dev/null + + - name: Cross-language interop tests + working-directory: tracker/src/apps/tracker-api + env: + EVOLITH_CLI_MAIN: ${{ github.workspace }}/core/src/sdk/cli/dist/main.js + run: dotnet test Tracker.sln --filter "Category=Interop" --logger "trx;LogFileName=interop-results.trx" + frontend: name: Frontend (lint + typecheck + build) runs-on: ubuntu-latest diff --git a/DECISIONS.es.md b/DECISIONS.es.md index 4b0df05a..82f8b83d 100644 --- a/DECISIONS.es.md +++ b/DECISIONS.es.md @@ -72,3 +72,4 @@ _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.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`. | +| T-056 | El sellado no lleva lógica, la validación de contenido es del tenant, y la IA propone pero nunca decide | Definir | Accepted | [T-056](./docs/adrs/T-056-three-layer-separation.es.md) | Tres capas con frontera dura, ratificadas al cablear `GT-588`. **(1) El sellado no mira el contenido:** `payload` es opaco y lo único que se fija es la regla de ESCRITURA (claves ordenadas en toda profundidad, comparación ordinal para que el idioma de la máquina no las reordene, una codificación). Por eso la interoperabilidad C#/TypeScript no crea un problema de estandarización por tenant — un notario sella documentos distintos con un procedimiento invariable. **(2) La validación de contenido la configura el tenant:** en cuanto la semántica de un cliente llega al motor como código, el motor pasa a ser un catálogo de casos particulares y cada cliente nuevo es una release. Misma frontera que `T-039` aplicada al contenido. **(3) La IA propone, nunca decide:** un decisor probabilístico vuelve la garantía incomprobable, porque dos ejecuciones podrían diferir y nadie sabría cuál vale; detrás tiene que haber un verificador determinista y, si la propuesta es probabilística, su confianza se muestra a quien la ratifica (`GT-590`, `GT-584`). | diff --git a/DECISIONS.md b/DECISIONS.md index ea948131..39e51e47 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -80,5 +80,6 @@ _Nota: Las decisiones universales se heredan del Upstream Base ([evolith_arch32] | 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`. | +| T-056 | El sellado no lleva lógica, la validación de contenido es del tenant, y la IA propone pero nunca decide | Definir | Accepted | [T-056](./docs/adrs/T-056-three-layer-separation.md) | Tres capas con frontera dura, ratificadas al cablear `GT-588`. **(1) El sellado no mira el contenido:** `payload` es opaco y lo único que se fija es la regla de ESCRITURA (claves ordenadas en toda profundidad, comparación ordinal para que el idioma de la máquina no las reordene, una codificación). Por eso la interoperabilidad C#/TypeScript no crea un problema de estandarización por tenant — un notario sella documentos distintos con un procedimiento invariable. **(2) La validación de contenido la configura el tenant:** en cuanto la semántica de un cliente llega al motor como código, el motor pasa a ser un catálogo de casos particulares y cada cliente nuevo es una release. Misma frontera que `T-039` aplicada al contenido. **(3) La IA propone, nunca decide:** un decisor probabilístico vuelve la garantía incomprobable, porque dos ejecuciones podrían diferir y nadie sabría cuál vale; detrás tiene que haber un verificador determinista y, si la propuesta es probabilística, su confianza se muestra a quien la ratifica (`GT-590`, `GT-584`). | > **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/README.es.md b/README.es.md index 92f4b877..f3191938 100644 --- a/README.es.md +++ b/README.es.md @@ -8,7 +8,7 @@ [![Plataforma](https://img.shields.io/badge/.NET_10_%7C_PostgreSQL_%7C_React_19-informational?style=for-the-badge)]() [![Arquitectura](https://img.shields.io/badge/Evolith-Satélite-blueviolet?style=for-the-badge)](https://github.com/beyondnetcode/evolith_arch32) -[![ADRs](https://img.shields.io/badge/ADRs-55_decisiones-orange?style=for-the-badge)](./DECISIONS.md) +[![ADRs](https://img.shields.io/badge/ADRs-56_decisiones-orange?style=for-the-badge)](./DECISIONS.md) [![Licencia](https://img.shields.io/badge/Licencia-Dual_License-informational?style=for-the-badge)](./LICENSE) diff --git a/README.md b/README.md index b8174ab3..ff9d7f2a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Platform](https://img.shields.io/badge/.NET_10_%7C_PostgreSQL_%7C_React_19-informational?style=for-the-badge)]() [![Architecture](https://img.shields.io/badge/Evolith-Satellite_Product-blueviolet?style=for-the-badge)](https://github.com/beyondnetcode/evolith_arch32) -[![ADRs](https://img.shields.io/badge/ADRs-55_decisions-orange?style=for-the-badge)](./DECISIONS.md) +[![ADRs](https://img.shields.io/badge/ADRs-56_decisions-orange?style=for-the-badge)](./DECISIONS.md) [![License](https://img.shields.io/badge/License-Dual_License-informational?style=for-the-badge)](./LICENSE) diff --git a/docs/adrs/T-056-three-layer-separation.es.md b/docs/adrs/T-056-three-layer-separation.es.md new file mode 100644 index 00000000..c2249786 --- /dev/null +++ b/docs/adrs/T-056-three-layer-separation.es.md @@ -0,0 +1,107 @@ +--- +adr: T-056 +title: El sellado no lleva lógica, la validación de contenido es del tenant, y la IA propone pero nunca decide +status: Accepted +date: 2026-08-01 +tags: [EvolithSatellite, governance, transparency, ai, multi-tenancy, architecture] +authority: Decisión de producto, ratificada el 2026-08-01 al cablear GT-588 +relates: [T-039 el Core recomienda el tenant decide, T-055 ingesta de evidencia iniciada por el Core, core/ADR-0111 costura del proveedor probabilístico, core/ADR-0101 Core sin estado] +gaps: [GT-588, GT-590, GT-584] +--- + +# ADR T-056 — Tres capas que no deben mezclarse + +## Status + +Aceptado (2026-08-01). Ratificado por el propietario del producto al decidir dónde vive el cable de +firma de GT-588, a raíz de una pregunta directa: *«¿no te parece que estandarizar cada contenido es +muy complejo y variable entre tenants?»*. La respuesta es que **no estandarizamos el contenido en +absoluto** — y escribirlo es el sentido de este registro, porque el error que previene es uno que un +ingeniero razonable cometería a propósito. + +## Context + +Tres asuntos distintos se venían discutiendo como si fueran uno, y confundirlos produce un fallo +concreto y caro: un motor que acumula un catálogo de reglas por cliente, donde cada tenant nuevo +obliga a tocar el núcleo. + +El detonante inmediato fue el ledger de transparencia. Una decisión firmada tiene que escribirse +byte a byte igual por un firmante en C# y un verificador en TypeScript, lo que invita a pensar que +«la forma de una decisión» debe estandarizarse entre tenants. No debe, y la razón vale mucho más +allá del sellado. + +Por separado surgió la posibilidad de que un tenant enchufe **su propia IA**, anclada a nuestra +configuración, para recibir contenido y contexto y validarlo. Es una buena idea en la capa correcta +y peligrosa en la equivocada. + +## Decision + +**Tres capas, con frontera dura entre cada una.** + +### 1. El sellado no lleva lógica ni opinión + +La capa de transparencia/firma **no** mira el contenido, no lo juzga y no lo interpreta. Coge lo que +llegue y lo escribe siempre igual para poder sellarlo — como una báscula que pesa un paquete sin +abrirlo. + +En concreto: `DecisionStatement.payload` es opaco. Lo que se fija es la regla de **escritura** +(claves ordenadas en toda profundidad, comparación ordinal para que el idioma de una máquina no las +reordene, una sola codificación), no el contenido. Esa regla es universal: sirve para cualquier +payload, incluido uno que un tenant invente mañana y que nunca hayamos visto. + +Por eso la interoperabilidad **no** crea un problema de estandarización por tenant. Un notario sella +documentos radicalmente distintos con un único procedimiento invariable. Lo que varía es el +documento; lo que no varía es el acto de sellar. + +### 2. La validación de contenido la configura el tenant, no la cableamos nosotros + +No escribimos lógica propia que juzgue contenido cuyo significado define cada tenant. Rulesets, +criterios y compuertas son configuración. En cuanto la semántica de un cliente llega al motor como +código, el motor se convierte en un catálogo de casos particulares y cada cliente nuevo es una +release. + +Es la misma frontera que `T-039` ya traza para la autoridad de excepción (el Core recomienda, el +tenant decide), aplicada al contenido. + +### 3. La IA propone; nunca es la autoridad final + +La IA de un tenant puede leer contenido y contexto y **sugerir**: «esto parece incompleto», «esto no +encaja con lo que declaraste». No puede decidir, porque un decisor probabilístico vuelve la garantía +incomprobable — dos ejecuciones podrían diferir y nadie sabría cuál vale. + +El reparto sano es **la IA propone → algo determinista verifica → un humano confirma cuando importa**, +que es la costura que `core/ADR-0111` ya define y que las aprobaciones de runtime de `GT-590` ya +implementan, incluido el campo `confidence`, que existe precisamente para decirle al humano que está +ratificando una **conjetura** y no un hecho. + +## Consequences + +- La capa de sellado se mantiene pequeña y estable. No puede adquirir comportamiento por tenant, + porque no tiene dónde ponerlo. +- Los vectores dorados del renderizado canónico son **cortos y no crecen con los tenants**: fijan la + regla (acentos, anidamiento, campos vacíos, textos largos), no los casos de negocio. +- Cualquier propuesta de «validar X para el tenant Y» en código del motor queda rechazada por este + registro; su sitio es la configuración. +- Cualquier propuesta de que un modelo emita un veredicto vinculante queda rechazada; detrás tiene + que haber un verificador determinista, y cuando la propuesta sea probabilística su confianza debe + mostrarse a quien la ratifica. +- El coste es real y se asume: la validación por configuración es más difícil de autorar que unas + reglas cableadas, y la costura de la IA necesita una superficie de confirmación. Ambos se pagan una + vez, no por tenant. + +## Validation + +- El ledger de `GT-588` lleva `payload` como miembro opaco; ningún camino de código lo lee para + decidir. El renderizador canónico ordena claves y jamás inspecciona valores. +- Las aprobaciones de runtime de `GT-590` ya llevan `confidence`, y la superficie de aprobación lo + muestra como una conjetura que se ratifica. +- Una violación futura se detecta preguntando de cualquier validación nueva: *¿está leyendo + significado definido por el tenant dentro de código del motor?* Si la respuesta es sí, su sitio es + la capa 2 como configuración. + +## References + +- `T-039` — el Core recomienda, el tenant decide. +- `core/ADR-0101` — el Core es un motor de evaluación sin estado; no puede sostener un ledger. +- `core/ADR-0111` — la costura del proveedor probabilístico. +- RFC 9943 — el Servicio de Transparencia es una entidad separada del Issuer. diff --git a/docs/adrs/T-056-three-layer-separation.md b/docs/adrs/T-056-three-layer-separation.md new file mode 100644 index 00000000..3cccc3e9 --- /dev/null +++ b/docs/adrs/T-056-three-layer-separation.md @@ -0,0 +1,104 @@ +--- +adr: T-056 +title: Sealing carries no logic, content validation belongs to the tenant, and AI proposes but never decides +status: Accepted +date: 2026-08-01 +tags: [EvolithSatellite, governance, transparency, ai, multi-tenancy, architecture] +authority: Product decision, ratified 2026-08-01 while wiring GT-588 +relates: [T-039 core recommends tenant decides, T-055 core-initiated evidence ingest, core/ADR-0111 probabilistic provider seam, core/ADR-0101 stateless core] +gaps: [GT-588, GT-590, GT-584] +--- + +# ADR T-056 — Three layers that must not be mixed + +## Status + +Accepted (2026-08-01). Ratified by the product owner while deciding where the GT-588 signing wire +belongs, in response to a direct question: *"isn't standardising every content very complex and +variable per tenant?"* The answer is that we do not standardise content at all — and writing that +down is the point of this record, because the mistake it prevents is one a reasonable engineer +would otherwise make on purpose. + +## Context + +Three distinct concerns kept being discussed as if they were one, and conflating them produces a +specific, expensive failure: an engine that grows a catalogue of per-customer rules, where every +new tenant means touching the core. + +The immediate trigger was the transparency ledger. A signed decision has to be written down +byte-for-byte identically by a C# signer and a TypeScript verifier, which invites the question of +whether "the shape of a decision" must therefore be standardised across tenants. It must not, and +the reason generalises well beyond signing. + +Separately, the possibility was raised of letting a tenant plug in **their own AI**, anchored to +our configuration, to receive content plus context and validate it. That is a good idea in the +right layer and a dangerous one in the wrong layer. + +## Decision + +**Three layers, with a hard boundary between each.** + +### 1. Sealing carries no logic and no opinion + +The transparency/signing layer does **not** inspect content, does not judge it, and does not +interpret it. It takes whatever arrives and writes it down the same way every time so that it can +be sealed — like a scale that weighs a parcel without opening it. + +Concretely: `DecisionStatement.payload` is opaque. What is fixed is the *rendering* rule (keys +sorted at every depth, ordinal comparison so a machine's locale cannot reorder them, one encoding), +not the content. That rule is universal: it works for any payload, including one a tenant invents +tomorrow that we have never seen. + +This is why the interop concern does **not** create a per-tenant standardisation problem. A notary +seals wildly different documents with one unvarying procedure. What varies is the document; what +does not vary is the act of sealing. + +### 2. Content validation is configured by the tenant, not wired by us + +We do not write our own logic that judges content whose meaning each tenant defines. Rulesets, +criteria and gates are configuration. The moment a tenant's semantics reach the engine as code, the +engine becomes a catalogue of special cases and every new customer is a release. + +This is the same boundary `T-039` already draws for exception authority (the Core recommends, the +tenant decides), applied to content. + +### 3. AI proposes; it never is the final authority + +A tenant's AI may read content and context and **suggest**: "this looks incomplete", "this does not +match what you declared". It may not decide, because a probabilistic decider makes the guarantee +uncheckable — two runs could differ and nobody could say which one counts. + +The sound division of labour is **AI proposes → something deterministic verifies → a human confirms +when it matters**, which is the seam `core/ADR-0111` already defines and which `GT-590`'s runtime +approvals already implement, including the `confidence` field that exists precisely to tell the +human they are ratifying a **guess** and not a fact. + +## Consequences + +- The signing layer stays small and stable. It cannot acquire per-tenant behaviour, because it has + no place to put it. +- Golden test vectors for the canonical rendering stay **short and do not grow with tenants**: they + pin the rule (accents, nesting, empty fields, long text), not the business cases. +- Any proposal to "validate X for tenant Y" in engine code is refused by this record; it belongs in + configuration. +- Any proposal to let a model emit a binding verdict is refused; a deterministic verifier must sit + behind it, and where the proposal is probabilistic its confidence must be surfaced to whoever + ratifies it. +- The cost is real and accepted: configuration-driven validation is harder to author than hardcoded + rules, and the AI-proposes seam needs a confirmation surface. Both are paid once, not per tenant. + +## Validation + +- `GT-588`'s ledger carries `payload` as an opaque member; no code path reads it to make a decision. + The canonical renderer sorts keys and never inspects values. +- `GT-590`'s runtime approvals already carry `confidence`, and the approval surface displays it as a + conjecture being ratified. +- A future violation is detectable by asking of any new validation: *is this reading tenant-defined + meaning inside engine code?* If yes, it belongs in layer 2 as configuration. + +## References + +- `T-039` — the Core recommends, the tenant decides. +- `core/ADR-0101` — the Core is a stateless evaluation engine; it cannot hold a ledger. +- `core/ADR-0111` — the probabilistic provider seam. +- RFC 9943 — the Transparency Service is an entity separate from the Issuer. diff --git a/reference/specs/design/tracker-postgresql-data-design.es.md b/reference/specs/design/tracker-postgresql-data-design.es.md index afcf316f..d78cdb33 100644 --- a/reference/specs/design/tracker-postgresql-data-design.es.md +++ b/reference/specs/design/tracker-postgresql-data-design.es.md @@ -17,14 +17,14 @@ ### 1.1 Mapa de Schemas -**8 schemas · 49 tablas.** Derivado de los `ModelSnapshot` de EF Core, que son lo que la base tiene de verdad. +**8 schemas · 50 tablas.** Derivado de los `ModelSnapshot` de EF Core, que son lo que la base tiene de verdad. | Schema | Contexto EF | Tablas | Tablas (derivadas del snapshot de EF) | |--------|------------|--------|---------------------------------------| | `masterdata` | `TenantProjectionDbContext` | 4 | `InboxState`, `OutboxMessage`, `OutboxState`, `tenant_projection` | | `tracker_construction` | `TrackerDbContext` | 1 | `peer_reviews` | | `tracker_geo` | `TrackerDbContext` | 13 | `catalog_release`, `country`, `currency`, `exchange_rate`, `language`, `locale_profile`, `location`, `location_code`, `location_i18n`, `location_level`, `tax_rate`, `tax_scheme`, `time_zone` | -| `tracker_governance` | `TrackerDbContext` | 21 | `artifact_field_schemas`, `audit_entries`, `core_evaluation_transactions`, `evidence_records`, `exception_requests`, `gate_decisions`, `gate_policies`, `gate_submissions`, `initiatives`, `opportunities`, `phase_artifacts`, `phase_gate_states`, `phase_progressions`, `products`, `provider_connections`, `publication_outbox`, `runtime_approvals`, `sdlc_executions`, `technical_blueprints`, `tenant_intelligence`, `tenants` | +| `tracker_governance` | `TrackerDbContext` | 22 | `artifact_field_schemas`, `audit_entries`, `core_evaluation_transactions`, `evidence_edges`, `evidence_records`, `exception_requests`, `gate_decisions`, `gate_policies`, `gate_submissions`, `initiatives`, `opportunities`, `phase_artifacts`, `phase_gate_states`, `phase_progressions`, `products`, `provider_connections`, `publication_outbox`, `runtime_approvals`, `sdlc_executions`, `technical_blueprints`, `tenant_intelligence`, `tenants` | | `tracker_intake` | `TrackerDbContext` | 5 | `intake_dead_letter`, `intake_outbox`, `ppm_endpoints`, `strategic_baselines`, `strategic_initiatives` | | `tracker_metrics` | `TrackerDbContext` | 1 | `phase_sla_policies` | | `tracker_qa` | `TrackerDbContext` | 3 | `defects`, `test_cycles`, `test_executions` | diff --git a/reference/specs/design/tracker-postgresql-data-design.md b/reference/specs/design/tracker-postgresql-data-design.md index 6164e00c..c7e41417 100644 --- a/reference/specs/design/tracker-postgresql-data-design.md +++ b/reference/specs/design/tracker-postgresql-data-design.md @@ -17,14 +17,14 @@ ### 1.1 Schema Map -**8 schemas · 49 tables.** Derived from the EF Core `ModelSnapshot` files, which are what the database actually has. +**8 schemas · 50 tables.** Derived from the EF Core `ModelSnapshot` files, which are what the database actually has. | Schema | EF context | Tables | Tables (derived from the EF snapshot) | |--------|------------|--------|---------------------------------------| | `masterdata` | `TenantProjectionDbContext` | 4 | `InboxState`, `OutboxMessage`, `OutboxState`, `tenant_projection` | | `tracker_construction` | `TrackerDbContext` | 1 | `peer_reviews` | | `tracker_geo` | `TrackerDbContext` | 13 | `catalog_release`, `country`, `currency`, `exchange_rate`, `language`, `locale_profile`, `location`, `location_code`, `location_i18n`, `location_level`, `tax_rate`, `tax_scheme`, `time_zone` | -| `tracker_governance` | `TrackerDbContext` | 21 | `artifact_field_schemas`, `audit_entries`, `core_evaluation_transactions`, `evidence_records`, `exception_requests`, `gate_decisions`, `gate_policies`, `gate_submissions`, `initiatives`, `opportunities`, `phase_artifacts`, `phase_gate_states`, `phase_progressions`, `products`, `provider_connections`, `publication_outbox`, `runtime_approvals`, `sdlc_executions`, `technical_blueprints`, `tenant_intelligence`, `tenants` | +| `tracker_governance` | `TrackerDbContext` | 22 | `artifact_field_schemas`, `audit_entries`, `core_evaluation_transactions`, `evidence_edges`, `evidence_records`, `exception_requests`, `gate_decisions`, `gate_policies`, `gate_submissions`, `initiatives`, `opportunities`, `phase_artifacts`, `phase_gate_states`, `phase_progressions`, `products`, `provider_connections`, `publication_outbox`, `runtime_approvals`, `sdlc_executions`, `technical_blueprints`, `tenant_intelligence`, `tenants` | | `tracker_intake` | `TrackerDbContext` | 5 | `intake_dead_letter`, `intake_outbox`, `ppm_endpoints`, `strategic_baselines`, `strategic_initiatives` | | `tracker_metrics` | `TrackerDbContext` | 1 | `phase_sla_policies` | | `tracker_qa` | `TrackerDbContext` | 3 | `defects`, `test_cycles`, `test_executions` | diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyInteropTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyInteropTests.cs index f1e4c440..67008ecd 100644 --- a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyInteropTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyInteropTests.cs @@ -13,31 +13,54 @@ namespace Tracker.Tests.Infrastructure.Transparency; /// solo byte de diferencia en cualquiera de esas cuatro capas y ninguna firma verifica. Comparar /// estructuras C# contra estructuras C# no probaría nada de eso. /// -/// Así que estas pruebas generan un ledger REAL y se lo dan al CLI REAL. Si el CLI no está -/// disponible se SALTAN en vez de dar verde: una prueba de interoperabilidad que pasa sin la otra -/// mitad presente es exactamente el falso verde que este gap existe para cerrar. +/// Así que estas pruebas generan un ledger REAL y se lo dan al CLI REAL. +/// +/// SOLO HAY DOS DESENLACES ACEPTABLES: verde porque se verificó, o rojo. Nunca verde porque la +/// otra mitad no estaba. La primera versión de este fichero hacía `return` cuando no encontraba el +/// CLI, y el job `Backend` de CI no hace checkout del Core — así que estas pruebas se saltaban en +/// silencio y el pipeline reportaba verde sin haber comprobado una sola firma. Era exactamente el +/// falso verde que este gap existe para cerrar, escrito en el fichero que lo denuncia. +/// +/// Quien las ejecuta debe aportar el CLI. En CI lo hace el job `Transparency interop`; en local, +/// el Core suele estar en el directorio hermano y `FindCli` lo encuentra solo. /// +[Trait("Category", "Interop")] public sealed class TransparencyInteropTests { - /// El CLI vive en el repositorio del Core, al lado de éste. - private static string? FindCli() + /// + /// Localiza el CLI del Core. FALLA si no está: no verificar no puede parecerse a verificar. + /// + private static string RequireCli() { - var candidates = new[] + // Una ruta EXPLÍCITA que no existe es un error, no una sugerencia. Caer al descubrimiento + // automático cuando alguien apuntó a un sitio concreto haría que CI verificara contra un + // CLI distinto del que se le pidió — y lo descubrí escribiendo la prueba de este mismo + // fallo: la puse a apuntar a una ruta inexistente y pasó en verde, porque el descubrimiento + // encontró el CLI de mi máquina. + var explicitPath = Environment.GetEnvironmentVariable("EVOLITH_CLI_MAIN"); + if (!string.IsNullOrWhiteSpace(explicitPath)) { - Environment.GetEnvironmentVariable("EVOLITH_CLI_MAIN"), - "/Users/beyondnet/Source/evolith/src/sdk/cli/dist/main.js", - Path.Combine(AppContext.BaseDirectory, "../../../../../../../../evolith/src/sdk/cli/dist/main.js"), - }; + if (File.Exists(explicitPath)) return Path.GetFullPath(explicitPath); - foreach (var candidate in candidates) - { - if (!string.IsNullOrWhiteSpace(candidate) && File.Exists(candidate)) - { - return Path.GetFullPath(candidate); - } + throw new InvalidOperationException( + $"GT-588: EVOLITH_CLI_MAIN apunta a `{explicitPath}`, que no existe. No se cae al " + + "descubrimiento automatico a proposito: verificar contra un CLI distinto del que " + + "se pidio es peor que no verificar, porque el resultado parece valido."); } - return null; + // Sin ruta explícita, el Core suele estar en el directorio hermano. + var sibling = Path.Combine( + AppContext.BaseDirectory, "../../../../../../../../evolith/src/sdk/cli/dist/main.js"); + if (File.Exists(sibling)) return Path.GetFullPath(sibling); + + throw new InvalidOperationException( + "GT-588: no se encontro `evolith audit verify`, asi que la interoperabilidad entre el " + + "firmante C# y el verificador TypeScript NO se ha comprobado.\n\n" + + "Esto es un FALLO y no un salto, a proposito: una prueba de interoperabilidad que " + + "pasa sin la otra mitad presente reporta como verificado algo que nadie miro.\n\n" + + "Para satisfacerla, apunte EVOLITH_CLI_MAIN al `dist/main.js` del CLI del Core, o " + + "tenga el repositorio del Core construido en el directorio hermano. En CI lo aporta " + + "el job `Transparency interop`."); } private static (int ExitCode, string Output) RunVerify( @@ -129,8 +152,7 @@ private static (string LedgerPath, string AnchorsPath, string Dir) WriteLedger( [Fact] public void ElCliDelCoreVerificaUnLedgerFirmadoPorElTracker() { - var cli = FindCli(); - if (cli is null) return; + var cli = RequireCli(); var (ledger, anchors, dir) = WriteLedger(decisions: 5); try @@ -155,8 +177,7 @@ public void ElCliDelCoreVerificaUnLedgerFirmadoPorElTracker() [Fact] public void UnaDecisionEditadaDespuesDeFirmar_PoneElLedgerEnRojo() { - var cli = FindCli(); - if (cli is null) return; + var cli = RequireCli(); var (ledger, anchors, dir) = WriteLedger(decisions: 3); try @@ -191,8 +212,7 @@ public void UnaDecisionEditadaDespuesDeFirmar_PoneElLedgerEnRojo() [Fact] public void UnLedgerFirmadoEnDesarrollo_SeRechazaAunqueLosRecibosVerifiquen() { - var cli = FindCli(); - if (cli is null) return; + var cli = RequireCli(); var (ledger, anchors, dir) = WriteLedger(decisions: 2, externalCustody: false); try @@ -221,8 +241,7 @@ public void UnLedgerFirmadoEnDesarrollo_SeRechazaAunqueLosRecibosVerifiquen() [Fact] public void UnaEntradaBorradaDelMedio_PoneElLedgerEnRojo() { - var cli = FindCli(); - if (cli is null) return; + var cli = RequireCli(); var (ledger, anchors, dir) = WriteLedger(decisions: 4); try