From ca4b8cf7059a5f4c6f046243f67c2cf544c40120 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sat, 1 Aug 2026 09:03:24 -0500 Subject: [PATCH] feat(governance): el expediente dice QUE se aprobo, no solo que algo se aprobo (GT-590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hasta aqui una `RuntimeApproval` llevaba solo `skillId` + `intent`, asi que TODA decision tomada a traves de una misma capacidad se veia igual: igual para el humano que la concede e igual en el ledger que la registra. Eso basta para «puede ejecutarse esta capacidad?» y no para «es ESTA la correspondencia correcta?» — que es la pregunta que el Core empezo a enrutar por esta compuerta. El sujeto se espeja del contrato del Core (`approval.port.ts`, `ApprovalSubject`) con sus mismos nombres — `kind`/`ref`/`summary`/`confidence`/`payload` — y no se reinterpreta. Renombrarlos haria que el dia que el contrato cambiara, el campo llegara y se descartara en silencio. Decisiones que el codigo no explica solo: · TODO el sujeto es opcional: los runtimes ya desplegados no lo envian y no pueden romperse por esta ficha. Pero un sujeto PRESENTE y a medias se rechaza con 400 en vez de recortarse — un `kind` sin `summary` dejaria al humano decidiendo a ciegas mientras cree que ve el objeto, que es peor que no ver nada. · El payload se valida como OBJETO JSON en el dominio, no en la base: un jsonb invalido reventaria en `SaveChanges` como un 500 opaco cuando lo cierto es que el cuerpo venia mal. · `confidence` fuera de [0,1] no es «poco fiable», es que quien la emitio no habla de una probabilidad. Existe para DECIRLE al humano que ratifica una CONJETURA (GT-584), y la pantalla la muestra por eso. · El indice de sujeto NO es unico, a diferencia del de `correlationId`: un mismo objeto puede volver a someterse tras un rechazo o una expiracion, y unificarlo convertiria un reintento legitimo en un error de base. · Rehidratar no revalida. Revalidar haria que un endurecimiento posterior de las reglas volviera ilegibles filas historicas que fueron validas cuando se escribieron. · La cola humana estrena DTO propio en vez de ampliar `RuntimeApprovalResponse`: aquel es el espejo del contrato de MAQUINA y solo lleva el veredicto. Quien concede necesita otra cosa — que capacidad, sobre que objeto, desde cuando espera y cuando vence. `AgentGovernanceMd3` declaraba la cola HITL como capacidad ausente del BFF. Ya no lo es: la pantalla la lista y decide sobre ella. Lo que sigue faltando —y ahora lo dice con precision— es el registro de skills. Verificado: `dotnet ef migrations has-pending-model-changes` confirma que el modelo y el snapshot cuadran; 34 tests nuevos (79 de RuntimeApproval en verde); nx typecheck, lint y build del front en verde. La suite completa pasa de 1066 a 1101 tests superados, con los mismos 10 fallos que develop ya tiene sin Postgres local. Co-Authored-By: Claude Opus 5 --- .../RuntimeApproval/RuntimeApproval.cs | 8 +- .../RuntimeApproval/RuntimeApprovalProps.cs | 7 + .../RuntimeApproval/RuntimeApprovalSubject.cs | 148 + ...1456_AddRuntimeApprovalSubject.Designer.cs | 3759 +++++++++++++++++ ...0260731191456_AddRuntimeApprovalSubject.cs | 99 + .../TrackerDbContextModelSnapshot.cs | 27 + .../RuntimeApprovalRecordConfiguration.cs | 18 + .../Entities/RuntimeApprovalRecord.cs | 10 + .../PostgreSqlRuntimeApprovalRepository.cs | 10 + .../Integration/RuntimeApprovalEndpoints.cs | 134 +- .../Domain/Governance/RuntimeApprovalTests.cs | 140 + .../RuntimeApprovalPersistenceTests.cs | 97 + .../RuntimeApprovalEndpointTests.cs | 118 + src/apps/tracker-web/src/api/endpoints.ts | 18 + src/apps/tracker-web/src/api/hooks.ts | 32 + src/apps/tracker-web/src/api/types.ts | 52 + .../screens/md3/AgentGovernanceMd3.tsx | 145 +- 17 files changed, 4808 insertions(+), 14 deletions(-) create mode 100644 src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApprovalSubject.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731191456_AddRuntimeApprovalSubject.Designer.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731191456_AddRuntimeApprovalSubject.cs diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApproval.cs b/src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApproval.cs index 0525ba82..5b2624ab 100644 --- a/src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApproval.cs +++ b/src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApproval.cs @@ -26,6 +26,10 @@ private RuntimeApproval(RuntimeApprovalProps props) : base(props) { } public string? CorrelationId => Props.CorrelationId; public string? RequestedBy => Props.RequestedBy; public string? ExecutionMode => Props.ExecutionMode; + + /// GT-590 — el objeto bajo decisión, cuando el runtime lo declaró. Nulo si no. + public RuntimeApprovalSubject? Subject => Props.Subject; + public string Status => Props.Status; public string? Approver => Props.Approver; public string? Reason => Props.Reason; @@ -43,7 +47,8 @@ public static Result Submit( string? initiativeId = null, string? correlationId = null, string? requestedBy = null, - string? executionMode = null) + string? executionMode = null, + RuntimeApprovalSubject? subject = null) { if (tenantId == Guid.Empty) return Result.Failure("RuntimeApproval.TenantRequired"); if (string.IsNullOrWhiteSpace(skillId)) return Result.Failure("RuntimeApproval.SkillRequired"); @@ -63,6 +68,7 @@ public static Result Submit( CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId, RequestedBy = requestedBy, ExecutionMode = executionMode, + Subject = subject, Status = RuntimeApprovalStatus.Pending, RequestedAtUtc = now, ExpiresAtUtc = now.Add(ttl), diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApprovalProps.cs b/src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApprovalProps.cs index 19a4357d..ea6c0429 100644 --- a/src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApprovalProps.cs +++ b/src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApprovalProps.cs @@ -24,6 +24,13 @@ public sealed record RuntimeApprovalProps : IProps public string? ExecutionMode { get; init; } + /// + /// GT-590 — QUÉ se decide, cuando `skillId` + `intent` no lo dicen. Nulo en toda aprobación + /// abierta por un runtime anterior a GT-590, y nulo sigue siendo válido: el sujeto es opcional + /// en el contrato del Core y hacerlo obligatorio aquí rompería a los runtimes desplegados. + /// + public RuntimeApprovalSubject? Subject { get; init; } + public string Status { get; init; } = RuntimeApprovalStatus.Pending; /// Humano que resolvio. Un `approved` sin aprobador deja el ledger sin responsable. diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApprovalSubject.cs b/src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApprovalSubject.cs new file mode 100644 index 00000000..f3461479 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/RuntimeApproval/RuntimeApprovalSubject.cs @@ -0,0 +1,148 @@ +using System.Text.Json; + +namespace Tracker.Domain.Governance.RuntimeApproval; + +/// +/// GT-590 — QUÉ se está decidiendo, cuando el id de la capacidad no lo dice. +/// +/// Hasta aqui una llevaba solo `skillId` + `intent`, asi que TODA +/// decision tomada a traves de una misma capacidad se veia igual: igual para el humano que la +/// concede e igual en el ledger que la registra. Eso basta para «¿puede ejecutarse esta +/// capacidad?» y NO basta para «¿es ESTA la correspondencia correcta?» —una decision de gobierno +/// sobre un objeto concreto, que es lo que el Core empezo a enrutar por esta compuerta. +/// +/// El contrato lo fija el Core en `approval.port.ts` (`ApprovalSubject`) y aqui se ESPEJA, no se +/// reinterpreta: +/// · Kind — familia del sujeto, con espacio de nombres de la capacidad que la introduce. +/// · Ref — referencia estable al objeto concreto, unica dentro de su Kind. +/// · Summary — una linea sobre la que un humano puede decidir sin abrir nada mas. +/// · Confidence — confianza de la propuesta que se ratifica, si vino de un proponente +/// probabilistico. Existe para DECIRLE al humano que esta ratificando una CONJETURA. +/// · PayloadJson — opaco. Se guarda y se devuelve verbatim; el Tracker NUNCA lo +/// interpreta, igual que el Core no lo interpreta. +/// +/// Todo el sujeto es OPCIONAL: las versiones del runtime que ya estan desplegadas no lo envian y +/// deben seguir funcionando exactamente igual. Pero un sujeto PRESENTE y a medias se rechaza en +/// vez de recortarse: si llega un kind sin summary, el humano decidiria a ciegas +/// creyendo que ve el objeto, que es peor que no ver nada. Ausente es ausente; roto es un 400. +/// +public sealed record RuntimeApprovalSubject +{ + public const int MaxKindLength = 100; + public const int MaxRefLength = 500; + public const int MaxSummaryLength = 1000; + + /// + /// El payload viaja a una columna jsonb de una fila de auditoria, no a un almacen de objetos. + /// El tope existe para que un adjunto desmedido no convierta el ledger en un blobstore. + /// + public const int MaxPayloadLength = 16 * 1024; + + private RuntimeApprovalSubject( + string kind, string reference, string summary, double? confidence, string? payloadJson) + { + Kind = kind; + Ref = reference; + Summary = summary; + Confidence = confidence; + PayloadJson = payloadJson; + } + + /// Familia del sujeto, p. ej. c4-binding. Indexable. + public string Kind { get; } + + /// Referencia estable al objeto bajo decision, unica dentro de . + public string Ref { get; } + + /// La linea que el humano lee para decidir. + public string Summary { get; } + + /// Confianza en [0,1] cuando la propuesta es probabilistica; nula si no lo es. + public double? Confidence { get; } + + /// JSON opaco tal cual llego. Nunca se interpreta aqui. + public string? PayloadJson { get; } + + /// + /// Valida y construye. se exige OBJETO JSON porque el contrato + /// del Core lo declara Record<string, unknown>; admitir un escalar o un array + /// dejaria pasar formas que ninguna superficie de lectura sabe mostrar. + /// + public static Result Create( + string? kind, + string? reference, + string? summary, + double? confidence = null, + string? payloadJson = null) + { + if (string.IsNullOrWhiteSpace(kind)) + return Result.Failure("RuntimeApproval.Subject.KindRequired"); + if (kind.Length > MaxKindLength) + return Result.Failure("RuntimeApproval.Subject.KindTooLong"); + + if (string.IsNullOrWhiteSpace(reference)) + return Result.Failure("RuntimeApproval.Subject.RefRequired"); + if (reference.Length > MaxRefLength) + return Result.Failure("RuntimeApproval.Subject.RefTooLong"); + + // Sin resumen el sujeto es un identificador opaco y el humano vuelve a decidir a ciegas, + // que es exactamente lo que este campo existe para evitar. + if (string.IsNullOrWhiteSpace(summary)) + return Result.Failure("RuntimeApproval.Subject.SummaryRequired"); + if (summary.Length > MaxSummaryLength) + return Result.Failure("RuntimeApproval.Subject.SummaryTooLong"); + + // Fuera de [0,1] no es «poco fiable»: es que quien la emitio no esta hablando de una + // probabilidad, y mostrarla como tal enganaria a quien decide. + if (confidence is not null && + (double.IsNaN(confidence.Value) || confidence.Value < 0 || confidence.Value > 1)) + { + return Result.Failure("RuntimeApproval.Subject.ConfidenceOutOfRange"); + } + + string? payload = null; + if (!string.IsNullOrWhiteSpace(payloadJson)) + { + if (payloadJson.Length > MaxPayloadLength) + return Result.Failure("RuntimeApproval.Subject.PayloadTooLarge"); + + // Se valida AQUI y no en la base: un jsonb invalido reventaria en `SaveChanges` como + // un 500 opaco, cuando lo cierto es que el cuerpo de la peticion venia mal. + try + { + using var parsed = JsonDocument.Parse(payloadJson); + if (parsed.RootElement.ValueKind != JsonValueKind.Object) + return Result.Failure("RuntimeApproval.Subject.PayloadNotAnObject"); + } + catch (JsonException) + { + return Result.Failure("RuntimeApproval.Subject.PayloadNotJson"); + } + + payload = payloadJson; + } + + return Result.Success( + new RuntimeApprovalSubject(kind.Trim(), reference.Trim(), summary.Trim(), confidence, payload)); + } + + /// + /// Rehidrata desde persistencia sin revalidar: la fila ya paso por . + /// Devuelve null cuando la fila no lleva sujeto —el caso de toda aprobacion abierta por + /// un runtime anterior a GT-590, que debe releerse igual que siempre. + /// + public static RuntimeApprovalSubject? Rehydrate( + string? kind, string? reference, string? summary, double? confidence, string? payloadJson) + { + // La ausencia se decide por `kind`: es el unico campo que ninguna fila con sujeto puede + // tener vacio. Una fila a medias se lee como SIN sujeto en vez de fabricar uno inventado. + if (string.IsNullOrWhiteSpace(kind) || + string.IsNullOrWhiteSpace(reference) || + string.IsNullOrWhiteSpace(summary)) + { + return null; + } + + return new RuntimeApprovalSubject(kind, reference, summary, confidence, payloadJson); + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731191456_AddRuntimeApprovalSubject.Designer.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731191456_AddRuntimeApprovalSubject.Designer.cs new file mode 100644 index 00000000..34aea805 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731191456_AddRuntimeApprovalSubject.Designer.cs @@ -0,0 +1,3759 @@ +// +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("20260731191456_AddRuntimeApprovalSubject")] + partial class AddRuntimeApprovalSubject + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("tracker_governance") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Artifacts.Entities.EvidenceRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdapterVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("adapter_version"); + + b.Property("ArtifactId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_type"); + + b.Property("Classification") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("classification"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentHash") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("content_hash"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Determinism") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("determinism"); + + b.Property("Dimension") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("dimension"); + + b.Property("EvidenceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("evidence_type"); + + b.Property("Immutable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("immutable"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Producer") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("producer"); + + b.Property("ReferencesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("references"); + + b.Property("RetentionPolicyRef") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("retention_policy_ref"); + + b.Property("SourceProvider") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("source_provider"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("SubmittedBy") + .HasColumnType("uuid") + .HasColumnName("submitted_by"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Classification"); + + b.HasIndex("Dimension"); + + b.HasIndex("TenantId"); + + b.HasIndex("ArtifactType", "ArtifactId"); + + b.ToTable("evidence_records", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Audit.Entities.AuditEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("ChangesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ActorId"); + + b.HasIndex("TenantId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_entries", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Design.Entities.TechnicalBlueprintRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IntegrationAdrRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("integration_adr_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("technical_blueprints", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.InitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalComplete") + .HasColumnType("boolean") + .HasColumnName("approval_complete"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedBy") + .HasColumnType("uuid") + .HasColumnName("approved_by"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConstituentsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("constituents"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("origin"); + + b.Property("OriginRef") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("origin_ref"); + + b.Property("ProductScopeJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("product_scope"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("RequiredApproversJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_approvers"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SupersededByInitiativeId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_initiative_id"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TypeFrozen") + .HasColumnType("boolean") + .HasColumnName("type_frozen"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code"); + + b.ToTable("initiatives", "tracker_governance", t => + { + t.HasCheckConstraint("ck_initiatives_status", "status IN ('draft', 'submitted', 'approved', 'rejected', 'consolidated')"); + }); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.OpportunityRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("ContextJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("context"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("opportunities", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CatalogReleaseRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("catalog_release", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CountryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DefaultCurrencyCode") + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("default_currency_code"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character(2)") + .HasColumnName("iso_alpha2") + .IsFixedLength(); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("iso_alpha3") + .IsFixedLength(); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumericCode") + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("numeric_code") + .IsFixedLength(); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IsoAlpha2") + .IsUnique(); + + b.HasIndex("IsoAlpha3") + .IsUnique(); + + b.ToTable("country", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CurrencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MinorUnits") + .HasColumnType("smallint") + .HasColumnName("minor_units"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("symbol"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currency", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.ExchangeRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("base_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("QuoteCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("quote_code"); + + b.Property("Rate") + .HasColumnType("numeric(20,10)") + .HasColumnName("rate"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("BaseCode", "QuoteCode", "ValidFrom") + .HasDatabaseName("ix_exchange_rate_pair"); + + b.ToTable("exchange_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LanguageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("language", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocaleProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DateFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("date_format"); + + b.Property("FirstDayOfWeek") + .HasColumnType("smallint") + .HasColumnName("first_day_of_week"); + + b.Property("Name") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumberFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("number_format"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CountryId"); + + b.ToTable("locale_profile", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scheme"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("LocationId", "Scheme") + .IsUnique(); + + b.HasIndex("Scheme", "Value") + .IsUnique(); + + b.ToTable("location_code", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.HasKey("LocationId", "Locale"); + + b.ToTable("location_i18n", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationLevelRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("Ordinal") + .HasColumnType("smallint") + .HasColumnName("ordinal"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Ordinal") + .IsUnique(); + + b.ToTable("location_level", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CatalogReleaseId") + .HasColumnType("uuid") + .HasColumnName("catalog_release_id"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LevelId") + .HasColumnType("uuid") + .HasColumnName("level_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("SupersedesId") + .HasColumnType("uuid") + .HasColumnName("supersedes_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_location_parent"); + + b.HasIndex("Path") + .HasDatabaseName("ix_location_path"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist"); + + b.HasIndex("CountryId", "ParentId") + .HasDatabaseName("ix_location_country_parent"); + + b.ToTable("location", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("OperationType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation_type"); + + b.Property("Rate") + .HasColumnType("numeric(6,4)") + .HasColumnName("rate"); + + b.Property("TaxSchemeId") + .HasColumnType("uuid") + .HasColumnName("tax_scheme_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("TaxSchemeId", "LocationId", "ValidFrom") + .HasDatabaseName("ix_tax_rate_scheme_location"); + + b.ToTable("tax_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxSchemeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Code") + .IsUnique(); + + b.ToTable("tax_scheme", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TimeZoneRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("country_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IanaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("iana_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.HasIndex("IanaId") + .IsUnique(); + + b.ToTable("time_zone", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ArtifactFieldSchemaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("artifact_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ArtifactType") + .IsUnique(); + + b.ToTable("artifact_field_schemas", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ExceptionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CriterionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("criterion_id"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at_utc"); + + b.Property("DecidedBy") + .HasColumnType("uuid") + .HasColumnName("decided_by"); + + b.Property("DecisionNotes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("decision_notes"); + + b.Property("ExceptionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("exception_type"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("Justification") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("justification"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("RequestedBy") + .HasColumnType("uuid") + .HasColumnName("requested_by"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_exception_requests_tenant_status"); + + b.HasIndex("TenantId", "SdlcExecutionId", "Phase") + .HasDatabaseName("ix_exception_requests_tenant_execution_phase"); + + b.ToTable("exception_requests", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("approval_ids"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DecisionBy") + .HasColumnType("uuid") + .HasColumnName("decision_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationIdsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evaluation_ids"); + + b.Property("EvidenceSnapshotRef") + .HasColumnType("text") + .HasColumnName("evidence_snapshot_ref"); + + b.Property("ExceptionIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("exception_ids"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("PolicySnapshotRef") + .HasColumnType("text") + .HasColumnName("policy_snapshot_ref"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rationale"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Phase"); + + b.HasIndex("SdlcExecutionId"); + + b.HasIndex("TenantId"); + + b.ToTable("gate_decisions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GatePolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("ApprovalPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approval_policy"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationCriteriaJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evaluation_criteria"); + + b.Property("ExceptionPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("exception_policy"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequiredEvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_evidence"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("gate_policies", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateSubmissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approvals"); + + b.Property("CoreVerdictJson") + .HasColumnType("jsonb") + .HasColumnName("core_verdict"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CriteriaEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("criteria_evaluation"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("requested_by"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("state"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase"); + + b.ToTable("gate_submissions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.PhaseGateStateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid") + .HasColumnName("evaluated_by"); + + b.Property("GateStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gate_status"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phase"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase"); + + b.HasIndex("TenantId", "SdlcExecutionId"); + + b.ToTable("phase_gate_states", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.RuntimeApprovalRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Approver") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("approver"); + + b.Property("CorrelationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("ExecutionMode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("execution_mode"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("InitiativeId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("initiative_id"); + + b.Property("Intent") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("intent"); + + b.Property("ProductId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("product_id"); + + b.Property("Reason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reason"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at_utc"); + + b.Property("RequestedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("requested_by"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at_utc"); + + b.Property("SkillId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("skill_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("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("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("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("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("operation_id"); + + 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("ResponseDataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_data"); + + 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("OperationId") + .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/20260731191456_AddRuntimeApprovalSubject.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731191456_AddRuntimeApprovalSubject.cs new file mode 100644 index 00000000..4c03b05b --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731191456_AddRuntimeApprovalSubject.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + /// + /// GT-590 — el sujeto de la decision sobre `runtime_approvals`. + /// + /// Estrictamente ADITIVA: cinco columnas anulables y un indice filtrado. Ninguna fila + /// existente cambia y ningun runtime desplegado necesita enviar nada nuevo — el campo llegaba + /// aceptado-y-descartado, de modo que el ledger registraba que ALGO se aprobo pero no QUE. + /// + public partial class AddRuntimeApprovalSubject : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "subject_confidence", + schema: "tracker_governance", + table: "runtime_approvals", + type: "double precision", + nullable: true); + + migrationBuilder.AddColumn( + name: "subject_kind", + schema: "tracker_governance", + table: "runtime_approvals", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "subject_payload", + schema: "tracker_governance", + table: "runtime_approvals", + type: "jsonb", + nullable: true); + + migrationBuilder.AddColumn( + name: "subject_ref", + schema: "tracker_governance", + table: "runtime_approvals", + type: "character varying(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "subject_summary", + schema: "tracker_governance", + table: "runtime_approvals", + type: "character varying(1000)", + maxLength: 1000, + nullable: true); + + migrationBuilder.CreateIndex( + name: "ix_runtime_approvals_tenant_subject", + schema: "tracker_governance", + table: "runtime_approvals", + columns: new[] { "tenant_id", "subject_kind", "subject_ref" }, + filter: "subject_kind IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_runtime_approvals_tenant_subject", + schema: "tracker_governance", + table: "runtime_approvals"); + + migrationBuilder.DropColumn( + name: "subject_confidence", + schema: "tracker_governance", + table: "runtime_approvals"); + + migrationBuilder.DropColumn( + name: "subject_kind", + schema: "tracker_governance", + table: "runtime_approvals"); + + migrationBuilder.DropColumn( + name: "subject_payload", + schema: "tracker_governance", + table: "runtime_approvals"); + + migrationBuilder.DropColumn( + name: "subject_ref", + schema: "tracker_governance", + table: "runtime_approvals"); + + migrationBuilder.DropColumn( + name: "subject_summary", + schema: "tracker_governance", + table: "runtime_approvals"); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs index de15909e..9430779a 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs @@ -1982,6 +1982,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -2006,6 +2029,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); }); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/RuntimeApprovalRecordConfiguration.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/RuntimeApprovalRecordConfiguration.cs index 65101a10..9bed39c9 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/RuntimeApprovalRecordConfiguration.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Configurations/RuntimeApprovalRecordConfiguration.cs @@ -18,6 +18,16 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.CorrelationId).HasColumnName("correlation_id").HasMaxLength(200); builder.Property(x => x.RequestedBy).HasColumnName("requested_by").HasMaxLength(200); builder.Property(x => x.ExecutionMode).HasColumnName("execution_mode").HasMaxLength(50); + // GT-590 — longitudes en paralelo con las constantes del dominio + // (`RuntimeApprovalSubject.MaxKindLength` y compañía): el agregado ya rechaza lo que + // exceda, así que la columna no está para validar sino para que la base no acepte en + // silencio algo que el dominio nunca produciría. + builder.Property(x => x.SubjectKind).HasColumnName("subject_kind").HasMaxLength(100); + builder.Property(x => x.SubjectRef).HasColumnName("subject_ref").HasMaxLength(500); + builder.Property(x => x.SubjectSummary).HasColumnName("subject_summary").HasMaxLength(1000); + builder.Property(x => x.SubjectConfidence).HasColumnName("subject_confidence"); + builder.Property(x => x.SubjectPayloadJson).HasColumnName("subject_payload").HasColumnType("jsonb"); + builder.Property(x => x.Status).HasColumnName("status").IsRequired().HasMaxLength(20); builder.Property(x => x.Approver).HasColumnName("approver").HasMaxLength(200); builder.Property(x => x.Reason).HasColumnName("reason").HasMaxLength(2000); @@ -42,5 +52,13 @@ public void Configure(EntityTypeBuilder builder) // Consulta de la cola de pendientes por tenant. builder.HasIndex(x => new { x.TenantId, x.Status }) .HasDatabaseName("ix_runtime_approvals_tenant_status"); + + // GT-590 — «¿ya se decidió sobre ESTE objeto?» es la pregunta que el sujeto habilita, y + // sin índice recorre la tabla entera. NO es único a propósito: un mismo objeto puede + // volver a someterse tras un rechazo o una expiración, y unificarlo convertiría un + // reintento legítimo en un error de base. La idempotencia sigue siendo de `correlationId`. + builder.HasIndex(x => new { x.TenantId, x.SubjectKind, x.SubjectRef }) + .HasDatabaseName("ix_runtime_approvals_tenant_subject") + .HasFilter("subject_kind IS NOT NULL"); } } diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/RuntimeApprovalRecord.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/RuntimeApprovalRecord.cs index d29bb859..67d37710 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/RuntimeApprovalRecord.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/Entities/RuntimeApprovalRecord.cs @@ -19,6 +19,16 @@ public sealed class RuntimeApprovalRecord : IAuditableRecord, ITenantScopedRecor public string? CorrelationId { get; set; } public string? RequestedBy { get; set; } public string? ExecutionMode { get; set; } + + // GT-590 — el sujeto se aplana en cinco columnas en vez de ir como un jsonb entero, porque + // `kind` y `ref` se consultan (van al índice) y un jsonb monolítico los dejaría fuera. El + // payload sí es jsonb: es opaco por contrato y nadie lo filtra. + public string? SubjectKind { get; set; } + public string? SubjectRef { get; set; } + public string? SubjectSummary { get; set; } + public double? SubjectConfidence { get; set; } + public string? SubjectPayloadJson { get; set; } + public string Status { get; set; } = string.Empty; public string? Approver { get; set; } public string? Reason { get; set; } diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlRuntimeApprovalRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlRuntimeApprovalRepository.cs index dedb41e9..986daa68 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlRuntimeApprovalRepository.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Governance/PostgreSqlRuntimeApprovalRepository.cs @@ -84,6 +84,11 @@ public async Task UpdateAsync(RuntimeApproval approval, CancellationToken ct = d CorrelationId = a.CorrelationId, RequestedBy = a.RequestedBy, ExecutionMode = a.ExecutionMode, + SubjectKind = a.Subject?.Kind, + SubjectRef = a.Subject?.Ref, + SubjectSummary = a.Subject?.Summary, + SubjectConfidence = a.Subject?.Confidence, + SubjectPayloadJson = a.Subject?.PayloadJson, Status = a.Status, Approver = a.Approver, Reason = a.Reason, @@ -109,6 +114,11 @@ private static RuntimeApproval MapToDomain(RuntimeApprovalRecord r) => CorrelationId = r.CorrelationId, RequestedBy = r.RequestedBy, ExecutionMode = r.ExecutionMode, + // Rehidrata SIN revalidar: la fila ya pasó por `Create` al abrirse. Revalidar aquí + // haría que un endurecimiento posterior de las reglas volviera ilegibles filas + // históricas que fueron válidas cuando se escribieron. + Subject = RuntimeApprovalSubject.Rehydrate( + r.SubjectKind, r.SubjectRef, r.SubjectSummary, r.SubjectConfidence, r.SubjectPayloadJson), Status = r.Status, Approver = r.Approver, Reason = r.Reason, diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/RuntimeApprovalEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/RuntimeApprovalEndpoints.cs index 02001ee4..b3741ba0 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/RuntimeApprovalEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/RuntimeApprovalEndpoints.cs @@ -48,6 +48,28 @@ public static void MapRuntimeApprovalEndpoints(this IEndpointRouteBuilder app) } } + // GT-590 — el sujeto es OPCIONAL, pero un sujeto PRESENTE y a medias se rechaza en vez + // de recortarse: si se aceptara un `kind` sin `summary`, el humano decidiría a ciegas + // creyendo que ve el objeto. Se valida ANTES de abrir el registro para que un cuerpo + // mal formado no deje una aprobación pendiente que nadie sabe interpretar. + RuntimeApprovalSubject? subject = null; + if (request.Subject is not null) + { + var candidate = RuntimeApprovalSubject.Create( + request.Subject.Kind, + request.Subject.Ref, + request.Subject.Summary, + request.Subject.Confidence, + request.Subject.Payload?.GetRawText()); + + if (candidate.IsFailure) + { + return Results.BadRequest(new { error = candidate.Error }); + } + + subject = candidate.Value; + } + var created = RuntimeApproval.Submit( tenantId, request.SkillId, @@ -57,7 +79,8 @@ public static void MapRuntimeApprovalEndpoints(this IEndpointRouteBuilder app) request.InitiativeId, request.CorrelationId, request.RequestedBy, - request.ExecutionMode); + request.ExecutionMode, + subject); if (created.IsFailure) { @@ -200,11 +223,11 @@ public static void MapRuntimeApprovalEndpoints(this IEndpointRouteBuilder app) CancellationToken ct) => { var pending = await approvals.ListPendingAsync(user.TenantId, ct); - return Results.Ok(pending.Select(ToResponse).ToList()); + return Results.Ok(pending.Select(ToPendingItem).ToList()); }) .WithName("ListPendingRuntimeApprovals") .RequireTrackerPermission(TrackerPermissions.RuntimeApprovalResolve) - .Produces>(200) + .Produces>(200) .Produces(401) .Produces(403); } @@ -220,7 +243,106 @@ public static void MapRuntimeApprovalEndpoints(this IEndpointRouteBuilder app) Status = a.CurrentStatus(), Approver = a.Approver, Reason = a.Reason, + Subject = ToSubjectDto(a.Subject), + }; + + /// + /// GT-590 — la cola humana NO usa el DTO espejo del Core. El espejo responde a la MÁQUINA, que + /// sólo necesita el veredicto, y ampliarlo para la pantalla lo alejaría del contrato que su + /// propio nombre promete. Quien decide necesita otra cosa: qué capacidad se invoca, sobre qué + /// objeto, desde cuándo espera y cuándo vence. + /// + private static PendingRuntimeApprovalDto ToPendingItem(RuntimeApproval a) => new() + { + ApprovalId = a.Id.ToString(), + SkillId = a.SkillId, + Intent = a.Intent, + ProductId = a.ProductId, + InitiativeId = a.InitiativeId, + RequestedBy = a.RequestedBy, + RequestedAtUtc = a.RequestedAtUtc, + ExpiresAtUtc = a.ExpiresAtUtc, + Status = a.CurrentStatus(), + Subject = ToSubjectDto(a.Subject), }; + + /// + /// GT-590 — el sujeto SALE por las mismas superficies por las que entró. Devolverlo sólo en el + /// canal de máquina lo dejaría invisible justo para quien decide: la cola de pendientes es la + /// pantalla del humano, y sin el sujeto vuelve a mostrar `skillId` + `intent`, que es la + /// indistinguibilidad que esta ficha existe para cerrar. + /// + /// El payload se re-parsea a para que viaje como JSON y no como una + /// cadena con JSON dentro. La fila ya pasó por Create, así que es JSON válido; el + /// catch cubre una fila escrita a mano en la base y prefiere omitir el payload antes que + /// tumbar la lista entera de pendientes por una fila corrupta. + /// + private static RuntimeApprovalSubjectDto? ToSubjectDto(RuntimeApprovalSubject? s) + { + if (s is null) return null; + + JsonElement? payload = null; + if (!string.IsNullOrWhiteSpace(s.PayloadJson)) + { + try + { + payload = JsonDocument.Parse(s.PayloadJson).RootElement.Clone(); + } + catch (JsonException) + { + payload = null; + } + } + + return new RuntimeApprovalSubjectDto + { + Kind = s.Kind, + Ref = s.Ref, + Summary = s.Summary, + Confidence = s.Confidence, + Payload = payload, + }; + } +} + +/// +/// GT-590 — una fila de la cola de decisión humana. Deliberadamente NO es +/// : aquél es el espejo del contrato de máquina del Core y +/// esta es la pantalla de quien concede. `expiresAtUtc` viaja porque una solicitud que vence en +/// diez minutos y otra que vence mañana no se atienden en el mismo orden, y sin la hora la cola +/// no puede decirlo. +/// +public sealed class PendingRuntimeApprovalDto +{ + public string ApprovalId { get; init; } = string.Empty; + public string SkillId { get; init; } = string.Empty; + public string Intent { get; init; } = string.Empty; + public string? ProductId { get; init; } + public string? InitiativeId { get; init; } + public string? RequestedBy { get; init; } + public DateTime RequestedAtUtc { get; init; } + public DateTime ExpiresAtUtc { get; init; } + public string Status { get; init; } = string.Empty; + + /// QUÉ se decide. Nulo en solicitudes abiertas por un runtime anterior a GT-590. + public RuntimeApprovalSubjectDto? Subject { get; init; } +} + +/// +/// Espejo de ApprovalSubject del Core (`approval.port.ts`). Los nombres son los del +/// contrato — kind/ref/summary/confidence/payload — y no se +/// renombran aquí: el día que divergieran, el Core enviaría un campo que este DTO descartaría en +/// silencio y la aprobación volvería a llegar sin sujeto sin que nadie lo notara. +/// +public sealed class RuntimeApprovalSubjectDto +{ + public string? Kind { get; init; } + public string? Ref { get; init; } + public string? Summary { get; init; } + public double? Confidence { get; init; } + + /// Opaco por contrato: se transporta verbatim y nunca se interpreta. + public JsonElement? Payload { get; init; } } /// Espejo de TrackerApprovalSubmission del Core. `tenantId` NO viaja: lo fija la clave. @@ -233,6 +355,9 @@ public sealed class RuntimeApprovalSubmissionRequest public string? CorrelationId { get; init; } public string? RequestedBy { get; init; } public string? ExecutionMode { get; init; } + + /// GT-590 — QUÉ se decide. Ausente en runtimes anteriores a la ficha, y eso es válido. + public RuntimeApprovalSubjectDto? Subject { get; init; } } public sealed class ResolveRuntimeApprovalRequest @@ -250,4 +375,7 @@ public sealed class RuntimeApprovalResponse public string? Approver { get; init; } public string? Reason { get; init; } public string? ApprovalId { get; init; } + + /// GT-590 — el objeto sobre el que se decide. Nulo cuando la solicitud no lo trajo. + public RuntimeApprovalSubjectDto? Subject { get; init; } } diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Governance/RuntimeApprovalTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/RuntimeApprovalTests.cs index 866b7783..efdd1aff 100644 --- a/src/apps/tracker-api/Tracker.Tests/Domain/Governance/RuntimeApprovalTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/RuntimeApprovalTests.cs @@ -187,4 +187,144 @@ public void ElCorrelationIdSeConserva() { Nueva(correlationId: "corr-42").CorrelationId.Should().Be("corr-42"); } + + // ── GT-590: QUÉ se decide ──────────────────────────────────────────────────────────────── + // + // Sin sujeto, TODA decisión tomada a través de una misma capacidad se ve igual —igual para el + // humano que concede e igual en el ledger que la registra—. Eso basta para «¿puede ejecutarse + // esta capacidad?» y no para «¿es ESTA la correspondencia correcta?». + + private static Result Sujeto( + string? kind = "c4-binding", + string? reference = "container:checkout-api", + string? summary = "Enlazar el contenedor checkout-api con src/apps/checkout", + double? confidence = null, + string? payloadJson = null) => + RuntimeApprovalSubject.Create(kind, reference, summary, confidence, payloadJson); + + [Fact] + public void SinSujeto_LaAprobacionSigueSiendoValida() + { + Nueva().Subject.Should().BeNull( + "el sujeto es opcional en el contrato del Core y los runtimes ya desplegados no lo " + + "envian; exigirlo aqui los romperia a todos de golpe"); + } + + [Fact] + public void ElSujetoViajaHastaElAgregado() + { + var aprobacion = RuntimeApproval.Submit( + Guid.NewGuid(), "skill.bind", "enlazar", Ttl, subject: Sujeto().Value).Value!; + + aprobacion.Subject!.Kind.Should().Be("c4-binding"); + aprobacion.Subject.Ref.Should().Be("container:checkout-api"); + } + + [Theory] + [InlineData(null, "ref", "resumen", "RuntimeApproval.Subject.KindRequired")] + [InlineData("kind", null, "resumen", "RuntimeApproval.Subject.RefRequired")] + // Sin resumen el sujeto es un identificador opaco y el humano vuelve a decidir a ciegas, que + // es exactamente lo que este campo existe para evitar. + [InlineData("kind", "ref", null, "RuntimeApproval.Subject.SummaryRequired")] + public void UnSujetoAMedias_SeRechazaEnVezDeRecortarse( + string? kind, string? reference, string? summary, string error) + { + Sujeto(kind, reference, summary).Error.Should().Be(error, + "aceptarlo a medias dejaria al humano decidiendo a ciegas mientras cree que ve el " + + "objeto, que es peor que no ver nada"); + } + + [Theory] + [InlineData(-0.01)] + [InlineData(1.01)] + [InlineData(double.NaN)] + public void UnaConfianzaFueraDeRango_NoEsUnaProbabilidad(double confianza) + { + Sujeto(confidence: confianza).Error.Should() + .Be("RuntimeApproval.Subject.ConfidenceOutOfRange", + "fuera de [0,1] no es «poco fiable»: es que quien la emitio no esta hablando de " + + "una probabilidad, y mostrarla como tal enganaria a quien decide"); + } + + [Theory] + [InlineData(0)] + [InlineData(0.5)] + [InlineData(1)] + public void UnaConfianzaEnRangoSeConserva(double confianza) + { + Sujeto(confidence: confianza).Value!.Confidence.Should().Be(confianza); + } + + [Fact] + public void SinConfianza_ElSujetoSigueSiendoValido() + { + Sujeto(confidence: null).Value!.Confidence.Should().BeNull( + "una propuesta que no es probabilistica no tiene ninguna confianza que declarar, y " + + "fabricarle un 1.0 afirmaria una certeza que nadie midio"); + } + + [Theory] + [InlineData("no es json", "RuntimeApproval.Subject.PayloadNotJson")] + // El contrato del Core lo declara `Record`: admitir un escalar o un array + // dejaria pasar formas que ninguna superficie de lectura sabe mostrar. + [InlineData("[1,2,3]", "RuntimeApproval.Subject.PayloadNotAnObject")] + [InlineData("42", "RuntimeApproval.Subject.PayloadNotAnObject")] + [InlineData("\"cadena\"", "RuntimeApproval.Subject.PayloadNotAnObject")] + public void UnPayloadQueNoEsUnObjetoJson_SeRechaza(string payload, string error) + { + Sujeto(payloadJson: payload).Error.Should().Be(error, + "se valida aqui y no en la base: un jsonb invalido reventaria en SaveChanges como un " + + "500 opaco cuando lo cierto es que el cuerpo de la peticion venia mal"); + } + + [Fact] + public void UnPayloadDesmedido_SeRechaza() + { + var enorme = "{\"a\":\"" + new string('x', RuntimeApprovalSubject.MaxPayloadLength) + "\"}"; + + Sujeto(payloadJson: enorme).Error.Should().Be("RuntimeApproval.Subject.PayloadTooLarge", + "el payload viaja a una columna de una fila de auditoria, no a un almacen de objetos"); + } + + [Fact] + public void ElPayloadSeConservaVerbatim() + { + const string payload = "{\"confidence\":0.82,\"evidence\":[\"a\",\"b\"]}"; + + Sujeto(payloadJson: payload).Value!.PayloadJson.Should().Be(payload, + "es opaco por contrato: el Tracker lo guarda y lo devuelve tal cual, igual que el Core " + + "no lo interpreta"); + } + + [Theory] + [InlineData(RuntimeApprovalSubject.MaxKindLength + 1, 1, 1, "RuntimeApproval.Subject.KindTooLong")] + [InlineData(1, RuntimeApprovalSubject.MaxRefLength + 1, 1, "RuntimeApproval.Subject.RefTooLong")] + [InlineData(1, 1, RuntimeApprovalSubject.MaxSummaryLength + 1, "RuntimeApproval.Subject.SummaryTooLong")] + public void LosTopesDeLongitudSeExigen(int kind, int reference, int summary, string error) + { + Sujeto(new string('k', kind), new string('r', reference), new string('s', summary)) + .Error.Should().Be(error); + } + + [Fact] + public void RehidratarNoRevalida() + { + // Una fila escrita cuando las reglas eran mas laxas debe seguir leyendose. Revalidar al + // rehidratar haria que un endurecimiento posterior volviera ilegible el historico. + var sujeto = RuntimeApprovalSubject.Rehydrate( + "kind", "ref", "resumen", confidence: 7.5, payloadJson: "no es json"); + + sujeto!.Confidence.Should().Be(7.5); + sujeto.PayloadJson.Should().Be("no es json"); + } + + [Theory] + [InlineData(null, "ref", "resumen")] + [InlineData("kind", null, "resumen")] + [InlineData("kind", "ref", null)] + public void UnaFilaAMedias_SeLeeComoSinSujeto(string? kind, string? reference, string? summary) + { + RuntimeApprovalSubject.Rehydrate(kind, reference, summary, null, null).Should().BeNull( + "leerla a medias fabricaria un sujeto inventado; ausente es ausente"); + } } diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/RuntimeApprovalPersistenceTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/RuntimeApprovalPersistenceTests.cs index 0532284d..08396715 100644 --- a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/RuntimeApprovalPersistenceTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/RuntimeApprovalPersistenceTests.cs @@ -164,4 +164,101 @@ public async Task LaResolucionSobreviveALaRelectura_ConAprobador() releida.Reason.Should().StartWith(RuntimeApprovalReason.DecisionPrefix, "el Core distingue una decision del Tracker de una averia por el prefijo"); } + + // ── GT-590: el sujeto sobrevive al viaje ───────────────────────────────────────────────── + // + // Estas van contra Postgres y no contra un doble a propósito: el sujeto se aplana en cinco + // columnas, el payload es `jsonb` y el índice es parcial. Un doble en memoria daría verde con + // el mapeo mal escrito, que es justo el falso verde que la clase entera existe para evitar. + + [Fact] + public async Task ElSujetoSobreviveAlaRelectura_IncluidoElPayloadOpaco() + { + using var db = TryOpen(); + if (db is null) return; + + var repo = new PostgreSqlRuntimeApprovalRepository(db); + var tenantId = Guid.NewGuid(); + const string payload = "{\"evidence\":[\"adr-0104\"],\"proposer\":\"c4-matcher\"}"; + + var sujeto = RuntimeApprovalSubject.Create( + "c4-binding", "container:checkout-api", + "Enlazar el contenedor checkout-api con src/apps/checkout", 0.82, payload).Value!; + + var aprobacion = RuntimeApproval.Submit( + tenantId, "skill.bind", "enlazar", TimeSpan.FromHours(4), subject: sujeto).Value!; + + await repo.AddAsync(aprobacion); + await db.SaveChangesAsync(); + + db.ChangeTracker.Clear(); + var releida = await repo.GetByIdAsync(tenantId, aprobacion.Id); + + releida!.Subject.Should().NotBeNull( + "sin el sujeto de vuelta, la cola de pendientes vuelve a mostrar skillId + intent y la " + + "ficha no cierra nada"); + releida.Subject!.Kind.Should().Be("c4-binding"); + releida.Subject.Ref.Should().Be("container:checkout-api"); + releida.Subject.Summary.Should().Be( + "Enlazar el contenedor checkout-api con src/apps/checkout"); + releida.Subject.Confidence.Should().Be(0.82, + "la confianza es lo que le dice al humano que esta ratificando una CONJETURA; " + + "perderla en el viaje la convierte en una certeza que nadie midio"); + + // `jsonb` normaliza el texto (reordena claves, quita espacios), así que la comparación es + // semántica: lo que no puede cambiar es el CONTENIDO. + JsonDocument.Parse(releida.Subject.PayloadJson!).RootElement + .GetProperty("proposer").GetString().Should().Be("c4-matcher"); + } + + [Fact] + public async Task UnaAprobacionSinSujeto_SeRelaeSinSujeto() + { + using var db = TryOpen(); + if (db is null) return; + + var repo = new PostgreSqlRuntimeApprovalRepository(db); + var tenantId = Guid.NewGuid(); + var aprobacion = Nueva(tenantId, correlationId: null); + + await repo.AddAsync(aprobacion); + await db.SaveChangesAsync(); + + db.ChangeTracker.Clear(); + var releida = await repo.GetByIdAsync(tenantId, aprobacion.Id); + + releida!.Subject.Should().BeNull( + "toda aprobacion abierta por un runtime anterior a GT-590 esta en esta forma y debe " + + "releerse igual que siempre, no con un sujeto fabricado"); + } + + /// + /// El índice de sujeto NO es único, a diferencia del de `correlationId`. Un mismo objeto puede + /// volver a someterse tras un rechazo o una expiración; unificarlo convertiría un reintento + /// legítimo en un error de base. + /// + [Fact] + public async Task DosAprobacionesSobreElMismoSujeto_CoexistenSinError() + { + using var db = TryOpen(); + if (db is null) return; + + var repo = new PostgreSqlRuntimeApprovalRepository(db); + var tenantId = Guid.NewGuid(); + var reference = $"container:{Guid.NewGuid():N}"; + + RuntimeApproval Sobre(string reference) => RuntimeApproval.Submit( + tenantId, "skill.bind", "enlazar", TimeSpan.FromHours(4), + subject: RuntimeApprovalSubject.Create( + "c4-binding", reference, "el mismo objeto, sometido dos veces").Value).Value!; + + await repo.AddAsync(Sobre(reference)); + await repo.AddAsync(Sobre(reference)); + + var act = async () => await db.SaveChangesAsync(); + + await act.Should().NotThrowAsync( + "el sujeto responde «se decidio sobre ESTE objeto?», no impone unicidad; la " + + "idempotencia sigue siendo del correlationId"); + } } diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/RuntimeApprovalEndpointTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/RuntimeApprovalEndpointTests.cs index ac0d2fbe..47bfdaad 100644 --- a/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/RuntimeApprovalEndpointTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/RuntimeApprovalEndpointTests.cs @@ -206,12 +206,130 @@ public async Task ElCanalHumanoSigueAceptandoIdentidadesDeUsuario() + "inoperable: nadie puede decidir nada y toda solicitud muere por vencimiento"); } + // ── GT-590: el sujeto entra y sale por HTTP ────────────────────────────────────────────── + + private static object SubmissionConSujeto(object? subject) => new + { + skillId = "skill.bind", + intent = "enlazar el modelo con el codigo", + correlationId = $"corr-{Guid.NewGuid():N}", + requestedBy = "agent-runtime", + subject, + }; + + /// + /// El viaje completo por el borde HTTP. El payload importa especialmente: sale como JSON y no + /// como una cadena con JSON dentro, que es la forma en que un DTO mal escrito lo entregaría. + /// + [Fact] + public async Task ElSujetoLlegaPorHttpYVuelveEnLaRespuesta() + { + if (!_factory.DatabaseAvailable) return; + + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-api-key", RuntimeApprovalWebApplicationFactory.MachineKey); + + var response = await client.PostAsJsonAsync("/api/v1/runtime-approvals", + SubmissionConSujeto(new + { + kind = "c4-binding", + @ref = "container:checkout-api", + summary = "Enlazar checkout-api con src/apps/checkout", + confidence = 0.82, + payload = new { proposer = "c4-matcher" }, + })); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(); + body!.Subject.Should().NotBeNull( + "sin el sujeto de vuelta, quien decide vuelve a ver solo skillId + intent"); + body.Subject!.Kind.Should().Be("c4-binding"); + body.Subject.Ref.Should().Be("container:checkout-api"); + body.Subject.Confidence.Should().Be(0.82); + body.Subject.Payload!.Value.GetProperty("proposer").GetString().Should().Be("c4-matcher", + "el payload es opaco pero JSON: entregarlo como cadena obligaria a cada consumidor a " + + "re-parsearlo y delataria un DTO que lo trata como texto"); + } + + [Fact] + public async Task SinSujeto_LaSumisionSigueFuncionando() + { + if (!_factory.DatabaseAvailable) return; + + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-api-key", RuntimeApprovalWebApplicationFactory.MachineKey); + + var response = await client.PostAsJsonAsync("/api/v1/runtime-approvals", Submission()); + + response.StatusCode.Should().Be(HttpStatusCode.OK, + "los runtimes ya desplegados no envian sujeto y no pueden romperse por esta ficha"); + + var body = await response.Content.ReadFromJsonAsync(); + body!.Subject.Should().BeNull(); + } + + /// + /// Un sujeto a medias es un 400 y no un recorte silencioso: si se aceptara un `kind` sin + /// `summary`, la solicitud quedaría abierta y el humano decidiría a ciegas creyendo que ve el + /// objeto. + /// + [Theory] + [InlineData(null, "container:x", "un resumen")] + [InlineData("c4-binding", null, "un resumen")] + [InlineData("c4-binding", "container:x", null)] + public async Task UnSujetoAMedias_SeRechazaConCuatrocientos( + string? kind, string? reference, string? summary) + { + if (!_factory.DatabaseAvailable) return; + + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-api-key", RuntimeApprovalWebApplicationFactory.MachineKey); + + var response = await client.PostAsJsonAsync("/api/v1/runtime-approvals", + SubmissionConSujeto(new { kind, @ref = reference, summary })); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task UnaConfianzaImposible_SeRechazaConCuatrocientos() + { + if (!_factory.DatabaseAvailable) return; + + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-api-key", RuntimeApprovalWebApplicationFactory.MachineKey); + + var response = await client.PostAsJsonAsync("/api/v1/runtime-approvals", + SubmissionConSujeto(new + { + kind = "c4-binding", + @ref = "container:x", + summary = "un resumen", + confidence = 7.5, + })); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, + "una confianza fuera de [0,1] no es una probabilidad, y mostrarla como tal enganaria " + + "a quien ratifica la conjetura"); + } + private sealed class ApprovalBody { public string Status { get; set; } = string.Empty; public string? Approver { get; set; } public string? Reason { get; set; } public string? ApprovalId { get; set; } + public SubjectBody? Subject { get; set; } + } + + private sealed class SubjectBody + { + public string? Kind { get; set; } + public string? Ref { get; set; } + public string? Summary { get; set; } + public double? Confidence { get; set; } + public JsonElement? Payload { get; set; } } } diff --git a/src/apps/tracker-web/src/api/endpoints.ts b/src/apps/tracker-web/src/api/endpoints.ts index 095af770..418c8691 100644 --- a/src/apps/tracker-web/src/api/endpoints.ts +++ b/src/apps/tracker-web/src/api/endpoints.ts @@ -33,6 +33,9 @@ import type { UpsertGatePolicyRequest, RecordApprovalRequest, CoreEvaluationTransactionDto, + PendingRuntimeApprovalDto, + ResolveRuntimeApprovalRequest, + RuntimeApprovalResponseDto, CreateBlueprintRequest, SaveBlueprintFacetRequest, SetIntegrationAdrRequest, @@ -216,6 +219,21 @@ export const auditApi = { get: (id: string) => apiFetch(`/audit-entries/${id}`), }; +/* ── Runtime approvals (HITL) ───────────────────────────────────────────── */ +/** + * GT-590 / CD-23 — la cola humana. El canal de MÁQUINA (`POST /runtime-approvals`, que el Core + * usa para abrir la solicitud) no se expone aquí a propósito: exige el esquema de autenticación + * de máquina y el navegador no lo presenta ni debe. Desde el front sólo se lee y se decide. + */ +export const runtimeApprovalsApi = { + listPending: () => apiFetch('/runtime-approvals/pending'), + resolve: (id: string, body: ResolveRuntimeApprovalRequest) => + apiFetch(`/runtime-approvals/${id}/resolve`, { + method: 'POST', + body, + }), +}; + /* ── Core evaluation transactions ───────────────────────────────────────── */ export const coreTxApi = { list: () => apiFetch('/core-evaluation-transactions/'), diff --git a/src/apps/tracker-web/src/api/hooks.ts b/src/apps/tracker-web/src/api/hooks.ts index 3c1988b6..8d00861b 100644 --- a/src/apps/tracker-web/src/api/hooks.ts +++ b/src/apps/tracker-web/src/api/hooks.ts @@ -27,6 +27,7 @@ import { phaseArtifactsApi, phaseGatesApi, productsApi, + runtimeApprovalsApi, sdlcApi, tenantIntelligenceApi, tenantsApi, @@ -56,6 +57,8 @@ import type { RecordApprovalRequest, ConsolidateInitiativeRequest, CoreEvaluationTransactionDto, + PendingRuntimeApprovalDto, + ResolveRuntimeApprovalRequest, CreateBlueprintRequest, SaveBlueprintFacetRequest, SetIntegrationAdrRequest, @@ -116,6 +119,7 @@ export const qk = { evidence: ['evidence-records'] as const, audit: ['audit-entries'] as const, coreTx: ['core-evaluation-transactions'] as const, + pendingRuntimeApprovals: ['runtime-approvals', 'pending'] as const, tenants: ['tenants'] as const, tenant: (id: string) => ['tenants', id] as const, products: (tenantId?: string) => ['products', tenantId ?? 'all'] as const, @@ -318,6 +322,34 @@ export function useCoreEvaluationTransactions(): UseQueryResult coreTxApi.list() }); } +/* ── Runtime approvals · HITL queue (GT-590 / CD-23) ────────────────────── */ +/** + * La cola se refresca sola porque su contenido caduca por TIEMPO: una solicitud vence a las 4 + * horas de abrirse y el Core lee ese vencimiento como denegación. Una pantalla estática mostraría + * como decidible algo sobre lo que ya no se puede decidir. + */ +export function usePendingRuntimeApprovals(): UseQueryResult { + return useQuery({ + queryKey: qk.pendingRuntimeApprovals, + queryFn: () => runtimeApprovalsApi.listPending(), + refetchInterval: 30_000, + }); +} + +export function useResolveRuntimeApproval() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: ResolveRuntimeApprovalRequest }) => + runtimeApprovalsApi.resolve(id, body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.pendingRuntimeApprovals }); + // Resolver ESCRIBE en el expediente: la pantalla de gobernanza de agentes lee ambas cosas y + // dejar el audit sin invalidar mostraria la decision tomada y su rastro ausente. + qc.invalidateQueries({ queryKey: qk.audit }); + }, + }); +} + /* ── Tenants ────────────────────────────────────────────────────────────── */ export function useTenants(): UseQueryResult { return useQuery({ queryKey: qk.tenants, queryFn: () => tenantsApi.list() }); diff --git a/src/apps/tracker-web/src/api/types.ts b/src/apps/tracker-web/src/api/types.ts index 738fc35d..0cfba37e 100644 --- a/src/apps/tracker-web/src/api/types.ts +++ b/src/apps/tracker-web/src/api/types.ts @@ -545,6 +545,58 @@ export interface AuditEntryDto { correlationId?: string | null; } +/* ── Integration · Runtime approvals (HITL) ─────────────────────────────── */ +/** + * GT-590 — QUÉ se está decidiendo. Espeja `ApprovalSubject` del Core (`approval.port.ts`) con + * los mismos nombres: renombrarlos aquí haría que el día que el contrato cambiara, el campo + * llegara y se descartara en silencio. + */ +export interface RuntimeApprovalSubjectDto { + kind: string; + /** Referencia estable al objeto concreto, única dentro de `kind`. */ + ref: string; + /** La línea sobre la que un humano puede decidir sin abrir nada más. */ + summary: string; + /** Confianza en [0,1] cuando la propuesta viene de un proponente probabilístico. */ + confidence?: number | null; + /** Opaco por contrato: se muestra, nunca se interpreta. */ + payload?: Record | null; +} + +/** + * Una fila de la cola de decisión humana (`GET /runtime-approvals/pending`). + * + * No es el DTO espejo que el Core recibe: aquél lleva sólo el veredicto, y esta es la pantalla de + * quien concede. `subject` es nulo en toda solicitud abierta por un runtime anterior a GT-590 — + * esas siguen siendo válidas y se muestran como lo que son: una decisión sin objeto declarado. + */ +export interface PendingRuntimeApprovalDto { + approvalId: string; + skillId: string; + intent: string; + productId?: string | null; + initiativeId?: string | null; + requestedBy?: string | null; + requestedAtUtc: string; + expiresAtUtc: string; + status: string; + subject?: RuntimeApprovalSubjectDto | null; +} + +export interface ResolveRuntimeApprovalRequest { + /** `approved` o `rejected`. Expirar es consecuencia del tiempo, no una decisión. */ + status: 'approved' | 'rejected'; + reason?: string; +} + +export interface RuntimeApprovalResponseDto { + approvalId?: string | null; + status: string; + approver?: string | null; + reason?: string | null; + subject?: RuntimeApprovalSubjectDto | null; +} + /* ── Integration · Core evaluation transactions ─────────────────────────── */ /** * CD-11 — refleja campo a campo lo que devuelve `GET /core-evaluation-transactions/` diff --git a/src/apps/tracker-web/src/components/screens/md3/AgentGovernanceMd3.tsx b/src/apps/tracker-web/src/components/screens/md3/AgentGovernanceMd3.tsx index dbcb74a2..2405045c 100644 --- a/src/apps/tracker-web/src/components/screens/md3/AgentGovernanceMd3.tsx +++ b/src/apps/tracker-web/src/components/screens/md3/AgentGovernanceMd3.tsx @@ -1,7 +1,14 @@ import React from 'react'; -import { ApiError, useAuditEntries, useCoreEvaluationTransactions } from '../../../api'; -import type { AuditEntryDto } from '../../../api'; import { + ApiError, + useAuditEntries, + useCoreEvaluationTransactions, + usePendingRuntimeApprovals, + useResolveRuntimeApproval, +} from '../../../api'; +import type { AuditEntryDto, PendingRuntimeApprovalDto } from '../../../api'; +import { + Button, Card, CardHeader, Chip, @@ -37,14 +44,88 @@ function KpiCard({ label, value, tone }: { label: string; value: React.ReactNode ); } +/** + * GT-590 — la fila de la cola. Lo que el humano necesita leer para decidir está en `subject` + * cuando el runtime lo declaró; cuando no, se dice ASÍ en vez de disfrazar `skillId` + `intent` + * de objeto: una solicitud sin sujeto es precisamente la que no se puede juzgar por su objeto, y + * ocultarlo devolvería al humano a decidir a ciegas creyendo que ve algo. + */ +function PendingApprovalRow({ + item, + onResolve, + busy, +}: { + item: PendingRuntimeApprovalDto; + onResolve: (id: string, status: 'approved' | 'rejected') => void; + busy: boolean; +}) { + const { t } = useTranslation(); + const s = item.subject; + + return ( +
+ + + +
+ + {s ? s.summary : item.intent} + +
+ {s ? ( + <> + {s.kind} · {s.ref} + + ) : ( + {t('no subject declared', 'sin sujeto declarado')} + )} + {' · '} + {item.skillId} + {' · '} + {t('expires', 'vence')} {fmtDateTime(item.expiresAtUtc)} +
+
+ {/* GT-584 — la confianza se muestra porque quien decide tiene derecho a saber que está + ratificando una CONJETURA y no un hecho. */} + {s?.confidence != null && ( + = 0.8 ? 'pass' : 'pend'}> + {(s.confidence * 100).toFixed(0)}% {t('confidence', 'confianza')} + + )} + + +
+ ); +} + export const AgentGovernanceMd3: React.FC = () => { const { t } = useTranslation(); const audit = useAuditEntries(); const coreTx = useCoreEvaluationTransactions(); + const pending = usePendingRuntimeApprovals(); + const resolve = useResolveRuntimeApproval(); // Derive posture ONLY from data we actually have — no fabricated agent list. const agentEntries = (audit.data ?? []).filter(isAgentEntry); const txCount = (coreTx.data ?? []).length; + const pendingItems = pending.data ?? []; + + const handleResolve = (id: string, status: 'approved' | 'rejected') => + resolve.mutate({ id, body: { status } }); return ( <> @@ -72,15 +153,59 @@ export const AgentGovernanceMd3: React.FC = () => { value={coreTx.isLoading ? '—' : txCount} /> - {t('no endpoint', 'sin endpoint')} - - } + label={t('Approvals awaiting a human', 'Aprobaciones esperando a un humano')} + value={pending.isLoading ? '—' : pendingItems.length} + tone={pendingItems.length > 0 ? 'var(--warn)' : undefined} /> +
+ {/* CD-23 / GT-590 — la compuerta humana. Mientras una solicitud espera aquí, el Core está + denegando la capacidad que la pidió: es la cola que se atiende primero. */} + + + {t('the runtime is blocked while these wait', 'el runtime está bloqueado mientras esperan')} + + } + /> + {pending.isLoading ? ( + + ) : pending.isError ? ( + pending.refetch()} + /> + ) : pendingItems.length === 0 ? ( + + ) : ( +
+ {pendingItems.map((item) => ( + + ))} +
+ )} +
+
+
{/* Agent-attributed activity (from the audit trail) */} @@ -168,8 +293,8 @@ export const AgentGovernanceMd3: React.FC = () => { {t( - 'A runtime skill/agent governance endpoint (e.g. list registered skills, HITL queue) is not available in the BFF today. When it lands, this screen will list live skills and pending human-in-the-loop approvals here.', - 'Hoy no hay disponible en el BFF un endpoint de gobernanza de skills/agentes en runtime (p. ej. listar skills registradas, cola HITL). Cuando exista, esta pantalla listará aquí las skills activas y las aprobaciones human-in-the-loop pendientes.', + 'The HITL queue above is live (CD-23). What is still missing is the skill registry: the BFF cannot list which runtime capabilities exist, so this screen can show what is awaiting a decision but not the catalogue it is decided against.', + 'La cola HITL de arriba ya es real (CD-23). Lo que sigue faltando es el registro de skills: el BFF no puede listar qué capacidades del runtime existen, así que esta pantalla puede mostrar qué espera decisión pero no el catálogo contra el que se decide.', )}