diff --git a/.codex/config.toml b/.codex/config.toml
deleted file mode 100644
index 8b5006a4..00000000
--- a/.codex/config.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-[mcp_servers.nx]
-command = "npx"
-args = [
- "-y",
- "nx-mcp@latest",
- "/Users/beyondnet/Source/evolith_tracker/src",
- "--minimal",
- "false",
- "--disableTelemetry",
-]
diff --git a/.gitignore b/.gitignore
index d63c798c..1bfc83ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -165,3 +165,8 @@ vite.config.ts.timestamp-*
# Informe generado por .harness/scripts/coverage-dashboard.mjs; el workflow lo sube
# como artifact. No se versiona ni se valida como documentacion redactada.
COVERAGE_REPORT.md
+
+# Local editor/tool config: machine-specific absolute paths, never shared.
+# Committed by accident in a5f719c (a `git add -A`) and only caught when a PR
+# targeted main, where the root-cleanliness guard rejects unknown root folders.
+.codex/
diff --git a/docs/audit/tracker-gap-reference-catalog.md b/docs/audit/tracker-gap-reference-catalog.md
index 045aa00c..da0c2212 100644
--- a/docs/audit/tracker-gap-reference-catalog.md
+++ b/docs/audit/tracker-gap-reference-catalog.md
@@ -46,10 +46,14 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an
- **Criticality:** P0 · **Complexity:** M
- **Proposed fix:** Crear un `CoreArtifactFormatCatalog` que sincronice el catálogo por fase desde el Core, materialice una vista tenant-aware y permita overrides locales declarados, auditables y no destructivos.
- **Acceptance criteria:**
- - [ ] Discovery, design, construction, QA y release usan un catálogo de artefactos derivado del Core.
- - [ ] Cada tenant puede marcar artefactos como requeridos, opcionales o no aplicables sin modificar el estándar base.
- - [ ] El scorecard y las compuertas leen la misma fuente de catálogo.
+ - [x] **Parcial (construction/QA/release):** el expediente operativo (`GetPhaseArtifact`) ya se contrasta contra el catálogo derivado del Core, resolviendo por `IPhaseArtifactProfileSource` — la misma costura que lee `/phase-artifact-profiles`. Discovery y Design conservan sus verticales a medida y NO están cubiertas; el camino genérico solo cubre esas tres fases (`SdlcPhases.GenericGatePhases`).
+ - [ ] Cada tenant puede marcar artefactos como requeridos, opcionales o no aplicables sin modificar el estándar base. **Sin empezar.** No existe ningún override por tenant sobre este catálogo: el handler no recibe tenant y no hay entidad de aplicabilidad. Es la otra mitad del título de esta ficha.
+ - [ ] El scorecard y las compuertas leen la misma fuente de catálogo. **Parcial:** el expediente y el endpoint de solo lectura ya comparten fuente; `ScorecardEvaluator` y el camino de `GateSubmission` siguen sin consultarla.
- [ ] `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.
+
+**Reporta, no impone (T-056).** El bloque `coreCatalog` dice las dos direcciones (lo declarado que falta, y lo presente que el Core no declara) y ahí se detiene. Nada rechaza un ítem ni cambia un estado: qué exige una compuerta lo configura el tenant, no lo cablea el motor. Se distingue además «no había catálogo para la fase» de «el catálogo no pide nada»: la primera omite el bloque en vez de devolver una lista vacía.
- **Dependencies:** CP-01.
#### CP-03
diff --git a/docs/audit/tracker-gap-tracking.md b/docs/audit/tracker-gap-tracking.md
index ec4578ca..b014dd87 100644
--- a/docs/audit/tracker-gap-tracking.md
+++ b/docs/audit/tracker-gap-tracking.md
@@ -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 | Core publica gates y schemas para `gate-f1..f5`; Tracker conserva catálogos derivados y un gap histórico de URLs de esquema | `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; falta la parametrización por tenant, y el scorecard y las compuertas siguen sin leer esa fuente | `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 | El gateway lee `overallVerdict` y `results.gate`, pero no aplica una política tenant-aware sobre `qualitySignals`, `phaseArtifacts`, `design`, `architecture` o recomendaciones | `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` |
diff --git a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs
index 61ce04a3..edb8dd8f 100644
--- a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs
+++ b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs
@@ -12,6 +12,51 @@ public sealed class ArtifactItemDto
public string Status { get; init; } = string.Empty;
}
+///
+/// CP-02 — one artifact of the phase AS THE CORE DECLARES IT, next to whether this
+/// record actually carries it.
+///
+public sealed class CoreCatalogArtifactDto
+{
+ public string ArtifactKind { get; init; } = string.Empty;
+ public string Label { get; init; } = string.Empty;
+
+ /// `binding` in the Core registry. Says what the STANDARD expects, not what this Tracker enforces.
+ public bool Required { get; init; }
+
+ /// Whether an item of this kind exists on the record. Present, not valid — nothing here reads content.
+ public bool Present { get; init; }
+}
+
+///
+/// CP-02 — the phase's artifact catalogue as the Core declares it, resolved through the
+/// SAME seam the read-only `/phase-artifact-profiles` endpoint reads.
+///
+/// Why this is REPORTED and never enforced. T-056: what a gate demands is
+/// tenant configuration, not engine code. Comparing the record against the standard is
+/// a fact; deciding that a missing artifact blocks a phase is an opinion, and it is not
+/// ours to wire. So this block states both directions and stops there.
+///
+/// Both directions, deliberately. `Artifacts` says what the Core declares
+/// and whether the record carries it; says what the record
+/// carries that the Core does not declare. Reporting only the first would make a record
+/// full of off-standard items look complete.
+///
+public sealed class PhaseCoreCatalogDto
+{
+ ///
+ /// `core-sync` once a sync has succeeded, `core-standin` while the mirror is being
+ /// served. A reader must be able to tell a synced answer from a mirrored one by
+ /// reading the payload rather than by assuming a sync happened.
+ ///
+ public string Source { get; init; } = string.Empty;
+
+ public IReadOnlyList Artifacts { get; init; } = new List();
+
+ /// Item kinds on the record that the Core catalogue does not declare.
+ public IReadOnlyList UnknownItemKinds { get; init; } = new List();
+}
+
public sealed class PhaseArtifactDto
{
public Guid Id { get; init; }
@@ -20,6 +65,13 @@ public sealed class PhaseArtifactDto
public string Phase { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public IReadOnlyList Items { get; init; } = new List();
+
+ ///
+ /// Null when no catalogue was resolved for the phase — which is NOT the same as an
+ /// empty catalogue, and the difference matters: one means "the standard was not
+ /// available", the other means "the standard asks for nothing here".
+ ///
+ public PhaseCoreCatalogDto? CoreCatalog { get; init; }
}
public static class PhaseArtifactDtoMapper
@@ -34,6 +86,54 @@ public static class PhaseArtifactDtoMapper
Items = artifact.Items.Select(ToDto).ToList()
};
+ ///
+ /// CP-02 — the same record, contrasted against the Core's catalogue for its phase.
+ ///
+ /// Item kinds are matched case-insensitively: the catalogue writes slugs and the
+ /// record accepts whatever a caller sent, so a case difference would report a present
+ /// artifact as missing — a false gap on a governance surface, which is worse than no
+ /// comparison at all.
+ ///
+ public static PhaseArtifactDto ToDto(
+ DomainPhaseArtifact artifact,
+ string source,
+ IReadOnlyList<(string Kind, string Label, bool Required)> catalog)
+ {
+ var present = artifact.Items
+ .Select(i => i.Type)
+ .Where(t => !string.IsNullOrWhiteSpace(t))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var declared = catalog.Select(c => c.Kind).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return new PhaseArtifactDto
+ {
+ Id = artifact.Id,
+ TenantId = artifact.TenantId,
+ InitiativeId = artifact.InitiativeId,
+ Phase = artifact.Phase,
+ Status = artifact.Status,
+ Items = artifact.Items.Select(ToDto).ToList(),
+ CoreCatalog = new PhaseCoreCatalogDto
+ {
+ Source = source,
+ Artifacts = catalog
+ .Select(c => new CoreCatalogArtifactDto
+ {
+ ArtifactKind = c.Kind,
+ Label = c.Label,
+ Required = c.Required,
+ Present = present.Contains(c.Kind),
+ })
+ .ToList(),
+ UnknownItemKinds = present
+ .Where(t => !declared.Contains(t))
+ .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)
+ .ToList(),
+ },
+ };
+ }
+
private static ArtifactItemDto ToDto(ArtifactItem i) => new()
{
Type = i.Type,
diff --git a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs
index 97f1a82b..6ef91d70 100644
--- a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs
+++ b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs
@@ -1,16 +1,42 @@
using Tracker.Application.Sdlc.PhaseArtifact.DTOs;
using Tracker.Domain.Sdlc.PhaseArtifact;
+// `PhaseArtifactDto` is a live name in BOTH the governance catalogue and this module. The
+// namespace is aliased rather than imported so the two never silently resolve to each other.
+using IPhaseArtifactProfileSource = Tracker.Application.Governance.ArtifactFieldSchema.IPhaseArtifactProfileSource;
+
namespace Tracker.Application.Sdlc.PhaseArtifact.Queries.GetPhaseArtifact;
+///
+/// CP-02 — the operative phase record, contrasted against the Core's catalogue.
+///
+/// What this closes. The Tracker had TWO artifact vocabularies that never met:
+/// `/phase-artifact-profiles`, read-only and derived from the Core, which nothing operational
+/// consulted; and this record, which the user actually fills and which accepted any `type`
+/// string whatsoever. A phase could therefore be completed entirely with items the Core does
+/// not declare, and no surface would say so. That is CP-02's risk verbatim: the Tracker
+/// looks like it governs the SDLC while governing a copy of the standard.
+///
+/// It reports, it does not enforce. T-056: what a gate demands is tenant
+/// configuration, not engine code. Nothing here rejects an item, changes a status, or blocks
+/// a phase — the comparison is a fact the caller can act on under its own policy.
+///
+/// Same seam, deliberately. It resolves through ,
+/// the very source the read-only endpoint reads. Two surfaces answering the same question from
+/// two catalogues is the defect; a second copy here would have rebuilt it.
+///
internal sealed class GetPhaseArtifactQueryHandler
: IQueryHandler
{
private readonly IPhaseArtifactRepository _artifacts;
+ private readonly IPhaseArtifactProfileSource _profiles;
- public GetPhaseArtifactQueryHandler(IPhaseArtifactRepository artifacts)
+ public GetPhaseArtifactQueryHandler(
+ IPhaseArtifactRepository artifacts,
+ IPhaseArtifactProfileSource profiles)
{
_artifacts = artifacts;
+ _profiles = profiles;
}
public async Task Handle(
@@ -21,6 +47,18 @@ public GetPhaseArtifactQueryHandler(IPhaseArtifactRepository artifacts)
if (artifact is null || artifact.TenantId != request.TenantId)
return null;
- return PhaseArtifactDtoMapper.ToDto(artifact);
+ var profile = _profiles.GetProfiles()
+ .FirstOrDefault(p => string.Equals(p.Phase, artifact.Phase, StringComparison.OrdinalIgnoreCase));
+
+ // No profile for the phase is NOT an empty profile. Reporting "the standard asks for
+ // nothing here" when the truth is "the standard was not available" is the kind of
+ // confident emptiness a governance surface must never produce, so the block is omitted.
+ if (profile is null)
+ return PhaseArtifactDtoMapper.ToDto(artifact);
+
+ return PhaseArtifactDtoMapper.ToDto(
+ artifact,
+ profile.Source,
+ profile.Artifacts.Select(a => (a.ArtifactKind, a.Label, a.Required)).ToList());
}
}
diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/GetPhaseArtifactCoreCatalogTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/GetPhaseArtifactCoreCatalogTests.cs
new file mode 100644
index 00000000..d5baf1f7
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/GetPhaseArtifactCoreCatalogTests.cs
@@ -0,0 +1,149 @@
+using Tracker.Application.Governance.ArtifactFieldSchema;
+using Tracker.Application.Sdlc.PhaseArtifact.Queries.GetPhaseArtifact;
+using Tracker.Domain.Sdlc.PhaseArtifact;
+using DomainPhaseArtifact = Tracker.Domain.Sdlc.PhaseArtifact.PhaseArtifact;
+using GovPhaseArtifactDto = Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactDto;
+
+namespace Tracker.Tests.Application.Sdlc;
+
+///
+/// CP-02 — el expediente operativo contrastado contra el catálogo del Core.
+///
+/// Lo que se prueba no es que los campos se copien, sino las propiedades que hacen que
+/// este contraste sea seguro en una superficie de gobierno: que REPORTE y no imponga (T-056),
+/// que diga las DOS direcciones, que no confunda «no había catálogo» con «el catálogo no pide
+/// nada», y que una diferencia de mayúsculas no invente un hueco que no existe.
+///
+public class GetPhaseArtifactCoreCatalogTests
+{
+ private static readonly Guid Tenant = Guid.NewGuid();
+ private static readonly Guid Initiative = Guid.NewGuid();
+
+ private sealed class FakeProfiles : IPhaseArtifactProfileSource
+ {
+ private readonly IReadOnlyList _profiles;
+ public FakeProfiles(string source, IReadOnlyList profiles)
+ {
+ Source = source;
+ _profiles = profiles;
+ }
+
+ public string Source { get; }
+
+ public IReadOnlyList GetProfiles() => new List
+ {
+ new()
+ {
+ Phase = "construction",
+ Source = Source,
+ Artifacts = _profiles.ToList(),
+ },
+ };
+ }
+
+ private sealed class FakeRepo : IPhaseArtifactRepository
+ {
+ private readonly DomainPhaseArtifact? _one;
+ public FakeRepo(DomainPhaseArtifact? one) => _one = one;
+
+ public Task GetByInitiativeAndPhaseAsync(
+ Guid initiativeId, string phase, CancellationToken ct) => Task.FromResult(_one);
+
+ public Task AddAsync(DomainPhaseArtifact a, CancellationToken ct) => Task.CompletedTask;
+ public Task UpdateAsync(DomainPhaseArtifact a, CancellationToken ct) => Task.CompletedTask;
+ }
+
+ private static DomainPhaseArtifact Expediente(params (string Type, string? Ref)[] items)
+ {
+ var a = DomainPhaseArtifact.CreateForPhase(Tenant, Initiative, "construction", Guid.NewGuid()).Value;
+ foreach (var (type, @ref) in items) a.SetItem(type, null, @ref, null);
+ return a;
+ }
+
+ private static GovPhaseArtifactDto Declarado(string kind, bool required = true) =>
+ new() { ArtifactKind = kind, Label = kind.ToUpperInvariant(), Required = required };
+
+ private static Task Consultar(
+ DomainPhaseArtifact? expediente, IPhaseArtifactProfileSource profiles) =>
+ new GetPhaseArtifactQueryHandler(new FakeRepo(expediente), profiles)
+ .Handle(new GetPhaseArtifactQuery(Tenant, Initiative, "construction"), CancellationToken.None);
+
+ [Fact]
+ public async Task DiceLasDosDirecciones_LoQueFaltaYLoQueSobra()
+ {
+ // La razón de que existan las dos: reportar solo lo declarado haría que un expediente
+ // lleno de items fuera de estándar pareciera completo.
+ var dto = await Consultar(
+ Expediente(("coverage-report", "cov.xml"), ("invento-local", "x.md")),
+ new FakeProfiles(PhaseArtifactProfileSourceKind.CoreSync,
+ new[] { Declarado("coverage-report"), Declarado("integration-evidence") }));
+
+ var cat = dto!.CoreCatalog!;
+ cat.Artifacts.Single(a => a.ArtifactKind == "coverage-report").Present.Should().BeTrue();
+ cat.Artifacts.Single(a => a.ArtifactKind == "integration-evidence").Present.Should().BeFalse();
+ cat.UnknownItemKinds.Should().ContainSingle().Which.Should().Be("invento-local");
+ }
+
+ [Fact]
+ public async Task REPORTA_PERO_NO_IMPONE()
+ {
+ // T-056. Un artefacto requerido ausente NO puede cambiar el estado del expediente ni
+ // hacer fallar la consulta: qué exige una compuerta lo configura el tenant, no lo
+ // cablea el motor. Si esto se rompe, el producto empezó a opinar por su cuenta.
+ var dto = await Consultar(
+ Expediente(),
+ new FakeProfiles(PhaseArtifactProfileSourceKind.CoreSync, new[] { Declarado("coverage-report") }));
+
+ dto.Should().NotBeNull();
+ dto!.Status.Should().Be(PhaseArtifactStatus.Draft);
+ dto.CoreCatalog!.Artifacts.Should().OnlyContain(a => !a.Present);
+ }
+
+ [Fact]
+ public async Task SinCatalogoNoEsCatalogoVacio()
+ {
+ // Las dos ausencias son distintas y la diferencia importa: «el estándar no estaba
+ // disponible» no es «el estándar no pide nada aquí». La segunda es la clase de vacío
+ // confiado que una superficie de gobierno no debe producir.
+ var sinLaFase = new FakeProfilesOtraFase();
+ var dto = await Consultar(Expediente(("coverage-report", "cov.xml")), sinLaFase);
+
+ dto!.CoreCatalog.Should().BeNull();
+ dto.Items.Should().ContainSingle("el expediente se sigue sirviendo entero");
+ }
+
+ private sealed class FakeProfilesOtraFase : IPhaseArtifactProfileSource
+ {
+ public string Source => PhaseArtifactProfileSourceKind.CoreSync;
+ public IReadOnlyList GetProfiles() => new List
+ {
+ new() { Phase = "qa", Source = PhaseArtifactProfileSourceKind.CoreSync },
+ };
+ }
+
+ [Fact]
+ public async Task UnaDiferenciaDeMayusculasNoInventaUnHueco()
+ {
+ // El catálogo escribe slugs; el expediente acepta lo que le manden. Un cotejo sensible
+ // a mayúsculas reportaría como ausente un artefacto que está — un hueco falso, que en
+ // gobierno es peor que no cotejar.
+ var dto = await Consultar(
+ Expediente(("Coverage-Report", "cov.xml")),
+ new FakeProfiles(PhaseArtifactProfileSourceKind.CoreSync, new[] { Declarado("coverage-report") }));
+
+ dto!.CoreCatalog!.Artifacts.Single().Present.Should().BeTrue();
+ dto.CoreCatalog.UnknownItemKinds.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task LaProcedenciaViajaEnElPayload()
+ {
+ // Un lector tiene que poder distinguir una respuesta sincronizada de una espejada
+ // leyendo el payload, no confiando en que hubo sincronización.
+ var dto = await Consultar(
+ Expediente(),
+ new FakeProfiles(PhaseArtifactProfileSourceKind.StandIn, new[] { Declarado("coverage-report") }));
+
+ dto!.CoreCatalog!.Source.Should().Be(PhaseArtifactProfileSourceKind.StandIn);
+ }
+}