Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/audit/tracker-gap-reference-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an
- **Una aplicabilidad inválida se rechaza al guardar**, por la misma razón que en la matriz de señales.

**Las TRES superficies leen el mismo catálogo con overlay**, que era el riesgo real de dejarlo a medias: el endpoint de perfiles, el contraste del expediente (criterio 1) y el `artifacts.required` que se le manda al Core en la evaluación. Mandar al Core el estándar pelado mientras el Tracker gobierna con el overlay habría sido, otra vez, dos superficies discrepando sobre el mismo hecho.
- [ ] El scorecard y las compuertas leen la misma fuente de catálogo. **Parcial:** el expediente, el endpoint de solo lectura y el payload que va al Core comparten ya el catálogo con overlay; `ScorecardEvaluator` sigue sin consultarlo.
- [x] El scorecard y las compuertas leen la misma fuente de catálogo — `ScorecardProjection` consulta ya el catálogo tenant-aware, igual que el expediente, el endpoint de solo lectura y el payload que va al Core.

**El falso verde que esto cerró (2026-08-03):** los hechos del scorecard salían SÓLO de lo entregado (`artefacto.Items`), así que un artefacto que el estándar exige y nadie entregó era **invisible**. Una fase con el expediente vacío no puntuaba mal: puntuaba `null` — «nada exigible todavía» — mientras el catálogo del tenant pedía tres cosas. El cuadro decía que no había nada que reprochar justo cuando faltaba todo.

**Lo que NO se tocó, y es correcto:** un exigido sin entregar DENTRO de plazo sigue en `pendiente`, no en rojo. Penalizar antes del vencimiento sería castigar por adelantado, y esa regla ya estaba bien razonada. La prueba fija el momento en que debe doler: vencido y sin entregar.

**Se puntúa contra el catálogo CON overlay**, no contra el estándar pelado: castigar a un tenant por no entregar lo que él mismo declaró no aplicable sería exactamente lo contrario de lo que CP-02 criterio 2 acaba de permitirle.
- [ ] `GAP-020` queda re-evaluado a la luz del catálogo vivo.

**Hallazgo que motivó el cambio (2026-08-03):** el Tracker tenía DOS vocabularios de artefactos que nunca se encontraban. `/phase-artifact-profiles` derivaba del Core pero nada operativo lo consultaba; el expediente que la gente llena aceptaba cualquier cadena en `type` y arrancaba con `Items` vacío. Una fase podía completarse entera con artefactos que el Core no declara sin que ninguna superficie lo dijera — que es el **Risk** de esta ficha al pie de la letra.
Expand Down
2 changes: 1 addition & 1 deletion docs/audit/tracker-gap-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ This board is the single source of truth for Tracker technical debt, gaps, oppor

