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