From fde2ab9c1fd6267c1307aa0738d0515b28bb5e33 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Thu, 30 Jul 2026 21:25:52 -0500 Subject: [PATCH] =?UTF-8?q?feat(audit):=20tipar=20el=20actor=20del=20exped?= =?UTF-8?q?iente=20y=20enchufar=20el=20ledger=20ag=C3=A9ntico=20(GT-603)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El ledger de turnos agénticos estaba escrito, probado y SIN REGISTRAR: `IAgentExecutionPort` no aparecía en ningún contenedor ni en ningún endpoint, y `/assistant/converse` —la única vía por la que un agente tocaba el corpus de un cliente— proxyaba al runtime sin persistir nada. La afirmación diferencial del producto (distinguir un acto humano de uno agéntico en el expediente) era falsa en el código. Y era la ficha que CADUCA: `audit_entries` es append-only por trigger desde COH-014, así que toda fila escrita antes de que existiera el discriminador queda inatribuible para siempre — el UPDATE que la tiparía está prohibido por la base. - `AuditActor`/`AuditActorType`: human | agent | system, más `unknown` reservado a lo anterior a la migración y EXCLUIDO de toda ruta de escritura. - `AuditEntry.Create` exige la atribución como parámetro obligatorio: los 8 sitios que escriben en el expediente declaran ahora quién escribe. Se añade `Rehydrate` para poder LEER `unknown` sin poder escribirlo. - Migración `AddAuditActorAttribution`: `actor_type` NOT NULL + `agent_id`, `model_id`, `session_id`. El DEFAULT `'unknown'` vive sólo dentro del `ADD COLUMN` (fast default: sin reescritura, sin disparar el trigger) y se RETIRA acto seguido, para que ninguna fila nueva entre sin declarar su actor. Los tres triggers append-only quedan intactos. - `AssistantEndpoints` pasa por el puerto: scope comprobado, turno asentado y sólo entonces se deja actuar al runtime. La respuesta se devuelve intacta. - `AgentTurnAuditor` CONFIRMA la escritura (`SaveEntitiesAsync`): sin ella, «el turno se pudo registrar» era una afirmación sobre memoria que nadie persistía. - Un turno sin identidad de agente se rechaza: no es atribuible, y en un registro que no admite corrección es preferible no escribirlo a escribirlo mal. - `audit-trail.robot.mjs` verifica extremo a extremo, contra la API viva, que un turno agéntico aterriza tipado como `agent` con su agente identificado y que un acto humano en el MISMO expediente sigue diciendo `human`. Verificado: 1034 pruebas + 14 de arquitectura en verde; las pruebas vivas de esquema contra PostgreSQL real (EVOLITH_CORE_LIVE=1) y RoboSoft `audit-trail` 25/25 contra tracker-api en marcha. Co-Authored-By: Claude Opus 5 --- robosoft/robots/audit-trail.robot.mjs | 56 + .../CreateAuditEntryCommand.cs | 8 + .../CreateAuditEntryCommandHandler.cs | 1 + .../Audit/AuditEntry/DTOs/AuditEntryDto.cs | 7 + .../GetAuditEntryQueryHandler.cs | 4 + .../DependencyInjection.cs | 11 + .../EvaluateGateCommandHandler.cs | 3 + .../RecordApprovalCommandHandler.cs | 3 + .../Services/GateAuditWriter.cs | 2 + .../Intake/Services/IntakeAuditWriter.cs | 3 + .../AgentExecution/AgentExecutionService.cs | 9 + .../AgentExecution/AgentTurnAuditor.cs | 27 +- .../AgentExecution/IAgentExecutionPort.cs | 25 +- .../Publication/GateDecisionPublisher.cs | 2 + .../Audit/AuditEntry/AuditActor.cs | 93 + .../Audit/AuditEntry/AuditEntry.cs | 39 + .../Audit/AuditEntry/AuditEntryProps.cs | 17 + .../AuditTrailPersistenceBackgroundService.cs | 4 + ...21901_AddAuditActorAttribution.Designer.cs | 3755 +++++++++++++++++ ...20260731021901_AddAuditActorAttribution.cs | 127 + .../TrackerDbContextModelSnapshot.cs | 23 + .../AuditEntryRecordConfiguration.cs | 10 + .../Audit/Entities/AuditEntryRecord.cs | 7 + .../Audit/PostgreSqlAuditEntryRepository.cs | 26 +- .../TrackerIntegrationGatewaysBootstrapper.cs | 6 + .../Endpoints/Audit/AuditEntryEndpoints.cs | 8 + .../Integration/AssistantEndpoints.cs | 88 +- .../Integration/AgentRuntimeOptions.cs | 15 + .../Integration/AgentRuntimeTurnExecutor.cs | 57 + .../Integration/AgentExecutionTests.cs | 84 +- .../Integration/AgentExecutionWiringTests.cs | 31 + .../AuditActorAttributionSchemaLiveTests.cs | 182 + .../Integration/AssistantTurnLedgerTests.cs | 167 + 33 files changed, 4880 insertions(+), 20 deletions(-) create mode 100644 src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditActor.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731021901_AddAuditActorAttribution.Designer.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260731021901_AddAuditActorAttribution.cs create mode 100644 src/apps/tracker-api/Tracker.Presentation/Integration/AgentRuntimeTurnExecutor.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Application/Integration/AgentExecutionWiringTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/AuditActorAttributionSchemaLiveTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Presentation/Integration/AssistantTurnLedgerTests.cs 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()); + } + } +}