| ID | Gap | Qué significa | Ejemplo | Component | Phase | Criticality | Complexity | Status |
|---|---|---|---|:---:|:---:|:---:|:---:|:---:|
| [`CP-02`](./tracker-gap-reference-catalog.md#cp-02) | El Tracker no tiene un catálogo vivo de formatos de artefacto por fase sincronizado con el Core y parametrizable por tenant | La iniciativa puede avanzar con una lista local de entregables que ya no coincide con el estándar que el Core evalúa | El expediente operativo de construction/QA/release ya se contrasta contra el catálogo derivado del Core por la misma costura que el endpoint de solo lectura; con overlay por tenant que marca requerido, opcional o no aplicable sin tocar el estándar base, y las tres superficies leen el mismo catálogo; el scorecard sigue sin consultarlo | `Artifacts/Config` | Cross | P0 | M | `PENDING` |
| [`CP-02`](./tracker-gap-reference-catalog.md#cp-02) | El Tracker no tiene un catálogo vivo de formatos de artefacto por fase sincronizado con el Core y parametrizable por tenant | La iniciativa puede avanzar con una lista local de entregables que ya no coincide con el estándar que el Core evalúa | El expediente operativo de construction/QA/release ya se contrasta contra el catálogo derivado del Core por la misma costura que el endpoint de solo lectura; con overlay por tenant que marca requerido, opcional o no aplicable sin tocar el estándar base, y las cuatro superficies leen el mismo catálogo, scorecard incluido, que antes daba por «nada exigible» una fase a la que le faltaba todo | `Artifacts/Config` | Cross | P0 | M | `PENDING` |
| [`CP-06`](./tracker-gap-reference-catalog.md#cp-06) | Falta una matriz por tenant que convierta resultados del Core en decisión de compuerta distinguiendo bloqueante, requisito local y advisory | Un hallazgo técnico opcional puede terminar bloqueando una fase, o un requisito contractual puede quedar como simple recomendación | La matriz por tenant y fase ya existe y se pliega en la decisión de la compuerta, con advisory por defecto y la configuración aplicada guardada en la entrega; falta la superficie web y conectar las observaciones reales del Core a la entrada del evaluador | `Governance/Tenancy` | Cross | P0 | M | `PENDING` |
| [`CP-12`](./tracker-gap-reference-catalog.md#cp-12) | No existe una mesa de ejecución documental por gate que permita operar cada artefacto tenant-aware de inicio a aprobación | El usuario sabe qué artefactos pide el gate, pero no tiene un lugar único para crear la copia del template, editarla, ver su estado y llevarla a aprobación | Los catálogos y wizards cubren piezas; falta el expediente operativo por iniciativa/fase/gate/artefacto | `WEB/Artifacts` | Cross | P0 | L | `PENDING` |
| [`CP-15`](./tracker-gap-reference-catalog.md#cp-15) | Los artefactos y gates no tienen flujos de aprobación configurables por tenant con personas y agentes autorizados | Una fase puede depender de revisores humanos o agentes especializados, pero el Tracker no modela secuencias, quórum, delegaciones ni HITL por artefacto | Las aprobaciones existentes no resuelven miembros/equipos/agentes del tenant para cada artefacto SDLC | `Governance/Tenancy` | Cross | P0 | L | `PENDING` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ internal sealed class ScorecardProjection(
IPhaseProgressionRepository progressions,
IPhaseArtifactRepository artifacts,
ITechnicalBlueprintRepository blueprints,
IPhaseSlaPolicyRepository slas) : IScorecardProjection
IPhaseSlaPolicyRepository slas,
// CP-02 criterio 3 — el MISMO catálogo que gobierna el expediente y la evaluación.
Governance.ArtifactFieldSchema.ITenantPhaseArtifactCatalog catalogo) : IScorecardProjection
{
/// <summary>
/// Las fases que el scorecard PINTA. Las tres genéricas sacan sus artefactos de
Expand Down Expand Up @@ -99,7 +101,7 @@ internal sealed class ScorecardProjection(

var items = fase == SdlcPhases.Design
? await ArtefactosDeDisenoAsync(initiativeId, ct)
: await ArtefactosGenericosAsync(initiativeId, fase, ct);
: await ArtefactosGenericosAsync(tenantId, initiativeId, fase, ct);

if (FasesPintadas.Contains(fase) && !politicas.ContainsKey(fase))
advertencias.Add($"Metrics.NoSlaConfigured.{fase}");
Expand Down Expand Up @@ -133,18 +135,45 @@ internal sealed class ScorecardProjection(
return new ScorecardDto(initiativeId, resultado.Score, fases, advertencias);
}

/// <summary>
/// Los artefactos de una fase genérica: lo ENTREGADO más lo EXIGIDO que falta.
///
/// <para><b>El falso verde que esto cierra (CP-02 criterio 3).</b> Antes los hechos salían sólo
/// de <c>artefacto.Items</c>, así que un artefacto que el estándar exige y nadie entregó era
/// INVISIBLE para el scorecard. Una fase con el expediente vacío no puntuaba mal: puntuaba
/// `null` — «nada exigible todavía» — mientras el catálogo del tenant pedía tres cosas. El
/// cuadro decía que no había nada que reprochar justo cuando faltaba todo.</para>
///
/// <para>El catálogo es el TENANT-AWARE, el mismo que gobierna el expediente y el payload que
/// va al Core: un scorecard que puntuara contra el estándar pelado castigaría a un tenant por
/// no entregar lo que él mismo declaró no aplicable.</para>
/// </summary>
private async Task<IReadOnlyList<ArtifactFact>> ArtefactosGenericosAsync(
Guid initiativeId, string fase, CancellationToken ct)
Guid tenantId, Guid initiativeId, string fase, CancellationToken ct)
{
var artefacto = await artifacts.GetByInitiativeAndPhaseAsync(initiativeId, fase, ct);
return artefacto is null
var entregados = artefacto is null
? []
: artefacto.Items
// GT-475 — se propaga la fecha de entrega por artefacto (null en las verticales de
// diseño/discovery, que no la modelan) para que el semáforo pueda ser de grano
// artefacto y no sólo de fase.
.Select(i => new ArtifactFact(i.Type, i.IsComplete, ArtifactOrigin.Automatic, i.DeliveredAtUtc))
.ToList();

var perfil = await catalogo.GetPhaseAsync(tenantId, fase, ct);
if (perfil is null) return entregados;

var yaEstan = entregados.Select(f => f.Type).ToHashSet(StringComparer.OrdinalIgnoreCase);

// Sólo lo `Required` tras el overlay. Lo advisory cuenta para completitud y pintarlo en
// rojo por no entregarse inventaría un incumplimiento sobre algo que nadie tenía que
// entregar — el mismo razonamiento que el ADR de integración de más abajo.
var faltantes = perfil.Artifacts
.Where(a => a.Required && !yaEstan.Contains(a.ArtifactKind))
.Select(a => new ArtifactFact(a.ArtifactKind, IsComplete: false, ArtifactOrigin.Automatic, DeliveredAtUtc: null));

return [.. entregados, .. faltantes];
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,30 @@ public class ScorecardProjectionTests
private readonly Mock<ITechnicalBlueprintRepository> _blueprints = new();
private readonly Mock<IPhaseSlaPolicyRepository> _slas = new();

// CP-02 criterio 3 — el catálogo del tenant. Por defecto NO devuelve perfil, así que las
// pruebas que ya existían siguen midiendo exactamente lo que medían: sin catálogo, el
// scorecard puntúa lo entregado y nada más.
private readonly Mock<Tracker.Application.Governance.ArtifactFieldSchema.ITenantPhaseArtifactCatalog> _catalogo = new();

private IScorecardProjection Sut() =>
new ScorecardProjection(
_progressions.Object, _artifacts.Object, _blueprints.Object, _slas.Object);
_progressions.Object, _artifacts.Object, _blueprints.Object, _slas.Object, _catalogo.Object);

/// <summary>Declara que el catálogo del tenant EXIGE estos artefactos en esa fase.</summary>
private void CatalogoExige(string fase, params string[] tipos)
=> _catalogo
.Setup(c => c.GetPhaseAsync(It.IsAny<Guid>(), fase, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactProfileDto
{
Phase = fase,
Artifacts = [.. tipos.Select(t =>
new Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactDto
{
ArtifactKind = t,
Label = t,
Required = true,
})],
});

private DomainPhaseProgression Progresion(params (string Phase, DateTime Approved)[] historia)
{
Expand Down Expand Up @@ -64,6 +85,94 @@ private void SinArtefactos() =>
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((DomainPhaseArtifact?)null);

// ── CP-02 criterio 3 — el scorecard puntúa contra el catálogo del tenant ──

[Fact]
public async Task UN_EXIGIDO_QUE_NADIE_ENTREGO_YA_NO_ES_INVISIBLE()
{
// EL FALSO VERDE QUE ESTO CIERRA. Antes los hechos salían sólo del expediente, así que una
// fase con el expediente vacío puntuaba `null` — «nada exigible todavía» — mientras el
// catálogo del tenant pedía dos cosas. El cuadro decía que no había nada que reprochar
// justo cuando faltaba todo.
// El plazo YA venció, que es cuando un exigido sin entregar debe doler. Dentro de plazo la
// regla existente lo deja en `pendiente` y es correcta: penalizar antes del vencimiento
// sería castigar por adelantado.
ConSla(("construction", 1));
SinArtefactos();
CatalogoExige("construction", "coverage-report", "integration-evidence");
_progressions.Setup(r => r.GetByInitiativeAsync(_initiativeId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Progresion(("design", DateTime.UtcNow.AddDays(-30))));

var dto = await Sut().ProjectAsync(_tenantId, _initiativeId);

var fase = dto!.Fases.Single(f => f.Fase == "construction");
fase.Artefactos.Select(a => a.Type)
.Should().BeEquivalentTo(["coverage-report", "integration-evidence"]);
fase.Artefactos.Should().OnlyContain(a => a.Estado == ArtifactSemaphore.Missing,
"vencido y sin entregar es exactamente lo que el cuadro tenía que estar diciendo");
dto.Score.Should().Be(0, "y ahora pesa: antes la misma fase puntuaba «nada exigible»");
}

[Fact]
public async Task LO_ENTREGADO_NO_SE_DUPLICA_CON_LO_EXIGIDO()
{
ConSla();
ConArtefactos("construction", ("coverage-report", true));
CatalogoExige("construction", "coverage-report", "integration-evidence");
_progressions.Setup(r => r.GetByInitiativeAsync(_initiativeId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Progresion(("construction", DateTime.UtcNow.AddDays(-1))));

var dto = await Sut().ProjectAsync(_tenantId, _initiativeId);

var fase = dto!.Fases.Single(f => f.Fase == "construction");
fase.Artefactos.Should().HaveCount(2);
fase.Artefactos.Count(a => a.Type == "coverage-report").Should().Be(1);
}

[Fact]
public async Task LO_QUE_EL_TENANT_DECLARO_NO_APLICABLE_NO_SE_PINTA()
{
// El catálogo llega YA con el overlay aplicado, así que un artefacto marcado no aplicable
// no viene como `Required`. Puntuar contra el estándar pelado castigaría a un tenant por
// no entregar lo que él mismo descartó.
ConSla();
SinArtefactos();
_catalogo
.Setup(c => c.GetPhaseAsync(It.IsAny<Guid>(), "construction", It.IsAny<CancellationToken>()))
.ReturnsAsync(new Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactProfileDto
{
Phase = "construction",
Artifacts =
[
new Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactDto
{
ArtifactKind = "cfr-metric", Label = "CFR", Required = false,
},
],
});
_progressions.Setup(r => r.GetByInitiativeAsync(_initiativeId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Progresion(("construction", DateTime.UtcNow.AddDays(-1))));

var dto = await Sut().ProjectAsync(_tenantId, _initiativeId);

dto!.Fases.Single(f => f.Fase == "construction").Artefactos.Should().BeEmpty();
}

[Fact]
public async Task SinCatalogoElScorecardSeComportaComoAntes()
{
// Sin perfil resuelto no se añade nada: un despliegue sin catálogo no cambia de conducta.
ConSla();
ConArtefactos("construction", ("algo-local", true));
_progressions.Setup(r => r.GetByInitiativeAsync(_initiativeId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Progresion(("construction", DateTime.UtcNow.AddDays(-1))));

var dto = await Sut().ProjectAsync(_tenantId, _initiativeId);

dto!.Fases.Single(f => f.Fase == "construction").Artefactos
.Should().ContainSingle().Which.Type.Should().Be("algo-local");
}

[Fact]
public async Task SinProgresion_DevuelveNulo()
{
Expand Down
Loading