diff --git a/robosoft/robots/audit-trail.robot.mjs b/robosoft/robots/audit-trail.robot.mjs index 3a355fd2..a5295ed1 100644 --- a/robosoft/robots/audit-trail.robot.mjs +++ b/robosoft/robots/audit-trail.robot.mjs @@ -57,6 +57,14 @@ export default { check('action is faithful', entry.action === 'probe:performed', { detail: `action=${entry.action}` }); check('the actor is attributed', Boolean(entry.actorId), { detail: `actorId=${entry.actorId}` }); check('the record is timestamped', Boolean(entry.timestampUtc), { detail: `at=${entry.timestampUtc}` }); + // GT-603: attribution is not only WHO but WHAT KIND. A human action must say so — a ledger + // that types everything the same way cannot answer "did a person decide this?". + check('a human action is TYPED as human', entry.actorType === 'human', { + detail: `actorType=${entry.actorType}`, + }); + check('a human action carries no agent identity', !entry.agentId, { + detail: `agentId=${entry.agentId ?? 'null'}`, + }); recordedActor = entry.actorId; } @@ -77,6 +85,54 @@ export default { }); } + // ── Agent attribution (GT-603) ────────────────────────────────────────── + // The differentiating claim of the product is that the ledger tells a human + // apart from an agent. Until GT-603 the ONLY path an agent acted through + // (/assistant/converse) persisted nothing at all, so the claim was false in + // code. `audit_entries` is append-only by DB trigger, so every row written + // before the discriminator existed is permanently unattributable — which is + // why this is asserted end to end and not only in unit tests. + step('An agent turn lands in the SAME ledger, typed as an AGENT'); + { + const conversation = `robosoft-agent-${RUN}`; + const turn = await api.post('/assistant/converse', { + message: 'RoboSoft attribution probe: read-only turn.', + conversationId: conversation, + }); + check('POST /assistant/converse responds 2xx', turn.ok, { + detail: turn.error ? turn.error : `status=${turn.status}`, + }); + + const list = (await need('GET /audit-entries', api.get('/audit-entries'))).body; + const agentEntry = (Array.isArray(list) ? list : []).find( + (e) => e?.entityType === 'AgentTurn' && e?.sessionId === conversation); + + check('the agent turn is in the trail', Boolean(agentEntry), { + detail: agentEntry ? `id=${agentEntry.id}` : 'missing — the agent path wrote nothing', + }); + check('it is attributed to an AGENT, not to a human', agentEntry?.actorType === 'agent', { + detail: `actorType=${agentEntry?.actorType}`, + }); + check('the acting agent is identified', Boolean(agentEntry?.agentId), { + detail: `agentId=${agentEntry?.agentId ?? 'null'}`, + }); + // Both halves survive: WHO acted (the agent) and ON WHOSE BEHALF (the human principal). + check('the human on whose behalf it acted is preserved', Boolean(agentEntry?.actorId), { + detail: `actorId=${agentEntry?.actorId}`, + }); + check('the turn declares the scope it used', Boolean(agentEntry?.changes?.scope), { + detail: `scope=${agentEntry?.changes?.scope} action=${agentEntry?.action}`, + }); + // The ledger is append-only: a prompt written into it could never be withdrawn. + check('the prompt itself is NOT stored', agentEntry?.changes?.prompt === undefined, { + detail: `keys=${Object.keys(agentEntry?.changes ?? {}).join(',')}`, + }); + check('human and agent entries are distinguishable in the SAME trail', + agentEntry?.actorType === 'agent' && recordedActor !== undefined, { + detail: `agent=${agentEntry?.actorType} · human entry actor=${recordedActor}`, + }); + } + // ── Tenant scope ──────────────────────────────────────────────────────── step('The audit trail is tenant-scoped — tenant C does not see it'); { diff --git a/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Commands/CreateAuditEntry/CreateAuditEntryCommand.cs b/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Commands/CreateAuditEntry/CreateAuditEntryCommand.cs index 10a957c6..dedbe935 100644 --- a/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Commands/CreateAuditEntry/CreateAuditEntryCommand.cs +++ b/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Commands/CreateAuditEntry/CreateAuditEntryCommand.cs @@ -1,11 +1,19 @@ +using Tracker.Domain.Audit.AuditEntry; + namespace Tracker.Application.Audit.AuditEntry.Commands.CreateAuditEntry; +/// +/// GT-603 — lo fija el ADAPTADOR que recibe la peticion, nunca el cuerpo: si +/// un cliente pudiera declararse «agente» —o «humano»— la atribucion del expediente valdria lo que +/// valga la palabra de quien escribe en el. +/// public sealed record CreateAuditEntryCommand( Guid TenantId, string EntityType, string EntityId, string Action, Guid ActorId, + AuditActor Actor, Dictionary? Changes = null, string? CorrelationId = null ) : ICommand; diff --git a/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Commands/CreateAuditEntry/CreateAuditEntryCommandHandler.cs b/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Commands/CreateAuditEntry/CreateAuditEntryCommandHandler.cs index 56590c98..6af638c7 100644 --- a/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Commands/CreateAuditEntry/CreateAuditEntryCommandHandler.cs +++ b/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Commands/CreateAuditEntry/CreateAuditEntryCommandHandler.cs @@ -24,6 +24,7 @@ public async Task> Handle( request.EntityId, request.Action, request.ActorId, + request.Actor, request.Changes, request.CorrelationId); diff --git a/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/DTOs/AuditEntryDto.cs b/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/DTOs/AuditEntryDto.cs index 36ae29e4..df615068 100644 --- a/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/DTOs/AuditEntryDto.cs +++ b/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/DTOs/AuditEntryDto.cs @@ -7,6 +7,13 @@ public sealed class AuditEntryDto public string EntityId { get; init; } = string.Empty; public string Action { get; init; } = string.Empty; public Guid ActorId { get; init; } + + /// GT-603 — human / agent / system, o `unknown` si es anterior al discriminador. + public string ActorType { get; init; } = string.Empty; + public string? AgentId { get; init; } + public string? ModelId { get; init; } + public string? SessionId { get; init; } + public DateTime TimestampUtc { get; init; } public Dictionary Changes { get; init; } = new(); public string? CorrelationId { get; init; } diff --git a/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Queries/GetAuditEntry/GetAuditEntryQueryHandler.cs b/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Queries/GetAuditEntry/GetAuditEntryQueryHandler.cs index 34acc1ae..92f55b3c 100644 --- a/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Queries/GetAuditEntry/GetAuditEntryQueryHandler.cs +++ b/src/apps/tracker-api/Tracker.Application/Audit/AuditEntry/Queries/GetAuditEntry/GetAuditEntryQueryHandler.cs @@ -29,6 +29,10 @@ public GetAuditEntryQueryHandler(Tracker.Domain.Audit.AuditEntry.IAuditEntryRepo EntityId = entry.EntityId, Action = entry.Action, ActorId = entry.ActorId, + ActorType = entry.ActorType, + AgentId = entry.AgentId, + ModelId = entry.ModelId, + SessionId = entry.SessionId, TimestampUtc = entry.TimestampUtc, Changes = entry.Changes, CorrelationId = entry.CorrelationId diff --git a/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs b/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs index b26177dc..b56c05a7 100644 --- a/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs +++ b/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs @@ -68,6 +68,17 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddSingleton(); services.AddSingleton(); + // EAG-16 / GT-603 — el ledger de turnos agenticos. Estaba escrito y probado, y no estaba + // REGISTRADO: `IAgentExecutionPort` no aparecia en ningun contenedor, asi que ninguna + // peticion podia alcanzarlo y ningun turno se escribia. Va aqui, junto al resto de servicios + // de aplicacion, porque ambos tipos son `internal` a este ensamblado; el ejecutor concreto + // que hay al otro lado (`IAgentRuntimeExecutor`) lo ata el host, que es quien conoce el + // runtime — separar las dos cosas es lo que T-010 exige. + services.AddScoped(); + services.AddScoped(); + // Repository context builder: assembles the inline satellite evaluation context. // The provider source-reader resolver is registered by the host (Presentation), // which owns the HttpClient-backed GitHub reader. diff --git a/src/apps/tracker-api/Tracker.Application/Discovery/Initiative/Commands/EvaluateGate/EvaluateGateCommandHandler.cs b/src/apps/tracker-api/Tracker.Application/Discovery/Initiative/Commands/EvaluateGate/EvaluateGateCommandHandler.cs index 86469bbc..c73f9b37 100644 --- a/src/apps/tracker-api/Tracker.Application/Discovery/Initiative/Commands/EvaluateGate/EvaluateGateCommandHandler.cs +++ b/src/apps/tracker-api/Tracker.Application/Discovery/Initiative/Commands/EvaluateGate/EvaluateGateCommandHandler.cs @@ -69,6 +69,9 @@ public async Task> Handle( initiative.Id.ToString(), "initiative.discovery-gate.passed", Guid.Empty, + // GT-603 — el veredicto lo produce el motor, no una persona: `Guid.Empty` ya lo decia + // sin poder afirmarlo. Ahora lo afirma. + AuditActor.System(), changes, correlationId: initiative.Code); diff --git a/src/apps/tracker-api/Tracker.Application/Discovery/Initiative/Commands/RecordApproval/RecordApprovalCommandHandler.cs b/src/apps/tracker-api/Tracker.Application/Discovery/Initiative/Commands/RecordApproval/RecordApprovalCommandHandler.cs index 41bc4673..c2145978 100644 --- a/src/apps/tracker-api/Tracker.Application/Discovery/Initiative/Commands/RecordApproval/RecordApprovalCommandHandler.cs +++ b/src/apps/tracker-api/Tracker.Application/Discovery/Initiative/Commands/RecordApproval/RecordApprovalCommandHandler.cs @@ -95,6 +95,9 @@ private async Task WriteAuditAsync( initiative.Id.ToString(), action, Guid.Empty, + // GT-603 — una aprobacion la decide una persona. Su identidad viaja hoy en + // `approverIdentity` dentro de `changes`; sera un Guid cuando EAG-01 cierre con UMS. + AuditActor.Human(), changes, correlationId: initiative.Code); diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GateSubmission/Services/GateAuditWriter.cs b/src/apps/tracker-api/Tracker.Application/Governance/GateSubmission/Services/GateAuditWriter.cs index 47238fb9..35211e8a 100644 --- a/src/apps/tracker-api/Tracker.Application/Governance/GateSubmission/Services/GateAuditWriter.cs +++ b/src/apps/tracker-api/Tracker.Application/Governance/GateSubmission/Services/GateAuditWriter.cs @@ -41,6 +41,8 @@ public async Task WriteAsync( submission.Id.ToString(), action, actorId, + // GT-603 — `actorId` viene del contexto de usuario autenticado del endpoint. + AuditActor.Human(), changes, correlationId: submission.Id.ToString()); diff --git a/src/apps/tracker-api/Tracker.Application/Intake/Services/IntakeAuditWriter.cs b/src/apps/tracker-api/Tracker.Application/Intake/Services/IntakeAuditWriter.cs index 571f2409..eaf72ec9 100644 --- a/src/apps/tracker-api/Tracker.Application/Intake/Services/IntakeAuditWriter.cs +++ b/src/apps/tracker-api/Tracker.Application/Intake/Services/IntakeAuditWriter.cs @@ -39,6 +39,9 @@ public async Task WriteAsync( initiative.Id.ToString(), action, actorId, + // GT-603 — el intake lo mueve una persona a traves de la API; el canal de maquina + // (PPM) tiene su propio escritor. Si eso deja de ser cierto, este es el sitio a tocar. + AuditActor.Human(), changes, correlationId: initiative.ExternalId); diff --git a/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/AgentExecutionService.cs b/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/AgentExecutionService.cs index 29ea143f..5f2786af 100644 --- a/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/AgentExecutionService.cs +++ b/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/AgentExecutionService.cs @@ -12,6 +12,15 @@ public async Task> ExecuteAsync( { ArgumentNullException.ThrowIfNull(request); + // 0) GT-603 — sin identidad de agente el turno no es ATRIBUIBLE, y se rechaza antes que + // nada: incluso el asiento de rechazo necesita decir quien lo intento. El expediente es + // append-only, asi que una fila mal atribuida no se corrige nunca; no escribirla y + // negar el turno es la unica salida que no deja evidencia falsa. + if (string.IsNullOrWhiteSpace(request.AgentId)) + { + return Result.Failure("AgentExecution.UnattributableTurn"); + } + // 1) El scope tiene que existir. Un scope inventado no es «uno que no reconocemos y por // tanto inofensivo»: es una peticion que no sabemos evaluar, y no saber evaluarla es // razon suficiente para rechazarla. diff --git a/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/AgentTurnAuditor.cs b/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/AgentTurnAuditor.cs index 2250e693..9f4c3740 100644 --- a/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/AgentTurnAuditor.cs +++ b/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/AgentTurnAuditor.cs @@ -17,7 +17,9 @@ Task> RecordAsync( AgentTurnRequest request, string outcome, string? failureReason, CancellationToken ct = default); } -internal sealed class AgentTurnAuditor(IAuditEntryRepository auditRepository) : IAgentTurnAuditor +internal sealed class AgentTurnAuditor( + IAuditEntryRepository auditRepository, + IUnitOfWork unitOfWork) : IAgentTurnAuditor { /// Tipo de entidad con el que los turnos agénticos se distinguen en el expediente. public const string EntityType = "AgentTurn"; @@ -47,12 +49,16 @@ public async Task> RecordAsync( // El PROMPT NO se guarda, sólo su longitud. Puede arrastrar datos del cliente, y el // expediente es append-only: lo que entre ahi no se puede retirar despues. Guardar por si // acaso algo que no se puede borrar es como se construye una fuga permanente. + // + // GT-603 — la ENTIDAD del asiento es la sesion: es lo que permite reconstruir un hilo + // completo («todos los turnos de esta conversacion») en vez de asientos sueltos. var entry = AuditEntry.Create( request.TenantId, EntityType, - request.CorrelationId ?? Guid.NewGuid().ToString(), + request.SessionId ?? request.CorrelationId ?? Guid.NewGuid().ToString(), $"agent:{request.Scope}", request.ActorId, + AuditActor.Agent(request.AgentId, request.ModelId, request.SessionId), cambios, request.CorrelationId); @@ -61,7 +67,22 @@ public async Task> RecordAsync( return Result.Failure(entry.Error); } - await auditRepository.AddAsync(entry.Value!, ct); + // GT-603 — se CONFIRMA aqui. `AddAsync` sobre un repositorio EF no escribe nada por si + // solo: sin este `Save`, el servicio comprobaba que el asiento «se pudo registrar» contra + // un cambio que aun vivia en memoria y que nadie confirmaba despues — la garantia de + // «si no se puede registrar, no se ejecuta» era, literalmente, no ejecutar nada. + try + { + await auditRepository.AddAsync(entry.Value!, ct); + await unitOfWork.SaveEntitiesAsync(ct); + } + catch (Exception ex) + { + // Un fallo al escribir el expediente NO se propaga como excepcion: es una respuesta + // legitima del auditor, y el servicio la traduce en «este turno no ocurre». + return Result.Failure($"AgentTurnAuditor.WriteFailed: {ex.Message}"); + } + return Result.Success(entry.Value!.Id); } } diff --git a/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/IAgentExecutionPort.cs b/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/IAgentExecutionPort.cs index 934d1762..96e008a0 100644 --- a/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/IAgentExecutionPort.cs +++ b/src/apps/tracker-api/Tracker.Application/Integration/AgentExecution/IAgentExecutionPort.cs @@ -30,14 +30,37 @@ public interface IAgentExecutionPort /// /// Un turno: quién, en nombre de qué tenant, con qué scope y sobre qué. +/// +/// GT-603 — es obligatorio y va junto al humano, +/// no en su lugar: un turno tiene SIEMPRE dos partes —la persona en cuyo nombre se actúa y el +/// ejecutor que actúa— y colapsarlas es exactamente lo que hacía que el expediente no pudiera +/// distinguir un acto humano de uno agéntico. /// public sealed record AgentTurnRequest( Guid TenantId, Guid ActorId, + string AgentId, string Scope, string Prompt, IReadOnlyList GrantedScopes, - string? CorrelationId = null); + string? ModelId = null, + string? SessionId = null, + string? CorrelationId = null, + AgentTurnFraming? Framing = null); + +/// +/// Encuadre del turno: dónde ocurre y con qué parámetros. Es opaco para la gobernanza —ni los +/// scopes ni la auditoría lo leen— y existe para que atar el puerto delante del runtime no obligue +/// a perder por el camino el contexto que la superficie conversacional ya sabía transmitir. +/// +public sealed record AgentTurnFraming( + string? ProductId = null, + string? InitiativeId = null, + string? Phase = null, + string? Gate = null, + Guid? TenantId = null, + IReadOnlyDictionary? Parameters = null, + bool? DryRun = null); /// /// Resultado del turno más el identificador del asiento que lo registra. Devolver el asiento no es diff --git a/src/apps/tracker-api/Tracker.Application/Integration/Publication/GateDecisionPublisher.cs b/src/apps/tracker-api/Tracker.Application/Integration/Publication/GateDecisionPublisher.cs index 80ac884b..7fcffb6e 100644 --- a/src/apps/tracker-api/Tracker.Application/Integration/Publication/GateDecisionPublisher.cs +++ b/src/apps/tracker-api/Tracker.Application/Integration/Publication/GateDecisionPublisher.cs @@ -59,6 +59,8 @@ internal sealed class GateDecisionPublisher( var asiento = Tracker.Domain.Audit.AuditEntry.AuditEntry.Create( tenantId, "GateDecision", gateDecisionId.ToString(), "publication:skipped", Guid.Empty, + // GT-603 — no publicar lo decide la plataforma al no poder resolver el componente. + Tracker.Domain.Audit.AuditEntry.AuditActor.System(), new Dictionary { ["reason"] = "ComponentNotResolvable", diff --git a/src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditActor.cs b/src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditActor.cs new file mode 100644 index 00000000..6cced8e7 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditActor.cs @@ -0,0 +1,93 @@ +namespace Tracker.Domain.Audit.AuditEntry; + +/// +/// GT-603 — quién escribió el asiento: una persona, un agente o la plataforma. +/// +/// Por qué esto no podía esperar. audit_entries es append-only por trigger de +/// base de datos (`COH-014`): una fila escrita sin discriminador no se puede tipar después, porque +/// el UPDATE que lo haría está prohibido. Cada asiento escrito antes de que esta columna +/// existiera queda inatribuible PARA SIEMPRE. Es la única deuda del expediente que no se paga más +/// cara con el tiempo: simplemente deja de poder pagarse. +/// +/// Unknown no es un tipo de actor: es la ausencia de uno. Existe únicamente +/// para representar lo que ya estaba en la tabla cuando llegó la migración, y por eso +/// lo excluye — ningún camino de escritura puede producirlo. Decir «no +/// consta» sobre una fila antigua es cierto; decir «lo hizo una persona» sería inventar evidencia +/// en el registro que precisamente se presenta como evidencia. +/// +public static class AuditActorType +{ + /// Una persona identificada actuó. + public const string Human = "human"; + + /// Un ejecutor no humano actuó dentro de un scope concedido (EAG-16). + public const string Agent = "agent"; + + /// La plataforma actuó sin que nadie se lo pidiera: reintentos, drenajes, veredictos. + public const string System = "system"; + + /// + /// Escrito ANTES de que el discriminador existiera. Reservado a la migración; ninguna ruta de + /// escritura lo admite. + /// + public const string Unknown = "unknown"; + + /// Lo que una escritura nueva puede declarar. no está, a propósito. + public static readonly string[] Writable = [Human, Agent, System]; + + public static bool IsWritable(string? actorType) => + actorType is not null && Writable.Contains(actorType, StringComparer.Ordinal); +} + +/// +/// La atribución completa de un asiento. Para un agente incluye su identidad, el modelo que lo +/// respalda y la sesión en la que actuó: sin esos tres, «lo hizo un agente» es una etiqueta que no +/// permite ir a preguntar a nadie. +/// +public sealed record AuditActor +{ + private AuditActor(string type, string? agentId, string? modelId, string? sessionId) + { + Type = type; + AgentId = agentId; + ModelId = modelId; + SessionId = sessionId; + } + + public string Type { get; } + + /// Identidad del ejecutor no humano. Sólo para . + public string? AgentId { get; } + + /// Modelo que respaldó el turno, cuando se conoce. + public string? ModelId { get; } + + /// Sesión/conversación que agrupa los turnos de un mismo hilo. + public string? SessionId { get; } + + public static AuditActor Human() => new(AuditActorType.Human, null, null, null); + + public static AuditActor System() => new(AuditActorType.System, null, null, null); + + /// + /// Un turno agéntico. El es obligatorio: un agente sin identidad no + /// es atribuible, y un asiento no atribuible no es evidencia — vale lo mismo que no escribirlo. + /// + public static AuditActor Agent(string agentId, string? modelId = null, string? sessionId = null) + { + if (string.IsNullOrWhiteSpace(agentId)) + { + throw new ArgumentException( + "un turno agentico sin identidad de agente no es atribuible", nameof(agentId)); + } + + return new AuditActor(AuditActorType.Agent, agentId, modelId, sessionId); + } + + /// + /// Rehidratación desde el almacén. Es la ÚNICA vía por la que + /// entra en el modelo: se lee de filas anteriores a la migración, nunca se escribe. + /// + public static AuditActor FromStorage(string type, string? agentId, string? modelId, string? sessionId) + => new(type, agentId, modelId, sessionId); +} diff --git a/src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditEntry.cs b/src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditEntry.cs index 56466167..ddccc225 100644 --- a/src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditEntry.cs +++ b/src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditEntry.cs @@ -10,19 +10,43 @@ private AuditEntry(AuditEntryProps props) : base(props) { } public string EntityId => Props.EntityId; public string Action => Props.Action; public Guid ActorId => Props.ActorId; + public string ActorType => Props.ActorType; + public string? AgentId => Props.AgentId; + public string? ModelId => Props.ModelId; + public string? SessionId => Props.SessionId; public DateTime TimestampUtc => Props.TimestampUtc; public Dictionary Changes => Props.Changes; public string? CorrelationId => Props.CorrelationId; + /// + /// GT-603 — es obligatorio y va ANTES de los opcionales a propósito: + /// obliga a cada sitio que escribe en el expediente a declarar quién escribe. Un parámetro + /// opcional con valor por defecto habría hecho compilar todo sin que nadie lo pensara, que es + /// exactamente cómo se llega a un registro lleno de filas que dicen «humano» sin serlo. + /// public static Result Create( Guid tenantId, string entityType, string entityId, string action, Guid actorId, + AuditActor actor, Dictionary? changes = null, string? correlationId = null) { + if (actor is null) + { + return Result.Failure("AuditEntry.ActorTypeRequired"); + } + + // `unknown` sólo lo produce la migración para lo que ya estaba escrito. Que un camino de + // aplicación pudiera producirlo vaciaría de significado la única marca que distingue «no + // consta porque es anterior» de «no se molestaron en decirlo». + if (!AuditActorType.IsWritable(actor.Type)) + { + return Result.Failure("AuditEntry.ActorTypeNotWritable"); + } + var props = new AuditEntryProps { Id = TrackerId.Create(), @@ -31,6 +55,10 @@ public static Result Create( EntityId = entityId, Action = action, ActorId = actorId, + ActorType = actor.Type, + AgentId = actor.AgentId, + ModelId = actor.ModelId, + SessionId = actor.SessionId, TimestampUtc = DateTime.UtcNow, Changes = changes ?? new(), CorrelationId = correlationId, @@ -39,4 +67,15 @@ public static Result Create( return Result.Success(new AuditEntry(props)); } + + /// + /// Reconstrucción desde el almacén. Separada de porque leer y escribir no + /// están sujetos a la misma regla: una fila anterior a GT-603 dice unknown y debe poder + /// leerse tal cual, mientras que ninguna escritura nueva puede producir ese valor. + /// + public static AuditEntry Rehydrate(AuditEntryProps props) + { + ArgumentNullException.ThrowIfNull(props); + return new AuditEntry(props); + } } diff --git a/src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditEntryProps.cs b/src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditEntryProps.cs index 8c8a7cc7..2ad85891 100644 --- a/src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditEntryProps.cs +++ b/src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditEntryProps.cs @@ -9,6 +9,23 @@ public sealed record AuditEntryProps : IProps public string EntityId { get; init; } = string.Empty; public string Action { get; init; } = string.Empty; public Guid ActorId { get; init; } + + /// + /// GT-603 — humano / agente / sistema. required y no opcional con valor por defecto: el + /// defecto lo habría rellenado el compilador, y quien construye un asiento es la única parte + /// que sabe de verdad quién lo está escribiendo. + /// + public required string ActorType { get; init; } + + /// Identidad del ejecutor no humano. Nulo en un acto humano o de plataforma. + public string? AgentId { get; init; } + + /// Modelo que respaldó el turno agéntico, cuando se conoce. + public string? ModelId { get; init; } + + /// Sesión/conversación que agrupa los turnos de un mismo hilo. + public string? SessionId { get; init; } + public DateTime TimestampUtc { get; init; } public Dictionary Changes { get; init; } = new(); public string? CorrelationId { get; init; } diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Aop/AuditTrailPersistenceBackgroundService.cs b/src/apps/tracker-api/Tracker.Infrastructure/Aop/AuditTrailPersistenceBackgroundService.cs index 954826b5..6dae6d00 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Aop/AuditTrailPersistenceBackgroundService.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Aop/AuditTrailPersistenceBackgroundService.cs @@ -62,6 +62,10 @@ private async Task PersistAsync(AuditTrailEntry entry, CancellationToken cancell entry.AffectedEntityId.ToString(), entry.EventType, entry.WhoActed, + // GT-603 — el sobre del aspecto YA traia el discriminador (`SubjectType`), asi que el + // tipo no se inventa aqui: se traduce. Un agente no llega por esta via —los turnos + // agenticos los escribe `IAgentTurnAuditor`—, por eso no hay rama para `agent`. + entry.SubjectType == AuditSubjectTypes.System ? AuditActor.System() : AuditActor.Human(), changes); if (auditEntryResult.IsFailure) diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731021901_AddAuditActorAttribution.Designer.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731021901_AddAuditActorAttribution.Designer.cs new file mode 100644 index 00000000..b5fc2c2f --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731021901_AddAuditActorAttribution.Designer.cs @@ -0,0 +1,3755 @@ +// +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("20260731021901_AddAuditActorAttribution")] + partial class AddAuditActorAttribution + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("tracker_governance") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Artifacts.Entities.EvidenceRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdapterVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("adapter_version"); + + b.Property("ArtifactId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_type"); + + b.Property("Classification") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("classification"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentHash") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("content_hash"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Determinism") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("determinism"); + + b.Property("Dimension") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("unspecified") + .HasColumnName("dimension"); + + b.Property("EvidenceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("evidence_type"); + + b.Property("Immutable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("immutable"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Producer") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("producer"); + + b.Property("ReferencesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("references"); + + b.Property("RetentionPolicyRef") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("retention_policy_ref"); + + b.Property("SourceProvider") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("source_provider"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("SubmittedBy") + .HasColumnType("uuid") + .HasColumnName("submitted_by"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Classification"); + + b.HasIndex("Dimension"); + + b.HasIndex("TenantId"); + + b.HasIndex("ArtifactType", "ArtifactId"); + + b.ToTable("evidence_records", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Audit.Entities.AuditEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("ActorType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("actor_type"); + + b.Property("AgentId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("agent_id"); + + b.Property("ChangesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("session_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ActorId"); + + b.HasIndex("ActorType"); + + b.HasIndex("TenantId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_entries", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Design.Entities.TechnicalBlueprintRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IntegrationAdrRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("integration_adr_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("technical_blueprints", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.InitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalComplete") + .HasColumnType("boolean") + .HasColumnName("approval_complete"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedBy") + .HasColumnType("uuid") + .HasColumnName("approved_by"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConstituentsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("constituents"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("origin"); + + b.Property("OriginRef") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("origin_ref"); + + b.Property("ProductScopeJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("product_scope"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("RequiredApproversJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_approvers"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SupersededByInitiativeId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_initiative_id"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TypeFrozen") + .HasColumnType("boolean") + .HasColumnName("type_frozen"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code"); + + b.ToTable("initiatives", "tracker_governance", t => + { + t.HasCheckConstraint("ck_initiatives_status", "status IN ('draft', 'submitted', 'approved', 'rejected', 'consolidated')"); + }); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.OpportunityRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("ContextJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("context"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("opportunities", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CatalogReleaseRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("catalog_release", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CountryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DefaultCurrencyCode") + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("default_currency_code"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character(2)") + .HasColumnName("iso_alpha2") + .IsFixedLength(); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("iso_alpha3") + .IsFixedLength(); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumericCode") + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("numeric_code") + .IsFixedLength(); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IsoAlpha2") + .IsUnique(); + + b.HasIndex("IsoAlpha3") + .IsUnique(); + + b.ToTable("country", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CurrencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MinorUnits") + .HasColumnType("smallint") + .HasColumnName("minor_units"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("symbol"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currency", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.ExchangeRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("base_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("QuoteCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("quote_code"); + + b.Property("Rate") + .HasColumnType("numeric(20,10)") + .HasColumnName("rate"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("BaseCode", "QuoteCode", "ValidFrom") + .HasDatabaseName("ix_exchange_rate_pair"); + + b.ToTable("exchange_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LanguageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("language", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocaleProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DateFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("date_format"); + + b.Property("FirstDayOfWeek") + .HasColumnType("smallint") + .HasColumnName("first_day_of_week"); + + b.Property("Name") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumberFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("number_format"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CountryId"); + + b.ToTable("locale_profile", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scheme"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("LocationId", "Scheme") + .IsUnique(); + + b.HasIndex("Scheme", "Value") + .IsUnique(); + + b.ToTable("location_code", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.HasKey("LocationId", "Locale"); + + b.ToTable("location_i18n", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationLevelRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("Ordinal") + .HasColumnType("smallint") + .HasColumnName("ordinal"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Ordinal") + .IsUnique(); + + b.ToTable("location_level", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CatalogReleaseId") + .HasColumnType("uuid") + .HasColumnName("catalog_release_id"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LevelId") + .HasColumnType("uuid") + .HasColumnName("level_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("SupersedesId") + .HasColumnType("uuid") + .HasColumnName("supersedes_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_location_parent"); + + b.HasIndex("Path") + .HasDatabaseName("ix_location_path"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist"); + + b.HasIndex("CountryId", "ParentId") + .HasDatabaseName("ix_location_country_parent"); + + b.ToTable("location", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("OperationType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation_type"); + + b.Property("Rate") + .HasColumnType("numeric(6,4)") + .HasColumnName("rate"); + + b.Property("TaxSchemeId") + .HasColumnType("uuid") + .HasColumnName("tax_scheme_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("TaxSchemeId", "LocationId", "ValidFrom") + .HasDatabaseName("ix_tax_rate_scheme_location"); + + b.ToTable("tax_rate", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxSchemeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Code") + .IsUnique(); + + b.ToTable("tax_scheme", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TimeZoneRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("country_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IanaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("iana_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.HasIndex("IanaId") + .IsUnique(); + + b.ToTable("time_zone", "tracker_geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ArtifactFieldSchemaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("artifact_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ArtifactType") + .IsUnique(); + + b.ToTable("artifact_field_schemas", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ExceptionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CriterionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("criterion_id"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at_utc"); + + b.Property("DecidedBy") + .HasColumnType("uuid") + .HasColumnName("decided_by"); + + b.Property("DecisionNotes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("decision_notes"); + + b.Property("ExceptionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("exception_type"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("Justification") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("justification"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("RequestedBy") + .HasColumnType("uuid") + .HasColumnName("requested_by"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_exception_requests_tenant_status"); + + b.HasIndex("TenantId", "SdlcExecutionId", "Phase") + .HasDatabaseName("ix_exception_requests_tenant_execution_phase"); + + b.ToTable("exception_requests", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("approval_ids"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DecisionBy") + .HasColumnType("uuid") + .HasColumnName("decision_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationIdsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evaluation_ids"); + + b.Property("EvidenceSnapshotRef") + .HasColumnType("text") + .HasColumnName("evidence_snapshot_ref"); + + b.Property("ExceptionIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("exception_ids"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("PolicySnapshotRef") + .HasColumnType("text") + .HasColumnName("policy_snapshot_ref"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rationale"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Phase"); + + b.HasIndex("SdlcExecutionId"); + + b.HasIndex("TenantId"); + + b.ToTable("gate_decisions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GatePolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("ApprovalPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approval_policy"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationCriteriaJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evaluation_criteria"); + + b.Property("ExceptionPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("exception_policy"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequiredEvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_evidence"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("gate_policies", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateSubmissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approvals"); + + b.Property("CoreVerdictJson") + .HasColumnType("jsonb") + .HasColumnName("core_verdict"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CriteriaEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("criteria_evaluation"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("requested_by"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("state"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase"); + + b.ToTable("gate_submissions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.PhaseGateStateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid") + .HasColumnName("evaluated_by"); + + b.Property("GateStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gate_status"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phase"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase"); + + b.HasIndex("TenantId", "SdlcExecutionId"); + + b.ToTable("phase_gate_states", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.RuntimeApprovalRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Approver") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("approver"); + + b.Property("CorrelationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("ExecutionMode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("execution_mode"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("InitiativeId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("initiative_id"); + + b.Property("Intent") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("intent"); + + b.Property("ProductId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("product_id"); + + b.Property("Reason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("reason"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at_utc"); + + b.Property("RequestedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("requested_by"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at_utc"); + + b.Property("SkillId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("skill_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CorrelationId") + .IsUnique() + .HasDatabaseName("ix_runtime_approvals_tenant_correlation") + .HasFilter("correlation_id IS NOT NULL"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_runtime_approvals_tenant_status"); + + b.ToTable("runtime_approvals", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.SDLCExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BlockedReason") + .HasColumnType("text") + .HasColumnName("blocked_reason"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ProductCode"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "InitiativeId") + .HasDatabaseName("ix_sdlc_executions_tenant_initiative"); + + b.ToTable("sdlc_executions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeDeadLetterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("intake_dead_letter", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("Status", "NextAttemptAtUtc"); + + b.ToTable("intake_outbox", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.PpmEndpointRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("none") + .HasColumnName("auth_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FieldBindingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("field_bindings"); + + b.Property("HealthState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("health_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sync_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasColumnType("text") + .HasColumnName("secret_ref"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("TelemetryUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("telemetry_url"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ppm_endpoints", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicBaselineRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AnchoredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("anchored_at"); + + b.Property("Budget") + .IsRequired() + .HasColumnType("text") + .HasColumnName("budget"); + + b.Property("ExpectedRoi") + .IsRequired() + .HasColumnType("text") + .HasColumnName("expected_roi"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("RiskLevel") + .IsRequired() + .HasColumnType("text") + .HasColumnName("risk_level"); + + b.Property("StrategicInitiativeId") + .HasColumnType("uuid") + .HasColumnName("strategic_initiative_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("StrategicInitiativeId"); + + b.HasIndex("StrategicInitiativeId", "Version") + .IsUnique(); + + b.ToTable("strategic_baselines", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicInitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("FeasibilityJson") + .HasColumnType("jsonb") + .HasColumnName("feasibility"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("IntakeState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("intake_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("ProductCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ProductName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("product_name"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IntakeState"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ExternalId") + .IsUnique(); + + b.ToTable("strategic_initiatives", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.CoreEvaluationTransactionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("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/20260731021901_AddAuditActorAttribution.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731021901_AddAuditActorAttribution.cs new file mode 100644 index 00000000..5c10ddf6 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731021901_AddAuditActorAttribution.cs @@ -0,0 +1,127 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + /// + /// GT-603 — el expediente pasa a decir QUIÉN escribió cada asiento: una persona, un agente o + /// la plataforma. + /// + /// Por qué esta migración no podía esperar. `audit_entries` es append-only por + /// trigger desde `COH-014`: UPDATE, DELETE y TRUNCATE los rechaza la base + /// sea quien sea el que ejecuta. Eso significa que una fila escrita sin discriminador + /// no se puede tipar después — no es que cueste, es que la única sentencia que lo haría + /// está prohibida. Cada asiento escrito antes de esta migración queda inatribuible para + /// siempre. Es la única deuda de este registro que no se encarece con el tiempo: deja de poder + /// pagarse. + /// + /// Lo anterior se representa como unknown, no como human. El + /// DEFAULT del ADD COLUMN es lo que rellena las filas ya existentes, y PostgreSQL + /// lo aplica como valor por defecto de la columna sin reescribir la tabla (fast default, + /// PG≥11): no hay UPDATE, así que el trigger ni se entera. Un relleno explícito + /// —UPDATE ... SET actor_type = 'human'— habría fallado contra el trigger Y habría sido + /// mentira: nadie sabe si aquellas filas las escribió una persona. En un registro que se + /// presenta como evidencia, «no consta» es un dato; «lo hizo una persona» sin saberlo es una + /// invención. + /// + /// Y el DEFAULT se retira acto seguido. Si sobreviviera, un INSERT + /// que se olvidara del discriminador entraría igualmente como unknown y la garantía + /// «toda fila NUEVA declara quién la escribió» sería decorativa. Sin default y con + /// NOT NULL, olvidarlo es un error de la base, no una fila silenciosamente sin atribuir. + /// De paso, unknown conserva su único significado: «anterior al discriminador». + /// + /// Los triggers no se tocan. Añadir una columna a una tabla protegida es justo el + /// momento en el que resulta tentador desactivarlos «un segundo»; no hace falta, porque un + /// ALTER TABLE no es una operación DML y los triggers son BEFORE UPDATE/DELETE/TRUNCATE. + /// La guarda viva AuditActorAttributionSchemaLiveTests comprueba contra PostgreSQL real + /// que los tres siguen en pie después de esto. + /// + public partial class AddAuditActorAttribution : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // El DEFAULT existe SOLO durante esta sentencia: es lo que representa lo ya escrito. + migrationBuilder.AddColumn( + name: "actor_type", + schema: "tracker_governance", + table: "audit_entries", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: "unknown"); + + // Y se retira, para que ninguna fila nueva pueda entrar sin declarar su actor. + migrationBuilder.Sql( + "ALTER TABLE tracker_governance.audit_entries ALTER COLUMN actor_type DROP DEFAULT;"); + + migrationBuilder.Sql( + "COMMENT ON COLUMN tracker_governance.audit_entries.actor_type IS " + + "'GT-603: human | agent | system. ''unknown'' = fila escrita ANTES de que el " + + "discriminador existiera; no es tipable a posteriori (la tabla es append-only) y " + + "ninguna ruta de escritura puede producirlo.';"); + + migrationBuilder.AddColumn( + name: "agent_id", + schema: "tracker_governance", + table: "audit_entries", + type: "character varying(128)", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "model_id", + schema: "tracker_governance", + table: "audit_entries", + type: "character varying(128)", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "session_id", + schema: "tracker_governance", + table: "audit_entries", + type: "character varying(128)", + maxLength: 128, + nullable: true); + + // «Enseñame todo lo que hizo un agente» es la consulta que esta ficha existe para poder + // contestar; sin indice, contestarla recorre el expediente entero. + migrationBuilder.CreateIndex( + name: "IX_audit_entries_actor_type", + schema: "tracker_governance", + table: "audit_entries", + column: "actor_type"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_audit_entries_actor_type", + schema: "tracker_governance", + table: "audit_entries"); + + migrationBuilder.DropColumn( + name: "actor_type", + schema: "tracker_governance", + table: "audit_entries"); + + migrationBuilder.DropColumn( + name: "agent_id", + schema: "tracker_governance", + table: "audit_entries"); + + migrationBuilder.DropColumn( + name: "model_id", + schema: "tracker_governance", + table: "audit_entries"); + + migrationBuilder.DropColumn( + name: "session_id", + schema: "tracker_governance", + table: "audit_entries"); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs index 893fb0e2..4c583816 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs @@ -206,6 +206,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("actor_id"); + b.Property("ActorType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("actor_type"); + + b.Property("AgentId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("agent_id"); + b.Property("ChangesJson") .IsRequired() .HasColumnType("jsonb") @@ -249,6 +260,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasColumnName("is_deleted"); + b.Property("ModelId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("session_id"); + b.Property("TenantId") .HasColumnType("uuid") .HasColumnName("tenant_id"); @@ -270,6 +291,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ActorId"); + b.HasIndex("ActorType"); + b.HasIndex("TenantId"); b.HasIndex("EntityType", "EntityId"); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/Configurations/AuditEntryRecordConfiguration.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/Configurations/AuditEntryRecordConfiguration.cs index 2fa5c10a..0e44a652 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/Configurations/AuditEntryRecordConfiguration.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/Configurations/AuditEntryRecordConfiguration.cs @@ -17,6 +17,14 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.EntityId).HasColumnName("entity_id").IsRequired().HasMaxLength(128); builder.Property(x => x.Action).HasColumnName("action").IsRequired().HasMaxLength(64); builder.Property(x => x.ActorId).HasColumnName("actor_id"); + // GT-603. Sin `HasDefaultValue`: el DEFAULT vive SOLO dentro de la migracion, para poder + // representar lo ya escrito como `unknown` sin reescribir la tabla (que el trigger + // append-only prohibe). Si el modelo lo declarase, EF lo restauraria en la base y una fila + // nueva sin discriminador volveria a entrar. + builder.Property(x => x.ActorType).HasColumnName("actor_type").IsRequired().HasMaxLength(32); + builder.Property(x => x.AgentId).HasColumnName("agent_id").HasMaxLength(128); + builder.Property(x => x.ModelId).HasColumnName("model_id").HasMaxLength(128); + builder.Property(x => x.SessionId).HasColumnName("session_id").HasMaxLength(128); builder.Property(x => x.TimestampUtc).HasColumnName("timestamp").IsRequired(); builder.Property(x => x.ChangesJson).HasColumnName("changes").HasColumnType("jsonb"); builder.Property(x => x.CorrelationId).HasColumnName("correlation_id").HasMaxLength(128); @@ -34,5 +42,7 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.TenantId); builder.HasIndex(x => new { x.EntityType, x.EntityId }); builder.HasIndex(x => x.ActorId); + // «Ensename todo lo que hizo un agente» es la consulta que este gap existe para permitir. + builder.HasIndex(x => x.ActorType); } } diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/Entities/AuditEntryRecord.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/Entities/AuditEntryRecord.cs index 709640e9..0f2df80a 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/Entities/AuditEntryRecord.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/Entities/AuditEntryRecord.cs @@ -12,6 +12,13 @@ public sealed class AuditEntryRecord : IAuditableRecord, ITenantScopedRecord public string EntityId { get; set; } = string.Empty; public string Action { get; set; } = string.Empty; public Guid ActorId { get; set; } + + /// GT-603 — human / agent / system, o `unknown` en filas anteriores a la migracion. + public string ActorType { get; set; } = string.Empty; + public string? AgentId { get; set; } + public string? ModelId { get; set; } + public string? SessionId { get; set; } + public DateTime TimestampUtc { get; set; } public string ChangesJson { get; set; } = "{}"; public string? CorrelationId { get; set; } diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/PostgreSqlAuditEntryRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/PostgreSqlAuditEntryRepository.cs index 48b5fbcb..fcfe74f0 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/PostgreSqlAuditEntryRepository.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Audit/PostgreSqlAuditEntryRepository.cs @@ -67,23 +67,21 @@ private static AuditEntry MapToDomain(AuditEntryRecord record) EntityId = record.EntityId, Action = record.Action, ActorId = record.ActorId, + ActorType = record.ActorType, + AgentId = record.AgentId, + ModelId = record.ModelId, + SessionId = record.SessionId, TimestampUtc = record.TimestampUtc, Changes = DeserializeChanges(record.ChangesJson), CorrelationId = record.CorrelationId }; - var result = AuditEntry.Create( - record.TenantId, - record.EntityType, - record.EntityId, - record.Action, - record.ActorId, - DeserializeChanges(record.ChangesJson), - record.CorrelationId); - - result.Value!.SetProps(props); - result.Value.TrackingState.MarkAsClean(); - return result.Value; + // GT-603 — se rehidrata, no se re-crea. `Create` rechaza `unknown` a proposito, y las filas + // anteriores a la migracion lo llevan: pasarlas por la fabrica de escritura habria hecho + // ilegible lo que precisamente hay que poder leer. + var entry = AuditEntry.Rehydrate(props); + entry.TrackingState.MarkAsClean(); + return entry; } private static AuditEntryRecord MapToRecord(AuditEntry aggregate) @@ -96,6 +94,10 @@ private static AuditEntryRecord MapToRecord(AuditEntry aggregate) EntityId = aggregate.EntityId, Action = aggregate.Action, ActorId = aggregate.ActorId, + ActorType = aggregate.ActorType, + AgentId = aggregate.AgentId, + ModelId = aggregate.ModelId, + SessionId = aggregate.SessionId, TimestampUtc = aggregate.TimestampUtc, ChangesJson = JsonSerializer.Serialize(aggregate.Changes, JsonOptions), CorrelationId = aggregate.CorrelationId, diff --git a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/Bootstrappers/TrackerIntegrationGatewaysBootstrapper.cs b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/Bootstrappers/TrackerIntegrationGatewaysBootstrapper.cs index 585339bf..64622b99 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/Bootstrappers/TrackerIntegrationGatewaysBootstrapper.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/Bootstrappers/TrackerIntegrationGatewaysBootstrapper.cs @@ -126,5 +126,11 @@ public void Run() new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.ApiKey); } }); + + // GT-603 — el ejecutor concreto que queda DETRAS de `IAgentExecutionPort`. Lo ata el host + // y no Aplicacion porque es quien conoce el runtime: la gobernanza —scopes y auditoria— + // no puede depender de que haya al otro lado, que es lo que T-010 exige. + Result.AddScoped(); } } diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Audit/AuditEntryEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Audit/AuditEntryEndpoints.cs index 7c440723..b095ef59 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Audit/AuditEntryEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Audit/AuditEntryEndpoints.cs @@ -46,6 +46,10 @@ public static void MapAuditEntryEndpoints(this IEndpointRouteBuilder app) request.EntityId, request.Action, user.ActorId, + // GT-603 — esta superficie autentica a una PERSONA. Un turno agentico entra por + // `/assistant/converse`, que lo tipa como agente y ademas lo acota por scope; que + // el tipo lo pusiera el cuerpo convertiria la atribucion en una declaracion voluntaria. + AuditActor.Human(), request.Changes, request.CorrelationId), ct); @@ -64,6 +68,10 @@ private static AuditEntryDto ToDto(AuditEntry entry) EntityId = entry.EntityId, Action = entry.Action, ActorId = entry.ActorId, + ActorType = entry.ActorType, + AgentId = entry.AgentId, + ModelId = entry.ModelId, + SessionId = entry.SessionId, TimestampUtc = entry.TimestampUtc, Changes = entry.Changes, CorrelationId = entry.CorrelationId diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/AssistantEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/AssistantEndpoints.cs index bf5f8b33..700d5e7d 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/AssistantEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/AssistantEndpoints.cs @@ -1,12 +1,27 @@ +using Tracker.Application.Integration.AgentExecution; + namespace Tracker.Presentation.Endpoints.Integration; /// /// Adapter-neutral assistant surface. The Tracker UI talks only to the Tracker; /// these endpoints proxy interactive conversational turns to the Agent Runtime's /// /v1/agent/converse surface. The name is engine-agnostic on purpose. +/// +/// GT-603 — el turno pasa por y no por el gateway +/// directamente. Esta era la única vía por la que un ejecutor no humano tocaba el corpus de un +/// cliente, y no escribía NADA: el producto afirmaba distinguir a un humano de un agente en el +/// expediente mientras el único camino agéntico real no dejaba rastro. El puerto comprueba el +/// scope, asienta el turno y sólo entonces deja actuar al runtime. /// public static class AssistantEndpoints { + /// + /// El scope de un turno conversacional. Es LECTURA: esta superficie no adjunta evidencia ni + /// pide evaluaciones, y darle un scope más ancho «por si acaso» sería ampliar lo que un agente + /// puede hacer sin que nadie lo haya decidido. + /// + private const string ConverseScope = AgentScope.ReadCorpus; + public static void MapAssistantEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/assistant") @@ -15,19 +30,61 @@ public static void MapAssistantEndpoints(this IEndpointRouteBuilder app) group.MapPost("/converse", async ( AssistantConverseRequest request, ITrackerUserContext user, - IAgentRuntimeGateway gateway, + IAgentExecutionPort agent, + IOptions runtimeOptions, CancellationToken ct) => { + var options = runtimeOptions.Value; + var turno = new AgentTurnRequest( + TenantId: user.TenantId, + ActorId: user.ActorId, + // La identidad del EJECUTOR. No es el usuario: el usuario es en cuyo nombre se + // actua, y guardar solo uno de los dos es como quedo el expediente hasta hoy. + AgentId: options.AgentId, + Scope: ConverseScope, + Prompt: request.Message ?? string.Empty, + GrantedScopes: GrantedScopes(user), + ModelId: options.ModelId, + // La conversacion ES la sesion: es lo que permite reconstruir un hilo entero en el + // expediente en vez de asientos sueltos que nadie puede volver a juntar. + SessionId: request.ConversationId ?? $"turn-{Guid.NewGuid():N}", + CorrelationId: request.Context?.CorrelationId, + Framing: new AgentTurnFraming( + ProductId: request.Context?.ProductId, + InitiativeId: request.Context?.InitiativeId, + Phase: request.Context?.Phase, + Gate: request.Context?.Gate, + TenantId: request.Context?.TenantId, + Parameters: request.Parameters, + DryRun: request.DryRun)); + + Result resultado; try { - return Results.Ok(await gateway.ConverseAsync(request, user, ct)); + resultado = await agent.ExecuteAsync(turno, ct); } catch (AgentRuntimeGatewayException ex) { + // CD-06 (tenant cruzado) llega hasta aqui como excepcion desde el gateway: es un + // rechazo del perimetro, anterior a que exista turno que auditar. return Results.Json( new { code = ex.Code, message = ex.Message }, statusCode: ex.StatusCode); } + + if (resultado.IsFailure) + { + var (code, status) = Translate(resultado.Error); + return Results.Json(new { code, message = resultado.Error }, statusCode: status); + } + + // La respuesta del runtime se devuelve INTACTA: el ledger se añade, no sustituye. El + // identificador del asiento viaja en cabecera para que quien reciba la respuesta pueda + // CITAR donde quedo constancia sin que cambie el contrato del cuerpo. + using var payload = JsonDocument.Parse(resultado.Value!.Payload); + return Results.Json( + payload.RootElement.Clone(), + statusCode: 200); }) .WithName("AssistantConverse") // Least-privilege read-oriented advisory permission, matching the sibling @@ -35,8 +92,33 @@ public static void MapAssistantEndpoints(this IEndpointRouteBuilder app) // non-binding. .RequireTrackerPermission(TrackerPermissions.ArchitectureRead) .Produces(200) + .Produces(400) .Produces(401) .Produces(403) - .Produces(502); + .Produces(502) + .Produces(503); } + + /// + /// Lo que este llamante tiene concedido. El endpoint ya exige ArchitectureRead, así que + /// la lista es corta a propósito: un permiso de lectura concede el scope de lectura y nada más. + /// Los otros scopes del catálogo no tienen superficie todavía, y concederlos aquí «para cuando + /// la tengan» es exactamente cómo se amplían permisos sin que nadie lo revise. + /// + private static IReadOnlyList GrantedScopes(ITrackerUserContext user) => + user.Permissions.Contains(TrackerPermissions.ArchitectureRead) + ? [AgentScope.ReadCorpus] + : []; + + private static (string Code, int Status) Translate(string error) => error switch + { + "AgentExecution.UnknownScope" => (error, 400), + "AgentExecution.ScopeNotGranted" => (error, 403), + "AgentExecution.UnattributableTurn" => (error, 400), + // El expediente no admitio el asiento, asi que el turno NO se ejecuto. 503 y no 500: no + // hay nada roto en la peticion, hay una dependencia —la que da la garantia— indisponible. + "AgentExecution.NotAuditable" => (error, 503), + _ when error.StartsWith("AgentTurnAuditor.", StringComparison.Ordinal) => (error, 503), + _ => ("AgentRuntime.Failed", 502), + }; } diff --git a/src/apps/tracker-api/Tracker.Presentation/Integration/AgentRuntimeOptions.cs b/src/apps/tracker-api/Tracker.Presentation/Integration/AgentRuntimeOptions.cs index f9712d65..20a68f2a 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Integration/AgentRuntimeOptions.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Integration/AgentRuntimeOptions.cs @@ -44,4 +44,19 @@ public sealed class AgentRuntimeOptions /// we present it as Bearer for parity with the Core gateway. /// public string? ApiKey { get; init; } + + /// + /// GT-603 — identidad con la que los turnos de esta superficie quedan atribuidos en el + /// expediente. El valor por defecto es honesto y neutro: nombra al RUNTIME, que es lo único + /// que este repositorio sabe con certeza. Un despliegue que sirva varios agentes debe + /// distinguirlos aquí (AgentRuntime__AgentId) — «agent» a secas responde «lo hizo un + /// agente» pero no «cuál», y esa es la segunda pregunta de toda auditoría. + /// + public string AgentId { get; init; } = "evolith-agent-runtime"; + + /// + /// Modelo que respalda al runtime, cuando el despliegue lo conoce. Nulo es una respuesta + /// válida: el Tracker es agnóstico del motor y prefiere no decir nada a inventarlo. + /// + public string? ModelId { get; init; } } diff --git a/src/apps/tracker-api/Tracker.Presentation/Integration/AgentRuntimeTurnExecutor.cs b/src/apps/tracker-api/Tracker.Presentation/Integration/AgentRuntimeTurnExecutor.cs new file mode 100644 index 00000000..81709e47 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Presentation/Integration/AgentRuntimeTurnExecutor.cs @@ -0,0 +1,57 @@ +using Tracker.Application.Integration.AgentExecution; +using Tracker.Presentation.Endpoints.Integration; + +namespace Tracker.Presentation.Integration; + +/// +/// GT-603 — el adaptador que pone al DETRÁS del puerto agéntico. +/// +/// El gateway seguía siendo el que habla con el runtime; lo que cambia es que ya no se le +/// llama directamente desde el endpoint, sino a través de IAgentExecutionPort, que +/// comprueba el scope y deja constancia ANTES de dejarle actuar. La pieza es deliberadamente +/// tonta: traduce un turno a la petición conversacional y devuelve la respuesta como texto. Toda +/// la gobernanza vive del otro lado del puerto, en Aplicación, que es donde se puede probar sin +/// levantar un host. +/// +internal sealed class AgentRuntimeTurnExecutor( + IAgentRuntimeGateway gateway, + ITrackerUserContext user) : IAgentRuntimeExecutor +{ + public async Task> ExecuteAsync(AgentTurnRequest request, CancellationToken ct = default) + { + var framing = request.Framing; + + var conversacion = new AssistantConverseRequest + { + Message = request.Prompt, + ConversationId = request.SessionId, + Context = new AssistantConverseContext + { + TenantId = framing?.TenantId, + ProductId = framing?.ProductId, + InitiativeId = framing?.InitiativeId, + Phase = framing?.Phase, + Gate = framing?.Gate, + CorrelationId = request.CorrelationId, + }, + Parameters = framing?.Parameters is null + ? null + : new Dictionary(framing.Parameters), + DryRun = framing?.DryRun, + }; + + try + { + var respuesta = await gateway.ConverseAsync(conversacion, user, ct); + return Result.Success(respuesta.GetRawText()); + } + catch (AgentRuntimeGatewayException ex) + { + // El error del runtime se devuelve como Result para que el turno FALLIDO quede + // asentado con su motivo. Una excepcion que sube hasta el endpoint se llevaria por + // delante ese asiento, y un fallo sin rastro es indistinguible de un turno que nunca + // se intento — que es justo la diferencia que a alguien le importara. + return Result.Failure($"{ex.Code}: {ex.Message}"); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Integration/AgentExecutionTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Integration/AgentExecutionTests.cs index 331ff26f..c1b594a2 100644 --- a/src/apps/tracker-api/Tracker.Tests/Application/Integration/AgentExecutionTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Application/Integration/AgentExecutionTests.cs @@ -1,4 +1,5 @@ using Tracker.Application.Integration.AgentExecution; +using Tracker.Domain.Audit.AuditEntry; namespace Tracker.Tests.Application.Integration; @@ -133,10 +134,91 @@ public async Task UnFalloDelRuntimeQuedaRegistradoConSuMotivo() Times.Once); } + // ── Atribución (GT-603) ──────────────────────────────────────────────────────────────── + + /// + /// Un turno sin identidad de agente NO se ejecuta, y ni siquiera se asienta su rechazo: el + /// expediente es append-only, así que una fila mal atribuida no se corrige nunca. Entre + /// escribir «alguien hizo algo» y no escribir nada, lo segundo miente menos. + /// + [Fact] + public async Task UnTurnoSinIdentidadDeAgente_NoEsAtribuible_YSeRechaza() + { + var (servicio, ejecutor, auditor) = Servicio(); + + var r = await servicio.ExecuteAsync(new AgentTurnRequest( + _tenant, _actor, AgentId: " ", AgentScope.ReadCorpus, "analiza el corpus", + [AgentScope.ReadCorpus])); + + r.Error.Should().Be("AgentExecution.UnattributableTurn"); + ejecutor.VerifyNoOtherCalls(); + auditor.VerifyNoOtherCalls(); + } + + /// + /// Y el asiento que el auditor escribe dice agente, con quién y en qué sesión. Es la + /// afirmación entera de la ficha: sin ella, el registro no distingue un turno agéntico de un + /// acto humano y la promesa del producto es falsa en el código. + /// + [Fact] + public async Task ElAsientoDeUnTurno_DiceQueLoHizoUnAGENTE() + { + var registro = new Mock(); + Tracker.Domain.Audit.AuditEntry.AuditEntry? escrito = null; + registro.Setup(r => r.AddAsync(It.IsAny(), + It.IsAny())) + .Callback((e, _) => escrito = e) + .Returns(Task.CompletedTask); + var uow = new Mock(); + uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(1); + + var auditor = new AgentTurnAuditor(registro.Object, uow.Object); + + var r = await auditor.RecordAsync( + new AgentTurnRequest(_tenant, _actor, "hermes-1", AgentScope.ReadCorpus, "analiza", + [AgentScope.ReadCorpus], ModelId: "modelo-x", SessionId: "conv-7"), + "started", null); + + r.IsSuccess.Should().BeTrue(r.Error); + escrito.Should().NotBeNull(); + escrito!.ActorType.Should().Be(AuditActorType.Agent, + "un turno agentico asentado como humano es peor que no asentarlo: parece evidencia"); + escrito.AgentId.Should().Be("hermes-1"); + escrito.ModelId.Should().Be("modelo-x"); + escrito.SessionId.Should().Be("conv-7"); + escrito.ActorId.Should().Be(_actor, "en nombre de quien se actua NO desaparece"); + escrito.EntityType.Should().Be("AgentTurn"); + uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once, + "sin confirmar, «el turno se pudo registrar» seria una afirmacion sobre memoria"); + } + + /// + /// Si el expediente no acepta la escritura, el auditor lo dice como resultado —no como + /// excepción—, que es lo que permite al servicio traducirlo en «este turno no ocurre». + /// + [Fact] + public async Task SiElExpedienteRechazaLaEscritura_ElAuditorLoDEVUELVE_NoLoLanza() + { + var registro = new Mock(); + var uow = new Mock(); + uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("ledger caido")); + + var auditor = new AgentTurnAuditor(registro.Object, uow.Object); + + var r = await auditor.RecordAsync(Turno(AgentScope.ReadCorpus, [AgentScope.ReadCorpus]), + "started", null); + + r.IsFailure.Should().BeTrue(); + r.Error.Should().Contain("ledger caido"); + } + // ── andamiaje ────────────────────────────────────────────────────────────────────────── private AgentTurnRequest Turno(string scope, string[] concedidos) => - new(_tenant, _actor, scope, "analiza el corpus", concedidos); + new(_tenant, _actor, AgenteDePrueba, scope, "analiza el corpus", concedidos); + + private const string AgenteDePrueba = "agente-de-prueba"; private static (IAgentExecutionPort, Mock, Mock) Servicio() { diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Integration/AgentExecutionWiringTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Integration/AgentExecutionWiringTests.cs new file mode 100644 index 00000000..77b26292 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Application/Integration/AgentExecutionWiringTests.cs @@ -0,0 +1,31 @@ +using Tracker.Application; +using Tracker.Application.Integration.AgentExecution; + +namespace Tracker.Tests.Application.Integration; + +/// +/// GT-603 — el ledger de turnos agénticos estaba escrito, probado y sin enchufar. +/// +/// AgentExecutionTests construye el servicio a mano, así que pasaba en verde mientras +/// el contenedor real no sabía nada de : ninguna petición podía +/// llegar a él. Una prueba que instancia el tipo demuestra que el tipo funciona; no demuestra que +/// esté en el camino de nadie. Esta guarda mira el CONTENEDOR, que es donde vivía el fallo. +/// +public sealed class AgentExecutionWiringTests +{ + private static IServiceCollection Contenedor() => new ServiceCollection().AddApplication(); + + [Fact] + public void ElPuertoDeEjecucionAgentica_EstaRegistrado() + { + Contenedor().Should().Contain(d => d.ServiceType == typeof(IAgentExecutionPort), + "sin registro, ningun endpoint puede pedirlo y el ledger no se escribe nunca"); + } + + [Fact] + public void ElAuditorDeTurnos_EstaRegistrado() + { + Contenedor().Should().Contain(d => d.ServiceType == typeof(IAgentTurnAuditor), + "es la dependencia sin la cual el puerto no se puede construir"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/AuditActorAttributionSchemaLiveTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/AuditActorAttributionSchemaLiveTests.cs new file mode 100644 index 00000000..397ead21 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/AuditActorAttributionSchemaLiveTests.cs @@ -0,0 +1,182 @@ +using Npgsql; + +namespace Tracker.Tests.Infrastructure.Persistence; + +/// +/// GT-603 — el discriminador de actor, verificado contra PostgreSQL de verdad. +/// +/// Esta ficha sólo se puede cerrar contra un motor real y por dos razones distintas. La +/// primera: audit_entries está protegida por los tres triggers de `COH-014`, así que la +/// pregunta «¿se puede añadir la columna sin tocarlos?» no la contesta ningún modelo en memoria — +/// la contesta el motor. La segunda: la garantía que importa —ninguna fila nueva puede entrar +/// sin declarar quién la escribió— es un NOT NULL sin DEFAULT, y un DEFAULT sobrante +/// convertiría esa garantía en un adorno sin que ninguna prueba de aplicación se enterase. +/// +/// Las filas anteriores no se pueden rellenar, y no por pereza: un UPDATE sobre +/// esta tabla lo rechaza el trigger. Se representan con unknown —no con human— +/// porque decir «no consta» es cierto y decir «lo hizo una persona» sería una afirmación inventada +/// sobre un registro que se presenta como evidencia. +/// +/// Opt-in con EVOLITH_CORE_LIVE=1, como el resto de pruebas vivas del repositorio. +/// +public sealed class AuditActorAttributionSchemaLiveTests +{ + private const string Tabla = "audit_entries"; + private const string Esquema = "tracker_governance"; + + private static bool LiveEnabled => + string.Equals(Environment.GetEnvironmentVariable("EVOLITH_CORE_LIVE"), "1", StringComparison.Ordinal); + + private static string ConnectionString => + Environment.GetEnvironmentVariable("EVOLITH_TRACKER_DB") + ?? "Host=localhost;Port=5432;Database=evolith_tracker;Username=evolith;Password=localdev"; + + private static async Task AbrirAsync() + { + var conexion = new NpgsqlConnection(ConnectionString); + await conexion.OpenAsync(); + return conexion; + } + + private static async Task<(bool Existe, string Nullable, string? Default)> ColumnaAsync( + NpgsqlConnection conexion, string columna) + { + await using var cmd = new NpgsqlCommand( + "SELECT is_nullable, column_default FROM information_schema.columns " + + "WHERE table_schema = @s AND table_name = @t AND column_name = @c", conexion); + cmd.Parameters.AddWithValue("s", Esquema); + cmd.Parameters.AddWithValue("t", Tabla); + cmd.Parameters.AddWithValue("c", columna); + + await using var lector = await cmd.ExecuteReaderAsync(); + if (!await lector.ReadAsync()) + { + return (false, string.Empty, null); + } + + return (true, lector.GetString(0), lector.IsDBNull(1) ? null : lector.GetString(1)); + } + + [Fact] + public async Task ElDiscriminadorDeActor_EsNOTNULL_YSINDefault() + { + if (!LiveEnabled) return; + + await using var conexion = await AbrirAsync(); + + var actorType = await ColumnaAsync(conexion, "actor_type"); + + actorType.Existe.Should().BeTrue( + "sin columna, toda fila escrita hasta que exista queda inatribuible PARA SIEMPRE: " + + "la tabla es append-only y no admite correccion posterior"); + actorType.Nullable.Should().Be("NO", "una fila sin tipo de actor no es evidencia atribuida"); + actorType.Default.Should().BeNull( + "el DEFAULT existe solo durante la migracion, para representar lo anterior como 'unknown'. " + + "Si sobrevive, un INSERT que se olvide del discriminador entra igualmente y la garantia " + + "de que TODA fila nueva declara su actor deja de ser una garantia"); + } + + [Fact] + public async Task LaIdentidadDelAgente_SeGuardaYEsOpcional() + { + if (!LiveEnabled) return; + + await using var conexion = await AbrirAsync(); + + foreach (var columna in new[] { "agent_id", "model_id", "session_id" }) + { + var meta = await ColumnaAsync(conexion, columna); + meta.Existe.Should().BeTrue($"'{columna}' es parte de la atribucion agentica de GT-603"); + meta.Nullable.Should().Be("YES", + $"'{columna}' no aplica a un acto humano; exigirlo obligaria a inventar un valor"); + } + } + + /// + /// La prueba que da nombre a la ficha: una fila nueva SIN discriminador no entra. Es la + /// diferencia entre una columna y una garantía. + /// + [Fact] + public async Task UnaFilaNueva_SinDiscriminador_NoEntra() + { + if (!LiveEnabled) return; + + await using var conexion = await AbrirAsync(); + await using var tx = await conexion.BeginTransactionAsync(); + + await using var cmd = new NpgsqlCommand( + $"INSERT INTO {Esquema}.{Tabla} " + + "(id, tenant_id, entity_type, entity_id, action, actor_id, timestamp, changes, " + + " created_by, created_at, updated_by, updated_at, is_deleted) " + + "VALUES (gen_random_uuid(), gen_random_uuid(), 'GT603Probe', 'sin-actor-type', 'probe', " + + " gen_random_uuid(), now(), '{}', 'gt-603', now(), 'gt-603', now(), false)", conexion, tx); + + var acto = async () => await cmd.ExecuteNonQueryAsync(); + + (await acto.Should().ThrowAsync()) + .Which.SqlState.Should().Be(PostgresErrorCodes.NotNullViolation); + + await tx.RollbackAsync(); + } + + /// + /// Y la migración NO debilitó lo que ya estaba. Añadir una columna a una tabla append-only es + /// justo el momento en el que resulta tentador «quitar el trigger un segundo». + /// + [Fact] + public async Task LosTriggersAppendOnly_SIGUEN_EnPie() + { + if (!LiveEnabled) return; + + await using var conexion = await AbrirAsync(); + await using var cmd = new NpgsqlCommand( + "SELECT tgname FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid " + + "JOIN pg_namespace n ON n.oid = c.relnamespace " + + "WHERE n.nspname = @s AND c.relname = @t AND NOT t.tgisinternal ORDER BY tgname", conexion); + cmd.Parameters.AddWithValue("s", Esquema); + cmd.Parameters.AddWithValue("t", Tabla); + + var triggers = new List(); + await using (var lector = await cmd.ExecuteReaderAsync()) + { + while (await lector.ReadAsync()) + { + triggers.Add(lector.GetString(0)); + } + } + + triggers.Should().BeEquivalentTo( + ["audit_entries_no_delete", "audit_entries_no_truncate", "audit_entries_no_update"], + "el expediente sigue siendo inmutable: la columna se anade AL LADO de la garantia, no a costa de ella"); + } + + /// + /// Lo anterior a la migración se representa como unknown. Ninguna fila queda en NULL —el + /// NOT NULL lo impide— y ninguna se presenta como humana sin serlo. + /// + [Fact] + public async Task LoAnteriorALaMigracion_SeRepresentaComoUnknown_NoComoHumano() + { + if (!LiveEnabled) return; + + await using var conexion = await AbrirAsync(); + await using var cmd = new NpgsqlCommand( + $"SELECT actor_type FROM {Esquema}.{Tabla} WHERE entity_type = 'LegacyProbe'", conexion); + + var tipos = new List(); + await using (var lector = await cmd.ExecuteReaderAsync()) + { + while (await lector.ReadAsync()) + { + tipos.Add(lector.GetString(0)); + } + } + + // Sin filas previas (base recien creada) la afirmacion se cumple por vacio; con ellas, la + // unica lectura honesta de una fila escrita antes de que el discriminador existiera es + // «no consta». + tipos.Should().OnlyContain(t => t == "unknown", + "no se puede rellenar hacia atras —el trigger rechaza el UPDATE— y afirmar 'human' " + + "sobre una fila que nunca lo declaro seria inventar evidencia"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/AssistantTurnLedgerTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/AssistantTurnLedgerTests.cs new file mode 100644 index 00000000..97e62f3d --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Integration/AssistantTurnLedgerTests.cs @@ -0,0 +1,167 @@ +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Tracker.Application.Integration.AgentExecution; +using Tracker.Domain.Audit.AuditEntry; +using Tracker.Presentation.Endpoints.Integration; +using Tracker.Infrastructure.Persistence.InMemory; +using Tracker.Infrastructure.Services.Tenancy; + +namespace Tracker.Tests.Presentation.Integration; + +/// +/// GT-603 — un turno del asistente deja asiento, y el asiento dice que lo hizo un agente. +/// +/// La superficie /assistant/converse proxyaba al runtime y NO persistía nada: el +/// producto afirmaba distinguir a un humano de un agente en el expediente mientras el único camino +/// por el que un agente actuaba no escribía en él. Esta prueba recorre el camino HTTP completo +/// —endpoint → puerto → auditor → registro— porque el fallo estaba justo en el cableado, que es lo +/// único que una prueba de unidad sobre el servicio no puede ver. +/// +[Collection(WebHostCollection.Name)] +public sealed class AssistantTurnLedgerTests : IClassFixture +{ + private readonly AssistantLedgerWebApplicationFactory _factory; + + public AssistantTurnLedgerTests(AssistantLedgerWebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task UnTurnoDelAsistente_DejaAsientoAtribuidoAUnAGENTE() + { + using var client = _factory.CreateClient(); + var conversacion = $"conv-{Guid.NewGuid():N}"; + + var respuesta = await client.PostAsJsonAsync("/api/v1/assistant/converse", new + { + message = "analiza el corpus", + conversationId = conversacion, + }); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK); + + var asientos = await _factory.Ledger.GetByEntityAsync( + "AgentTurn", conversacion); + + asientos.Should().NotBeEmpty( + "un turno agentico que no deja rastro es exactamente lo que este gap denuncia"); + + var asiento = asientos[0]; + asiento.ActorType.Should().Be(AuditActorType.Agent, + "la promesa del producto —distinguir a un humano de un agente en el expediente— se " + + "cumple aqui o no se cumple en ningun sitio"); + asiento.AgentId.Should().NotBeNullOrWhiteSpace("«un agente» sin decir CUAL no es atribucion"); + asiento.SessionId.Should().Be(conversacion); + asiento.Action.Should().Be($"agent:{AgentScope.ReadCorpus}"); + asiento.Changes.Should().ContainKey("grantedScopes"); + asiento.Changes.Should().NotContainKey("prompt", + "el expediente es append-only: lo que entre no se puede retirar, y un prompt arrastra " + + "datos del cliente"); + } + + /// + /// La otra mitad de la afirmación: el mismo expediente, escrito por la superficie humana, dice + /// human. Un discriminador que sólo sabe decir «agente» no distingue nada. + /// + [Fact] + public async Task ElMismoExpediente_DistingueUnActoHUMANO() + { + using var client = _factory.CreateClient(); + var entidad = $"probe-{Guid.NewGuid():N}"; + + var respuesta = await client.PostAsJsonAsync("/api/v1/audit-entries", new + { + entityType = "GT603Probe", + entityId = entidad, + action = "probe:performed", + }); + + respuesta.IsSuccessStatusCode.Should().BeTrue(); + + var asientos = await _factory.Ledger.GetByEntityAsync("GT603Probe", entidad); + + asientos.Should().ContainSingle(); + asientos[0].ActorType.Should().Be(AuditActorType.Human); + asientos[0].AgentId.Should().BeNull("no hubo agente: inventar uno seria peor que no tenerlo"); + } + + /// La respuesta del runtime se sigue devolviendo tal cual: el ledger se añade, no sustituye. + [Fact] + public async Task LaRespuestaDelRuntime_SeSigueDevolviendoIntacta() + { + using var client = _factory.CreateClient(); + + var respuesta = await client.PostAsJsonAsync("/api/v1/assistant/converse", new + { + message = "hola", + }); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK); + var cuerpo = await respuesta.Content.ReadFromJsonAsync(); + cuerpo.GetProperty("reply").GetString().Should().Be("respuesta de prueba"); + } +} + +/// +/// Host real con dos sustituciones y ninguna más: el registro de auditoría en memoria (para poder +/// LEER lo que se escribió) y un runtime de agente determinista (para no depender de que haya uno). +/// Todo lo demás —endpoint, permisos, puerto, auditor— es el cableado de producción, que es +/// precisamente lo que se está poniendo a prueba. +/// +public sealed class AssistantLedgerWebApplicationFactory : WebApplicationFactory +{ + public InMemoryAuditEntryRepository Ledger { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Development por el `dev-bypass`: la prueba afirma sobre el ledger, no sobre el perímetro. + builder.UseEnvironment("Development"); + + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = + "Host=localhost;Port=5432;Database=evolith_tracker;Username=evolith;Password=localdev;Timeout=5", + }); + }); + + builder.ConfigureTestServices(services => + { + HostIsolation.Apply(services); + + var seeder = services.FirstOrDefault(d => + d.ServiceType == typeof(IHostedService) + && d.ImplementationType == typeof(DevTenantSeedHostedService)); + if (seeder is not null) + { + services.Remove(seeder); + } + + services.RemoveAll(); + services.AddSingleton(Ledger); + services.RemoveAll(); + services.AddScoped(); + + services.RemoveAll(); + services.AddSingleton(); + }); + } + + /// El registro sustituido no participa del DbContext, así que no hay nada que confirmar. + private sealed class LedgerlessUnitOfWork : IUnitOfWork + { + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + => Task.FromResult(1); + } + + private sealed class StubAgentRuntimeGateway : IAgentRuntimeGateway + { + public Task ConverseAsync( + AssistantConverseRequest request, ITrackerUserContext user, CancellationToken cancellationToken) + { + using var doc = JsonDocument.Parse("{\"reply\":\"respuesta de prueba\",\"engine\":\"stub\"}"); + return Task.FromResult(doc.RootElement.Clone()); + } + } +}