diff --git a/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/DTOs/RepositoryRevisionTimelineDto.cs b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/DTOs/RepositoryRevisionTimelineDto.cs
new file mode 100644
index 00000000..e4aecb52
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/DTOs/RepositoryRevisionTimelineDto.cs
@@ -0,0 +1,53 @@
+namespace Tracker.Application.Integration.CoreEvaluationTransaction.DTOs;
+
+///
+/// GT-615 — la linea de revisiones de un repositorio con su proyeccion de veredicto y las senales
+/// de drift derivadas. Es un DTO de LECTURA: nada de esto se persiste aparte, se calcula sobre el
+/// ledger core_evaluation_transactions.
+///
+public sealed class RepositoryRevisionTimelineDto
+{
+ /// Repositorio consultado, o null si la linea abarca todo el tenant.
+ public string? RepositoryUrl { get; init; }
+
+ /// Limite inferior aplicado sobre requestedAt, o null si no se acoto.
+ public DateTime? SinceUtc { get; init; }
+
+ /// Revisiones en orden de revision (la mas antigua primero).
+ public IReadOnlyList Revisions { get; init; } = [];
+
+ /// Senales DriftDetected derivadas de los cambios de veredicto.
+ public IReadOnlyList DriftSignals { get; init; } = [];
+}
+
+/// Una revision evaluada dentro de la linea.
+public sealed class RepositoryRevisionDto
+{
+ public int Sequence { get; init; }
+ public Guid TransactionId { get; init; }
+ public string OperationId { get; init; } = string.Empty;
+ public string RepositoryUrl { get; init; } = string.Empty;
+ public string RepositoryRevision { get; init; } = string.Empty;
+ public string Status { get; init; } = string.Empty;
+ public string Verdict { get; init; } = string.Empty;
+
+ /// false cuando el Core no llego a pronunciarse (pendiente o error de transporte).
+ public bool Decided { get; init; }
+
+ public DateTime ObservedAtUtc { get; init; }
+}
+
+/// Cambio de veredicto entre dos revisiones consecutivas del mismo repositorio.
+public sealed class DriftSignalDto
+{
+ public string Signal { get; init; } = string.Empty;
+ public string RepositoryUrl { get; init; } = string.Empty;
+ public string FromRevision { get; init; } = string.Empty;
+ public string ToRevision { get; init; } = string.Empty;
+ public string FromVerdict { get; init; } = string.Empty;
+ public string ToVerdict { get; init; } = string.Empty;
+ public Guid FromTransactionId { get; init; }
+ public Guid ToTransactionId { get; init; }
+ public bool SameRevision { get; init; }
+ public DateTime DetectedAtUtc { get; init; }
+}
diff --git a/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQuery.cs b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQuery.cs
new file mode 100644
index 00000000..6a9f2b8b
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQuery.cs
@@ -0,0 +1,14 @@
+using Tracker.Application.Integration.CoreEvaluationTransaction.DTOs;
+
+namespace Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetRepositoryRevisionTimeline;
+
+///
+/// GT-615 — pregunta por la linea de revisiones de un repositorio. TenantId lo resuelve el
+/// llamante desde la identidad, nunca el cuerpo: el aislamiento entre tenants es del perimetro
+/// (T-044) y esta consulta no es la excepcion.
+///
+public sealed record GetRepositoryRevisionTimelineQuery(
+ Guid TenantId,
+ string? RepositoryUrl,
+ DateTime? SinceUtc
+) : IQuery;
diff --git a/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQueryHandler.cs b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQueryHandler.cs
new file mode 100644
index 00000000..fd442c37
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Application/Integration/CoreEvaluationTransaction/Queries/GetRepositoryRevisionTimeline/GetRepositoryRevisionTimelineQueryHandler.cs
@@ -0,0 +1,57 @@
+using Tracker.Application.Integration.CoreEvaluationTransaction.DTOs;
+using Tracker.Domain.Integration.CoreEvaluationTransaction;
+
+namespace Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetRepositoryRevisionTimeline;
+
+internal sealed class GetRepositoryRevisionTimelineQueryHandler
+ : IQueryHandler
+{
+ private readonly ICoreEvaluationTransactionRepository _repository;
+
+ public GetRepositoryRevisionTimelineQueryHandler(ICoreEvaluationTransactionRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task Handle(
+ GetRepositoryRevisionTimelineQuery request,
+ CancellationToken cancellationToken)
+ {
+ var transactions = await _repository.GetRevisionTimelineAsync(
+ request.TenantId, request.RepositoryUrl, request.SinceUtc, cancellationToken);
+
+ var entries = RepositoryRevisionTimeline.Project(transactions);
+ var signals = RepositoryRevisionTimeline.DetectDrift(entries);
+
+ return new RepositoryRevisionTimelineDto
+ {
+ RepositoryUrl = string.IsNullOrWhiteSpace(request.RepositoryUrl) ? null : request.RepositoryUrl.Trim(),
+ SinceUtc = request.SinceUtc,
+ Revisions = entries.Select(entry => new RepositoryRevisionDto
+ {
+ Sequence = entry.Sequence,
+ TransactionId = entry.TransactionId,
+ OperationId = entry.OperationId,
+ RepositoryUrl = entry.RepositoryUrl,
+ RepositoryRevision = entry.RepositoryRevision,
+ Status = entry.Status,
+ Verdict = entry.Verdict,
+ Decided = entry.Decided,
+ ObservedAtUtc = entry.ObservedAtUtc
+ }).ToList(),
+ DriftSignals = signals.Select(signal => new DriftSignalDto
+ {
+ Signal = signal.Signal,
+ RepositoryUrl = signal.RepositoryUrl,
+ FromRevision = signal.FromRevision,
+ ToRevision = signal.ToRevision,
+ FromVerdict = signal.FromVerdict,
+ ToVerdict = signal.ToVerdict,
+ FromTransactionId = signal.FromTransactionId,
+ ToTransactionId = signal.ToTransactionId,
+ SameRevision = signal.SameRevision,
+ DetectedAtUtc = signal.DetectedAtUtc
+ }).ToList()
+ };
+ }
+}
diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs
index d50864f8..769d6209 100644
--- a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs
+++ b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/Repositories.cs
@@ -8,4 +8,21 @@ public interface ICoreEvaluationTransactionRepository
Task DeleteAsync(CoreEvaluationTransaction aggregate, CancellationToken cancellationToken = default);
Task> GetByTenantAsync(Guid tenantId, CancellationToken cancellationToken = default);
Task GetByOperationIdAsync(string operationId, CancellationToken cancellationToken = default);
+
+ ///
+ /// GT-615 — la linea de revisiones de un repositorio, en ORDEN DE REVISION (la mas antigua
+ /// primero). Es el orden que necesita : el drift solo
+ /// tiene sentido leyendo la serie hacia adelante. Es deliberadamente el inverso del listado
+ /// del ledger (, mas reciente primero), que responde a otra
+ /// pregunta —«que ha pasado ultimamente»— y no se toca.
+ ///
+ /// Ambos filtros son opcionales y se combinan: acota a un
+ /// repositorio (comparacion exacta; el ledger guarda la URL tal cual la mando el gateway) y
+ /// a las peticiones hechas en o despues de ese instante.
+ ///
+ Task> GetRevisionTimelineAsync(
+ Guid tenantId,
+ string? repositoryUrl,
+ DateTime? sinceUtc,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/RepositoryRevisionTimeline.cs b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/RepositoryRevisionTimeline.cs
new file mode 100644
index 00000000..75ab0a74
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Domain/Integration/CoreEvaluationTransaction/RepositoryRevisionTimeline.cs
@@ -0,0 +1,190 @@
+namespace Tracker.Domain.Integration.CoreEvaluationTransaction;
+
+///
+/// GT-615 — la proyeccion de veredicto sobre la linea de revisiones de un repositorio.
+///
+/// El ledger core_evaluation_transactions ya guardaba repository_revision junto al
+/// veredicto que el Core emitio para esa revision, pero nadie lo leia como serie: el unico acceso
+/// era «todas las del tenant» o «una por id». Es decir, el sustrato del drift existia y no emitia
+/// nada. Esta proyeccion es la lectura que faltaba: ordena las evaluaciones de UN repositorio,
+/// les asigna el veredicto que el Core decidio, y emite cuando ese
+/// veredicto CAMBIA entre revisiones consecutivas.
+///
+public static class RepositoryRevisionTimeline
+{
+ /// Nombre del unico tipo de senal que esta proyeccion emite hoy.
+ public const string DriftDetected = "DriftDetected";
+
+ /// Clave bajo la que Complete() deja el veredicto del Core en ResponseData.
+ private const string DecisionKey = "decision";
+
+ private const string StatusCompleted = "completed";
+ private const string StatusFailed = "failed";
+
+ /// Veredicto de una evaluacion cuyo estado no permite afirmar nada del Core.
+ public const string VerdictPending = "PENDING";
+
+ /// La evaluacion no llego a producirse (error de transporte/contrato): no es un veredicto.
+ public const string VerdictError = "ERROR";
+
+ /// Completada, pero sin decision registrada — un asiento antiguo o truncado.
+ public const string VerdictUnknown = "UNKNOWN";
+
+ ///
+ /// Proyecta las transacciones a entradas de linea temporal. La entrada debe venir YA ordenada
+ /// por la consulta (orden de revision = orden cronologico de peticion); aqui no se reordena
+ /// para no tapar un fallo del repositorio con un sort defensivo.
+ ///
+ public static IReadOnlyList Project(
+ IEnumerable transactions)
+ {
+ ArgumentNullException.ThrowIfNull(transactions);
+
+ var entries = new List();
+ var sequence = 0;
+
+ foreach (var transaction in transactions)
+ {
+ var (verdict, decided) = ProjectVerdict(transaction);
+ entries.Add(new RepositoryRevisionEntry
+ {
+ Sequence = sequence++,
+ TransactionId = transaction.Id,
+ OperationId = transaction.OperationId,
+ RepositoryUrl = transaction.RepositoryUrl,
+ RepositoryRevision = transaction.RepositoryRevision,
+ Status = transaction.Status,
+ Verdict = verdict,
+ Decided = decided,
+ ObservedAtUtc = transaction.CompletedAtUtc ?? transaction.RequestedAtUtc
+ });
+ }
+
+ return entries;
+ }
+
+ ///
+ /// Deriva el veredicto de una transaccion y si el Core llego a DECIDIR sobre ella.
+ /// decided=false para las pendientes y las fallidas: una caida del Core o un timeout no
+ /// son un veredicto sobre la arquitectura, y contarlas como tal convertiria cada incidencia de
+ /// transporte en un falso positivo de drift.
+ ///
+ public static (string Verdict, bool Decided) ProjectVerdict(CoreEvaluationTransaction transaction)
+ {
+ ArgumentNullException.ThrowIfNull(transaction);
+
+ if (string.Equals(transaction.Status, StatusFailed, StringComparison.OrdinalIgnoreCase))
+ {
+ return (VerdictError, false);
+ }
+
+ if (!string.Equals(transaction.Status, StatusCompleted, StringComparison.OrdinalIgnoreCase))
+ {
+ return (VerdictPending, false);
+ }
+
+ if (transaction.ResponseData.TryGetValue(DecisionKey, out var decision))
+ {
+ var text = decision?.ToString();
+ if (!string.IsNullOrWhiteSpace(text))
+ {
+ return (text.Trim().ToUpperInvariant(), true);
+ }
+ }
+
+ return (VerdictUnknown, false);
+ }
+
+ ///
+ /// Recorre la linea por repositorio y emite una senal por cada cambio de veredicto entre
+ /// revisiones consecutivas DECIDIDAS. Las entradas no decididas (pendiente/error) se saltan
+ /// sin romper la cadena: entre un PASSED y el siguiente FAILED puede haber habido diez
+ /// reintentos fallidos y el drift sigue siendo uno solo, entre esas dos revisiones.
+ ///
+ public static IReadOnlyList DetectDrift(IEnumerable entries)
+ {
+ ArgumentNullException.ThrowIfNull(entries);
+
+ var signals = new List();
+ var previousByRepository = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var entry in entries)
+ {
+ if (!entry.Decided)
+ {
+ continue;
+ }
+
+ if (previousByRepository.TryGetValue(entry.RepositoryUrl, out var previous)
+ && !string.Equals(previous.Verdict, entry.Verdict, StringComparison.OrdinalIgnoreCase))
+ {
+ signals.Add(new DriftSignal
+ {
+ Signal = DriftDetected,
+ RepositoryUrl = entry.RepositoryUrl,
+ FromRevision = previous.RepositoryRevision,
+ ToRevision = entry.RepositoryRevision,
+ FromVerdict = previous.Verdict,
+ ToVerdict = entry.Verdict,
+ FromTransactionId = previous.TransactionId,
+ ToTransactionId = entry.TransactionId,
+ // Una re-evaluacion de la MISMA revision que cambia de veredicto tambien es
+ // drift, pero de la regla, no del codigo. Distinguirlo aqui evita que quien
+ // lea la senal salga a buscar un commit que no existe.
+ SameRevision = string.Equals(
+ previous.RepositoryRevision, entry.RepositoryRevision, StringComparison.Ordinal),
+ DetectedAtUtc = entry.ObservedAtUtc
+ });
+ }
+
+ previousByRepository[entry.RepositoryUrl] = entry;
+ }
+
+ return signals;
+ }
+}
+
+/// Una revision evaluada, con el veredicto que el Core emitio sobre ella.
+public sealed record RepositoryRevisionEntry
+{
+ /// Posicion en la linea (0 = la mas antigua del rango consultado).
+ public int Sequence { get; init; }
+
+ public Guid TransactionId { get; init; }
+ public string OperationId { get; init; } = string.Empty;
+ public string RepositoryUrl { get; init; } = string.Empty;
+ public string RepositoryRevision { get; init; } = string.Empty;
+
+ /// Estado del asiento (pending/completed/failed).
+ public string Status { get; init; } = string.Empty;
+
+ /// Veredicto proyectado (PASSED/FAILED/SKIPPED/BLOCKED/…, o PENDING/ERROR/UNKNOWN).
+ public string Verdict { get; init; } = string.Empty;
+
+ /// true solo si el Core llego a pronunciarse; ver .
+ public bool Decided { get; init; }
+
+ public DateTime ObservedAtUtc { get; init; }
+}
+
+///
+/// Senal derivada: el veredicto cambio entre dos revisiones consecutivas del mismo repositorio.
+/// Se CALCULA sobre el ledger, no se almacena: el asiento de evaluacion sigue siendo el unico
+/// sistema de registro, y una tabla paralela solo podria contradecirlo.
+///
+public sealed record DriftSignal
+{
+ public string Signal { get; init; } = RepositoryRevisionTimeline.DriftDetected;
+ public string RepositoryUrl { get; init; } = string.Empty;
+ public string FromRevision { get; init; } = string.Empty;
+ public string ToRevision { get; init; } = string.Empty;
+ public string FromVerdict { get; init; } = string.Empty;
+ public string ToVerdict { get; init; } = string.Empty;
+ public Guid FromTransactionId { get; init; }
+ public Guid ToTransactionId { get; init; }
+
+ /// El veredicto cambio sin cambiar de revision: drift de regla, no de codigo.
+ public bool SameRevision { get; init; }
+
+ public DateTime DetectedAtUtc { get; init; }
+}
diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs
index c23615c1..4849dfe4 100644
--- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs
+++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/InMemory/InMemoryRepositories.cs
@@ -189,4 +189,27 @@ public Task> GetByTenantAsync(Guid tena
public Task GetByOperationIdAsync(string operationId, CancellationToken cancellationToken = default)
=> Task.FromResult(_items.Values.FirstOrDefault(x => x.OperationId == operationId));
+
+ ///
+ /// GT-615 — misma semantica que la implementacion Postgres: filtros opcionales combinables y
+ /// orden de revision (mas antigua primero, desempatando por id para que la serie sea total).
+ ///
+ public Task> GetRevisionTimelineAsync(
+ Guid tenantId,
+ string? repositoryUrl,
+ DateTime? sinceUtc,
+ CancellationToken cancellationToken = default)
+ {
+ var url = repositoryUrl?.Trim();
+
+ var items = _items.Values
+ .Where(x => x.TenantId == tenantId)
+ .Where(x => string.IsNullOrWhiteSpace(url) || x.RepositoryUrl == url)
+ .Where(x => sinceUtc is not { } since || x.RequestedAtUtc >= since)
+ .OrderBy(x => x.RequestedAtUtc)
+ .ThenBy(x => x.Id)
+ .ToArray();
+
+ return Task.FromResult>(items);
+ }
}
diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs
index 9e91793d..d07aed85 100644
--- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs
+++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Integration/PostgreSqlCoreEvaluationTransactionRepository.cs
@@ -40,6 +40,53 @@ public async Task> GetByTenantAsync(Gui
return records.Select(MapToDomain).ToList();
}
+ public async Task> GetRevisionTimelineAsync(
+ Guid tenantId,
+ string? repositoryUrl,
+ DateTime? sinceUtc,
+ CancellationToken cancellationToken = default)
+ {
+ var records = await ApplyRevisionTimelineQuery(
+ _context.CoreEvaluationTransactions, tenantId, repositoryUrl, sinceUtc)
+ .ToListAsync(cancellationToken);
+ return records.Select(MapToDomain).ToList();
+ }
+
+ ///
+ /// GT-615 — el filtro y el orden de la linea de revisiones, aislados como composicion sobre
+ /// . Se extrae del metodo para poder ejercitarlo sin Postgres (mismo
+ /// idioma que el mapper de ProviderConnection): el defecto que cierra este gap no es de
+ /// transporte sino de consulta, y una prueba que necesite base de datos se saltaria en CI y
+ /// dejaria el criterio de aceptacion sin demostrar.
+ ///
+ /// El desempate por Id no es cosmetico: dos evaluaciones del mismo repositorio pueden
+ /// compartir requested_at al milisegundo, y sin orden total la deteccion de drift
+ /// emitiria senales distintas en ejecuciones identicas.
+ ///
+ internal static IQueryable ApplyRevisionTimelineQuery(
+ IQueryable source,
+ Guid tenantId,
+ string? repositoryUrl,
+ DateTime? sinceUtc)
+ {
+ var query = source.Where(x => x.TenantId == tenantId && !x.IsDeleted);
+
+ if (!string.IsNullOrWhiteSpace(repositoryUrl))
+ {
+ var url = repositoryUrl.Trim();
+ query = query.Where(x => x.RepositoryUrl == url);
+ }
+
+ if (sinceUtc is { } since)
+ {
+ query = query.Where(x => x.RequestedAtUtc >= since);
+ }
+
+ return query
+ .OrderBy(x => x.RequestedAtUtc)
+ .ThenBy(x => x.Id);
+ }
+
public async Task AddAsync(CoreEvaluationTransaction aggregate, CancellationToken cancellationToken = default)
{
await _context.CoreEvaluationTransactions.AddAsync(MapToRecord(aggregate), cancellationToken);
diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs
index 968a3ad0..a269a5d7 100644
--- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs
+++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationTransactionEndpoints.cs
@@ -1,5 +1,6 @@
using Tracker.Application.Integration.CoreEvaluationTransaction.DTOs;
using Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetCoreEvaluationTransaction;
+using Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetRepositoryRevisionTimeline;
using Tracker.Domain.Integration.CoreEvaluationTransaction;
namespace Tracker.Presentation.Endpoints.Integration;
@@ -10,16 +11,56 @@ public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuil
{
var group = app.MapGroup("/core-evaluation-transactions").WithTags("Integration");
+ // GT-615 — el listado del ledger acepta ahora los dos filtros que no tenia:
+ // `repositoryUrl` y `since`. Sin ellos el unico acceso era «todo el tenant», asi que
+ // interrogar el historial de UN repositorio obligaba a descargarlo entero y filtrar en el
+ // cliente. Sin filtros la respuesta es identica a la de siempre (mas reciente primero).
group.MapGet("/", async (
+ string? repositoryUrl,
+ DateTime? since,
ITrackerUserContext user,
ICoreEvaluationTransactionRepository repository,
CancellationToken ct) =>
{
- var items = await repository.GetByTenantAsync(user.TenantId, ct);
- return Results.Ok(items.Select(ToDto));
+ if (string.IsNullOrWhiteSpace(repositoryUrl) && since is null)
+ {
+ var all = await repository.GetByTenantAsync(user.TenantId, ct);
+ return Results.Ok(all.Select(ToDto));
+ }
+
+ // La consulta filtrada devuelve orden de revision (ascendente); este listado es el
+ // ledger y su contrato es «lo mas reciente primero», asi que se invierte aqui en vez
+ // de duplicar la consulta con el orden contrario.
+ var items = await repository.GetRevisionTimelineAsync(
+ user.TenantId, repositoryUrl, NormalizeToUtc(since), ct);
+ return Results.Ok(items.Reverse().Select(ToDto));
})
.RequireTrackerPermission(TrackerPermissions.CoreTransactionRead)
- .WithName("ListCoreEvaluationTransactions");
+ .WithName("ListCoreEvaluationTransactions")
+ .Produces>(200);
+
+ // GT-615 — la lectura que faltaba: el ledger guardaba `repository_revision` junto al
+ // veredicto y nadie lo leia como serie. Aqui la revision es el eje: orden de revision
+ // (mas antigua primero), veredicto proyectado por revision y una senal `DriftDetected`
+ // por cada cambio de veredicto entre revisiones consecutivas.
+ //
+ // Exige `tracker:drift:read` —el permiso de drift ya estaba declarado en el vocabulario y
+ // no lo pedia ninguna ruta— y no `core:transaction:read`: quien vigila la deriva
+ // arquitectonica no tiene por que poder leer el ledger de integracion entero.
+ group.MapGet("/revisions", async (
+ string? repositoryUrl,
+ DateTime? since,
+ ITrackerUserContext user,
+ IMediator mediator,
+ CancellationToken ct) =>
+ {
+ var timeline = await mediator.Send(new GetRepositoryRevisionTimelineQuery(
+ user.TenantId, repositoryUrl, NormalizeToUtc(since)), ct);
+ return Results.Ok(timeline);
+ })
+ .RequireTrackerPermission(TrackerPermissions.DriftRead)
+ .WithName("GetRepositoryRevisionTimeline")
+ .Produces(200);
group.MapGet("/{id:guid}", async (
Guid id,
@@ -34,6 +75,19 @@ public static void MapCoreEvaluationTransactionEndpoints(this IEndpointRouteBuil
.WithName("GetCoreEvaluationTransaction");
}
+ ///
+ /// `requested_at` se guarda en UTC. Un `since` que llegue con offset (o sin `Z`, que el binder
+ /// resuelve como hora local del servidor) compararia contra otra escala y recortaria la serie
+ /// por un margen que cambia con el huso del proceso.
+ ///
+ private static DateTime? NormalizeToUtc(DateTime? value) => value switch
+ {
+ null => null,
+ { Kind: DateTimeKind.Utc } utc => utc,
+ { Kind: DateTimeKind.Local } local => local.ToUniversalTime(),
+ var unspecified => DateTime.SpecifyKind(unspecified.Value, DateTimeKind.Utc)
+ };
+
private static CoreEvaluationTransactionDto ToDto(CoreEvaluationTransaction transaction)
{
return new CoreEvaluationTransactionDto
diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Integration/GetRepositoryRevisionTimelineQueryHandlerTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Integration/GetRepositoryRevisionTimelineQueryHandlerTests.cs
new file mode 100644
index 00000000..e899a467
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Tests/Application/Integration/GetRepositoryRevisionTimelineQueryHandlerTests.cs
@@ -0,0 +1,134 @@
+using Tracker.Application.Integration.CoreEvaluationTransaction.Queries.GetRepositoryRevisionTimeline;
+using Tracker.Domain.Integration.CoreEvaluationTransaction;
+using Tracker.Infrastructure.Persistence.InMemory;
+
+namespace Tracker.Tests.Application.Integration;
+
+///
+/// GT-615 — el recorrido completo de la lectura que faltaba: consulta filtrada -> orden de
+/// revision -> proyeccion de veredicto -> senal de deriva. Se ata contra el repositorio en memoria
+/// (misma semantica de filtro y orden que la implementacion Postgres) para demostrar el criterio
+/// de aceptacion sin depender de una base que en CI no existe.
+///
+public class GetRepositoryRevisionTimelineQueryHandlerTests
+{
+ private const string RepoA = "https://github.com/org/repo-a";
+ private const string RepoB = "https://github.com/org/repo-b";
+
+ private static readonly DateTime Origen = new(2026, 7, 30, 8, 0, 0, DateTimeKind.Utc);
+
+ private static CoreEvaluationTransaction Evaluacion(
+ Guid tenantId, string repositoryUrl, string revision, string decision, int minuto)
+ {
+ var momento = Origen.AddMinutes(minuto);
+ var txn = CoreEvaluationTransaction.Create(
+ tenantId, $"op-{revision}", repositoryUrl, revision, Guid.NewGuid()).Value!;
+ txn.Complete(new Dictionary { ["decision"] = decision, ["status"] = "COMPLETED" });
+ txn.SetProps(txn.Props with { RequestedAtUtc = momento, CompletedAtUtc = momento });
+ return txn;
+ }
+
+ private static async Task Ledger(
+ params CoreEvaluationTransaction[] transacciones)
+ {
+ var repo = new InMemoryCoreEvaluationTransactionRepository();
+ foreach (var t in transacciones)
+ {
+ await repo.AddAsync(t, CancellationToken.None);
+ }
+
+ return repo;
+ }
+
+ [Fact]
+ public async Task DevuelveLaLineaDeUnRepositorioConSuDeriva()
+ {
+ var tenant = Guid.NewGuid();
+ var repo = await Ledger(
+ Evaluacion(tenant, RepoA, "a-1", "PASSED", 0),
+ Evaluacion(tenant, RepoB, "b-1", "FAILED", 5),
+ Evaluacion(tenant, RepoA, "a-2", "PASSED", 10),
+ Evaluacion(tenant, RepoA, "a-3", "FAILED", 20));
+
+ var handler = new GetRepositoryRevisionTimelineQueryHandler(repo);
+
+ var resultado = await handler.Handle(
+ new GetRepositoryRevisionTimelineQuery(tenant, RepoA, null), CancellationToken.None);
+
+ resultado.RepositoryUrl.Should().Be(RepoA);
+ resultado.Revisions.Select(r => r.RepositoryRevision).Should().Equal(["a-1", "a-2", "a-3"],
+ "la linea va en orden de revision y sin las evaluaciones de otro repositorio");
+
+ resultado.DriftSignals.Should().ContainSingle(
+ "el veredicto cambio una vez, entre a-2 y a-3");
+ var senal = resultado.DriftSignals[0];
+ senal.Signal.Should().Be(RepositoryRevisionTimeline.DriftDetected);
+ senal.FromRevision.Should().Be("a-2");
+ senal.ToRevision.Should().Be("a-3");
+ senal.FromVerdict.Should().Be("PASSED");
+ senal.ToVerdict.Should().Be("FAILED");
+ senal.DetectedAtUtc.Should().Be(Origen.AddMinutes(20));
+ }
+
+ [Fact]
+ public async Task ElCorteTemporalRecortaLaSerieYConEllaLaDeriva()
+ {
+ var tenant = Guid.NewGuid();
+ var repo = await Ledger(
+ Evaluacion(tenant, RepoA, "a-1", "PASSED", 0),
+ Evaluacion(tenant, RepoA, "a-2", "FAILED", 10),
+ Evaluacion(tenant, RepoA, "a-3", "FAILED", 20));
+
+ var handler = new GetRepositoryRevisionTimelineQueryHandler(repo);
+
+ var resultado = await handler.Handle(
+ new GetRepositoryRevisionTimelineQuery(tenant, RepoA, Origen.AddMinutes(10)),
+ CancellationToken.None);
+
+ resultado.Revisions.Select(r => r.RepositoryRevision).Should().Equal(["a-2", "a-3"]);
+ resultado.DriftSignals.Should().BeEmpty(
+ "dentro de la ventana pedida el veredicto no cambia; el PASSED->FAILED quedo fuera y "
+ + "declararlo seria contar como reciente algo que la consulta excluyo");
+ }
+
+ [Fact]
+ public async Task SinFiltroDeRepositorio_CadaSerieSeEvaluaPorSeparado()
+ {
+ var tenant = Guid.NewGuid();
+ var repo = await Ledger(
+ Evaluacion(tenant, RepoA, "a-1", "PASSED", 0),
+ Evaluacion(tenant, RepoB, "b-1", "PASSED", 5),
+ Evaluacion(tenant, RepoA, "a-2", "FAILED", 10),
+ Evaluacion(tenant, RepoB, "b-2", "PASSED", 15));
+
+ var handler = new GetRepositoryRevisionTimelineQueryHandler(repo);
+
+ var resultado = await handler.Handle(
+ new GetRepositoryRevisionTimelineQuery(tenant, null, null), CancellationToken.None);
+
+ resultado.RepositoryUrl.Should().BeNull();
+ resultado.Revisions.Should().HaveCount(4);
+ resultado.DriftSignals.Should().ContainSingle();
+ resultado.DriftSignals[0].RepositoryUrl.Should().Be(RepoA);
+ }
+
+ [Fact]
+ public async Task NoDevuelveLaLineaDeOtroTenant()
+ {
+ var tenant = Guid.NewGuid();
+ var ajeno = Guid.NewGuid();
+ var repo = await Ledger(
+ Evaluacion(tenant, RepoA, "a-1", "PASSED", 0),
+ Evaluacion(ajeno, RepoA, "ajena-1", "FAILED", 10));
+
+ var handler = new GetRepositoryRevisionTimelineQueryHandler(repo);
+
+ var resultado = await handler.Handle(
+ new GetRepositoryRevisionTimelineQuery(tenant, RepoA, null), CancellationToken.None);
+
+ resultado.Revisions.Select(r => r.RepositoryRevision).Should().Equal(["a-1"]);
+ resultado.DriftSignals.Should().BeEmpty(
+ "mezclar tenants sobre la MISMA url de repositorio no solo filtraria datos ajenos: "
+ + "fabricaria una deriva inexistente");
+ }
+}
diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Integration/RepositoryRevisionTimelineTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Integration/RepositoryRevisionTimelineTests.cs
new file mode 100644
index 00000000..86e3d479
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Tests/Domain/Integration/RepositoryRevisionTimelineTests.cs
@@ -0,0 +1,192 @@
+using Tracker.Domain.Integration.CoreEvaluationTransaction;
+
+namespace Tracker.Tests.Domain.Integration;
+
+///
+/// GT-615 — el defecto que estas pruebas fijan: el ledger persistia `repository_revision` junto al
+/// veredicto del Core y NO emitia nada. Habia sustrato para detectar deriva y ninguna lectura que
+/// lo hiciera, asi que un repositorio podia pasar de PASSED a FAILED entre dos revisiones sin que
+/// ninguna superficie lo dijera.
+///
+/// La proyeccion es pura a proposito: la deteccion de drift es una regla de dominio, no una
+/// consulta SQL, y aqui se demuestra sin base de datos ni host.
+///
+public class RepositoryRevisionTimelineTests
+{
+ private const string RepoA = "https://github.com/org/repo-a";
+ private const string RepoB = "https://github.com/org/repo-b";
+
+ private static readonly DateTime Origen = new(2026, 7, 30, 8, 0, 0, DateTimeKind.Utc);
+
+ ///
+ /// Un asiento del ledger tal como lo escriben `CoreEvaluationEndpoints` y
+ /// `ProductEndpoints.evaluate-architecture`: el veredicto viaja en `ResponseData["decision"]`.
+ ///
+ private static CoreEvaluationTransaction Evaluacion(
+ string repositoryUrl, string revision, string? decision, int minuto, string estado = "completed")
+ {
+ var momento = Origen.AddMinutes(minuto);
+ var txn = CoreEvaluationTransaction.Create(
+ Guid.NewGuid(), $"op-{revision}-{minuto}", repositoryUrl, revision, Guid.NewGuid()).Value!;
+
+ if (estado == "completed")
+ {
+ txn.Complete(new Dictionary
+ {
+ ["decision"] = decision!,
+ ["status"] = "COMPLETED"
+ });
+ }
+ else if (estado == "failed")
+ {
+ txn.Fail("CoreEvaluation.Unavailable", "el Core no respondio");
+ }
+
+ txn.SetProps(txn.Props with { RequestedAtUtc = momento, CompletedAtUtc = momento });
+ return txn;
+ }
+
+ [Fact]
+ public void CambioDeVeredictoEntreRevisionesConsecutivas_EmiteDriftDetected()
+ {
+ var linea = new[]
+ {
+ Evaluacion(RepoA, "rev-1", "PASSED", 0),
+ Evaluacion(RepoA, "rev-2", "PASSED", 10),
+ Evaluacion(RepoA, "rev-3", "FAILED", 20),
+ Evaluacion(RepoA, "rev-4", "FAILED", 30),
+ Evaluacion(RepoA, "rev-5", "PASSED", 40),
+ };
+
+ var entradas = RepositoryRevisionTimeline.Project(linea);
+ var senales = RepositoryRevisionTimeline.DetectDrift(entradas);
+
+ senales.Should().HaveCount(2,
+ "hay dos cambios de veredicto (rev-2->rev-3 y rev-4->rev-5); una revision que repite "
+ + "veredicto no es deriva y emitirla convertiria la senal en ruido");
+
+ senales[0].Signal.Should().Be(RepositoryRevisionTimeline.DriftDetected);
+ senales[0].RepositoryUrl.Should().Be(RepoA);
+ senales[0].FromRevision.Should().Be("rev-2");
+ senales[0].ToRevision.Should().Be("rev-3");
+ senales[0].FromVerdict.Should().Be("PASSED");
+ senales[0].ToVerdict.Should().Be("FAILED");
+ senales[0].SameRevision.Should().BeFalse();
+ senales[0].DetectedAtUtc.Should().Be(Origen.AddMinutes(20));
+
+ senales[1].FromRevision.Should().Be("rev-4");
+ senales[1].ToRevision.Should().Be("rev-5");
+ senales[1].FromVerdict.Should().Be("FAILED");
+ senales[1].ToVerdict.Should().Be("PASSED");
+ }
+
+ [Fact]
+ public void VeredictoEstable_NoEmiteNada()
+ {
+ var linea = new[]
+ {
+ Evaluacion(RepoA, "rev-1", "PASSED", 0),
+ Evaluacion(RepoA, "rev-2", "PASSED", 10),
+ Evaluacion(RepoA, "rev-3", "PASSED", 20),
+ };
+
+ RepositoryRevisionTimeline
+ .DetectDrift(RepositoryRevisionTimeline.Project(linea))
+ .Should().BeEmpty("sin cambio de veredicto no hay deriva que declarar");
+ }
+
+ [Fact]
+ public void LaDerivaNoCruzaRepositorios()
+ {
+ // Intercalados en el tiempo: si el recorrido no agrupara por repositorio, la alternancia
+ // A(PASSED) -> B(FAILED) -> A(PASSED) produciria dos senales inventadas.
+ var linea = new[]
+ {
+ Evaluacion(RepoA, "a-1", "PASSED", 0),
+ Evaluacion(RepoB, "b-1", "FAILED", 5),
+ Evaluacion(RepoA, "a-2", "PASSED", 10),
+ Evaluacion(RepoB, "b-2", "FAILED", 15),
+ };
+
+ RepositoryRevisionTimeline
+ .DetectDrift(RepositoryRevisionTimeline.Project(linea))
+ .Should().BeEmpty("cada repositorio tiene su propia serie; mezclarlas fabrica deriva "
+ + "donde solo hay dos productos distintos evaluandose a la vez");
+ }
+
+ [Fact]
+ public void UnaEvaluacionFallida_NoCuentaComoVeredicto()
+ {
+ // Una caida del Core no es un veredicto sobre la arquitectura. Si contara, cada incidencia
+ // de transporte generaria DOS senales (PASSED->ERROR y ERROR->PASSED) y el tablero de
+ // deriva mediria la salud del Core en vez de la del repositorio.
+ var linea = new[]
+ {
+ Evaluacion(RepoA, "rev-1", "PASSED", 0),
+ Evaluacion(RepoA, "rev-2", null, 10, estado: "failed"),
+ Evaluacion(RepoA, "rev-3", "PASSED", 20),
+ };
+
+ var entradas = RepositoryRevisionTimeline.Project(linea);
+
+ entradas[1].Verdict.Should().Be(RepositoryRevisionTimeline.VerdictError);
+ entradas[1].Decided.Should().BeFalse();
+
+ RepositoryRevisionTimeline.DetectDrift(entradas).Should().BeEmpty();
+ }
+
+ [Fact]
+ public void UnaEvaluacionPendiente_NoRompeLaCadena()
+ {
+ var linea = new[]
+ {
+ Evaluacion(RepoA, "rev-1", "PASSED", 0),
+ Evaluacion(RepoA, "rev-2", null, 10, estado: "pending"),
+ Evaluacion(RepoA, "rev-3", "FAILED", 20),
+ };
+
+ var entradas = RepositoryRevisionTimeline.Project(linea);
+ entradas[1].Verdict.Should().Be(RepositoryRevisionTimeline.VerdictPending);
+
+ var senales = RepositoryRevisionTimeline.DetectDrift(entradas);
+
+ senales.Should().ContainSingle("la revision sin decidir se salta, no corta la serie: el "
+ + "cambio real sigue siendo rev-1 -> rev-3");
+ senales[0].FromRevision.Should().Be("rev-1");
+ senales[0].ToRevision.Should().Be("rev-3");
+ }
+
+ [Fact]
+ public void ReevaluarLaMismaRevisionConOtroVeredicto_SeMarcaComoDerivaDeRegla()
+ {
+ var linea = new[]
+ {
+ Evaluacion(RepoA, "rev-1", "PASSED", 0),
+ Evaluacion(RepoA, "rev-1", "FAILED", 10),
+ };
+
+ var senales = RepositoryRevisionTimeline.DetectDrift(RepositoryRevisionTimeline.Project(linea));
+
+ senales.Should().ContainSingle();
+ senales[0].SameRevision.Should().BeTrue(
+ "el codigo no cambio: lo que cambio fue la regla. Sin esta marca, quien lea la senal "
+ + "sale a buscar un commit que no existe");
+ }
+
+ [Fact]
+ public void LaProyeccionNumeraLaSerieYConservaElOrdenRecibido()
+ {
+ var linea = new[]
+ {
+ Evaluacion(RepoA, "rev-1", "PASSED", 0),
+ Evaluacion(RepoA, "rev-2", "FAILED", 10),
+ };
+
+ var entradas = RepositoryRevisionTimeline.Project(linea);
+
+ entradas.Select(e => e.Sequence).Should().Equal([0, 1]);
+ entradas.Select(e => e.RepositoryRevision).Should().Equal(["rev-1", "rev-2"]);
+ entradas.Select(e => e.Verdict).Should().Equal(["PASSED", "FAILED"]);
+ entradas.Should().OnlyContain(e => e.Decided);
+ }
+}
diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/CoreEvaluationTransactionRevisionQueryTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/CoreEvaluationTransactionRevisionQueryTests.cs
new file mode 100644
index 00000000..0d5c6917
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/CoreEvaluationTransactionRevisionQueryTests.cs
@@ -0,0 +1,133 @@
+using Tracker.Infrastructure.Persistence.Integration;
+using Tracker.Infrastructure.Persistence.Integration.Entities;
+
+namespace Tracker.Tests.Infrastructure.Persistence;
+
+///
+/// GT-615 — el otro medio defecto: el ledger solo se podia consultar «todo el tenant» o «una por
+/// id». No habia filtro por repositorio, ni corte temporal, ni orden de revision. Esta prueba
+/// ejercita la composicion de consulta REAL (la misma expresion que va a Postgres) sobre una lista
+/// en memoria, siguiendo el idioma sin-base de `ProviderConnectionMapperTests`: el defecto es de
+/// consulta, y una prueba que exigiera Postgres se saltaria en CI dejando el criterio sin demostrar.
+///
+public class CoreEvaluationTransactionRevisionQueryTests
+{
+ private const string RepoA = "https://github.com/org/repo-a";
+ private const string RepoB = "https://github.com/org/repo-b";
+
+ private static readonly DateTime Origen = new(2026, 7, 30, 8, 0, 0, DateTimeKind.Utc);
+
+ private static CoreEvaluationTransactionRecord Registro(
+ Guid tenantId, string repositoryUrl, string revision, int minuto, bool borrado = false)
+ => new()
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ OperationId = $"op-{revision}",
+ RepositoryUrl = repositoryUrl,
+ RepositoryRevision = revision,
+ Status = "completed",
+ RequestedAtUtc = Origen.AddMinutes(minuto),
+ CompletedAtUtc = Origen.AddMinutes(minuto),
+ IsDeleted = borrado
+ };
+
+ [Fact]
+ public void FiltraPorRepositorio_YDejaFueraLosDemas()
+ {
+ var tenant = Guid.NewGuid();
+ var datos = new[]
+ {
+ Registro(tenant, RepoA, "a-1", 0),
+ Registro(tenant, RepoB, "b-1", 5),
+ Registro(tenant, RepoA, "a-2", 10),
+ }.AsQueryable();
+
+ var resultado = PostgreSqlCoreEvaluationTransactionRepository
+ .ApplyRevisionTimelineQuery(datos, tenant, RepoA, null)
+ .ToList();
+
+ resultado.Select(r => r.RepositoryRevision).Should().Equal(["a-1", "a-2"],
+ "sin este filtro interrogar el historial de UN repositorio obligaba a traerse el "
+ + "ledger entero del tenant y filtrar en el cliente");
+ }
+
+ [Fact]
+ public void FiltraPorSince_ConLimiteInclusivo()
+ {
+ var tenant = Guid.NewGuid();
+ var datos = new[]
+ {
+ Registro(tenant, RepoA, "a-1", 0),
+ Registro(tenant, RepoA, "a-2", 10),
+ Registro(tenant, RepoA, "a-3", 20),
+ }.AsQueryable();
+
+ var resultado = PostgreSqlCoreEvaluationTransactionRepository
+ .ApplyRevisionTimelineQuery(datos, tenant, null, Origen.AddMinutes(10))
+ .ToList();
+
+ resultado.Select(r => r.RepositoryRevision).Should().Equal(["a-2", "a-3"],
+ "el corte es >= : excluir el instante exacto haria que dos consultas encadenadas por "
+ + "el ultimo `observedAt` se saltaran justo la revision de la frontera");
+ }
+
+ [Fact]
+ public void DevuelveLaSerieEnOrdenDeRevision_NoAlReves()
+ {
+ var tenant = Guid.NewGuid();
+ var datos = new[]
+ {
+ Registro(tenant, RepoA, "a-3", 20),
+ Registro(tenant, RepoA, "a-1", 0),
+ Registro(tenant, RepoA, "a-2", 10),
+ }.AsQueryable();
+
+ var resultado = PostgreSqlCoreEvaluationTransactionRepository
+ .ApplyRevisionTimelineQuery(datos, tenant, RepoA, null)
+ .ToList();
+
+ resultado.Select(r => r.RepositoryRevision).Should().Equal(["a-1", "a-2", "a-3"],
+ "la deteccion de deriva lee la serie hacia adelante; con el orden del ledger (mas "
+ + "reciente primero) cada senal saldria invertida");
+ }
+
+ [Fact]
+ public void CombinaAmbosFiltros()
+ {
+ var tenant = Guid.NewGuid();
+ var datos = new[]
+ {
+ Registro(tenant, RepoA, "a-1", 0),
+ Registro(tenant, RepoB, "b-1", 10),
+ Registro(tenant, RepoA, "a-2", 20),
+ }.AsQueryable();
+
+ var resultado = PostgreSqlCoreEvaluationTransactionRepository
+ .ApplyRevisionTimelineQuery(datos, tenant, RepoA, Origen.AddMinutes(10))
+ .ToList();
+
+ resultado.Select(r => r.RepositoryRevision).Should().Equal(["a-2"]);
+ }
+
+ [Fact]
+ public void NoCruzaTenantsNiDevuelveBorrados()
+ {
+ var tenant = Guid.NewGuid();
+ var otro = Guid.NewGuid();
+ var datos = new[]
+ {
+ Registro(tenant, RepoA, "a-1", 0),
+ Registro(otro, RepoA, "ajeno", 5),
+ Registro(tenant, RepoA, "a-borrado", 10, borrado: true),
+ }.AsQueryable();
+
+ var resultado = PostgreSqlCoreEvaluationTransactionRepository
+ .ApplyRevisionTimelineQuery(datos, tenant, RepoA, null)
+ .ToList();
+
+ resultado.Select(r => r.RepositoryRevision).Should().Equal(["a-1"],
+ "la superficie nueva no puede aflojar el aislamiento por tenant ni resucitar asientos "
+ + "borrados (T-044)");
+ }
+}