diff --git a/docs/artifacts/CORE_ARTIFACT_SCHEMAS.es.md b/docs/artifacts/CORE_ARTIFACT_SCHEMAS.es.md
index 212eb420..112c86fd 100644
--- a/docs/artifacts/CORE_ARTIFACT_SCHEMAS.es.md
+++ b/docs/artifacts/CORE_ARTIFACT_SCHEMAS.es.md
@@ -63,6 +63,43 @@ Tres filas merecen atención en vez de un mapeo silencioso:
catálogo del Tracker**. Se listan igualmente, porque quien los busque debe enterarse de que
existen en vez de concluir que no.
+## Cómo lo consume el Tracker
+
+Hasta el 2026-08-02 este documento *era* todo GAP-020: una persona podía encontrar la forma
+canónica y ninguna máquina podía. La ficha se reabrió por dirección de producto exactamente por eso
+— un satélite que sólo documenta el enlace no lo ha consumido.
+
+La correspondencia vive ahora como **dato** en
+`Tracker.Application/Governance/ArtifactFieldSchema/CoreArtifactSchemaCatalog.cs`, y cada
+`PhaseArtifactDto` que sirve `GET /api/phase-artifact-profiles` lleva su `canonicalSchema`:
+
+```json
+{ "artifactKind": "prd", "label": "PRD", "required": true,
+ "canonicalSchema": { "schemaId": "https://evolith.dev/schema/prd.schema.json",
+ "provenance": "core-schema" } }
+```
+
+`provenance` tiene tres valores y no un id anulable, porque dos de ellos son *ausencias distintas*:
+
+| valor | qué significa | qué hacer |
+|---|---|---|
+| `core-schema` | el Core publica una forma canónica | seguir el `$id` |
+| `tool-output` | el Core no publica ninguna, a propósito | leer el formato propio de la herramienta |
+| `none` | nada upstream lo cubre | el `ArtifactFieldSchema` del tenant es la única autoridad |
+
+Juntar `tool-output` y `none` en un `null` borraría la diferencia entre *un hueco que alguien
+debería cerrar* y *una decisión ya tomada*.
+
+La tabla de este documento y la de esa clase se mantienen idénticas mediante
+`CoreArtifactSchemaCatalogTests`, que parsea el markdown de arriba y compara en los dos sentidos:
+falla una fila de aquí que nadie cableó, y falla también una entrada de allí que nadie documentó.
+Se comprobó rompiéndolo en ambas direcciones, no se supuso.
+
+**Sigue siendo un puntero y no una restricción.** Nada en el Tracker valida el contenido de un
+artefacto contra estos esquemas: según [`T-056`](../adrs/T-056-three-layer-separation.md) eso es
+configuración del tenant y no código del motor. La referencia se ofrece a quien rellena el
+artefacto.
+
## Los artefactos que no tienen esquema, y por qué
Diez de los veinticuatro no están cubiertos, y se dividen en dos grupos que piden respuestas
diff --git a/docs/artifacts/CORE_ARTIFACT_SCHEMAS.md b/docs/artifacts/CORE_ARTIFACT_SCHEMAS.md
index bd18c873..1b747e18 100644
--- a/docs/artifacts/CORE_ARTIFACT_SCHEMAS.md
+++ b/docs/artifacts/CORE_ARTIFACT_SCHEMAS.md
@@ -61,6 +61,42 @@ Three rows deserve attention rather than a silent mapping:
Tracker's catalog**. They are listed anyway, because a reader looking for them should find out
they exist rather than conclude they do not.
+## How the Tracker consumes this
+
+Until 2026-08-02 this document *was* the whole of GAP-020: a reader could find the canonical shape,
+and no machine could. The row was reopened by product direction for exactly that reason — a
+satellite that only documents the link has not consumed it.
+
+The correspondence now lives as **data** in
+`Tracker.Application/Governance/ArtifactFieldSchema/CoreArtifactSchemaCatalog.cs`, and every
+`PhaseArtifactDto` served by `GET /api/phase-artifact-profiles` carries a `canonicalSchema`:
+
+```json
+{ "artifactKind": "prd", "label": "PRD", "required": true,
+ "canonicalSchema": { "schemaId": "https://evolith.dev/schema/prd.schema.json",
+ "provenance": "core-schema" } }
+```
+
+`provenance` has three values and not a nullable id, because two of them are *different absences*:
+
+| value | meaning | what a reader should do |
+|---|---|---|
+| `core-schema` | the Core publishes a canonical shape | follow the `$id` |
+| `tool-output` | the Core deliberately publishes none | read the tool's own format |
+| `none` | nothing upstream covers it | the tenant's `ArtifactFieldSchema` is the only authority |
+
+Collapsing `tool-output` and `none` into `null` would erase the distinction between *a gap someone
+should close* and *a decision already made*.
+
+The table in this document and the table in that class are kept identical by
+`CoreArtifactSchemaCatalogTests`, which parses the markdown above and compares both ways — a row
+here that nobody wired fails, and an entry there that nobody documented fails too. This was
+verified by breaking it in both directions, not assumed.
+
+**It remains a pointer and not a constraint.** Nothing in the Tracker validates artifact content
+against these schemas: per [`T-056`](../adrs/T-056-three-layer-separation.md) that is the tenant's
+configuration, not engine code. The reference is offered to whoever fills the artifact in.
+
## The artifacts that have no schema, and why
Ten of the twenty-four are not covered, and they split into two groups that call for different
diff --git a/docs/audit/tracker-gap-reference-catalog.md b/docs/audit/tracker-gap-reference-catalog.md
index 23fab29b..611ee0fb 100644
--- a/docs/audit/tracker-gap-reference-catalog.md
+++ b/docs/audit/tracker-gap-reference-catalog.md
@@ -2947,6 +2947,7 @@ Corregidas las **29 menciones** distinguiendo dos clases que no son lo mismo: la
- **Resolution / Next step:** Consumir desde el Core las URLs o referencias canónicas de schema de cada artefacto, registrarlas en el catálogo tenant-aware del Tracker y usarlas como base para validar los formatos expuestos y llenados.
- **Status:** `PENDING` (2026-08-01) — reabierta por dirección de producto: aunque el Core define la autoridad de los schemas, el Tracker debe materializar su consumo para operar el flujo SDLC completo.
- **Triaje 2026-08-01 — PENDIENTE.** Las URLs de los esquemas de artefacto siguen siendo autoridad del Core; el Tracker no las inventa ni las redefine. El trabajo pendiente en este repositorio es consumirlas, persistir o cachear sus referencias de forma tenant-aware, exponerlas en los formatos de fase y usarlas para validar los artefactos que personas o agentes llenan antes de la evaluación del Core.
+- **Avance 2026-08-02 (la ficha sigue PENDIENTE).** Consumo hecho: mapa como dato en `CoreArtifactSchemaCatalog`, `canonicalSchema` en cada artefacto de `/api/phase-artifact-profiles`, enlace en la pantalla de campos y prueba anti-deriva en los dos sentidos contra `docs/artifacts/CORE_ARTIFACT_SCHEMAS.md`. Queda la persistencia/caché tenant-aware de las referencias y —sujeto a decisión de producto por T-056— usarlas para validar lo que se rellena.
#### GAP-021
diff --git a/docs/audit/tracker-gaps-opportunities-tracking.md b/docs/audit/tracker-gaps-opportunities-tracking.md
index 6ddaba52..c27a1f15 100644
--- a/docs/audit/tracker-gaps-opportunities-tracking.md
+++ b/docs/audit/tracker-gaps-opportunities-tracking.md
@@ -25,7 +25,7 @@ This document is the only operational gap register in this repository. The maste
| # | Status | ID | Type | Category | Component | Module | Story(ies) | Description | Resolution / Next Step | Criticality | Complexity |
|---:|---|---|---|---|---|---|---|---|---|:---:|:---:|
-| 1 | 🟡 OPEN | [GAP-020](#detail-gap-020) | Docs | Documentation gap | Docs | Docs | N/A | Artifact Schema URLs from Core Missing | REOPENED — this is a correction of my own closure earlier today. I closed it on the strength of `docs/artifacts/CORE_ARTIFACT_SCHEMAS.md`, which links each artifact to its canonical `$id`. The catalog then reopened it the same day by product direction, and the reason is not satisfied by that document: the Tracker must CONSUME the Core schema references — persist or cache them tenant-aware, expose them in the phase forms, and validate what people and agents fill in before the Core evaluates. Documenting the links is a prerequisite, not the deliverable. Aligned to the catalog rather than argued with. | 🟡 MEDIUM | 🟡 MEDIUM |
+| 1 | 🟡 OPEN | [GAP-020](#detail-gap-020) | Docs | Documentation gap | Docs | Docs | N/A | Artifact Schema URLs from Core Missing | NARROWED 2026-08-02, not closed. The reopen asked for FOUR things and two are done. **Done:** the references are consumed as data — `CoreArtifactSchemaCatalog` holds the correspondence, every artifact in `GET /api/phase-artifact-profiles` carries a `canonicalSchema` with the published `$id`, and the artifact-fields screen renders it as a link. `provenance` separates `core-schema` from `tool-output` (the Core publishes none on purpose) and from `none` (nothing upstream), because those are different absences. The mapping is a written decision — a slug matcher would miss `security-scan-result` ⇄ Security Scan Report and two others — so a test compares the code against `CORE_ARTIFACT_SCHEMAS.md` in both directions, verified by breaking it both ways. **Still open:** (1) tenant-aware persistence or caching of the references — today the map is static in code, which is arguably right since the references are Core-authoritative and tenant-agnostic, but that is a judgement the row has not ratified; (2) using the schemas to validate what people and agents fill in. Point 2 collides with T-056 — content validation is the tenant's configuration, not engine code — so it needs a product decision before anyone builds it, and this row must not read as done while it is unresolved. | 🟡 MEDIUM | 🟡 MEDIUM |
| 2 | 🟡⏳ DEFERRED | [OPP-002](#detail-opp-002) | OPP | Improvement opportunity | Backend | N/A | N/A | Extraer AuditTrail como Shared Kernel — 5+ contextos implementan historiales inmutables | Pending owner/action definition in this register. | 🟠 HIGH | 🔴 HIGH |
| 3 | 🟢 RESOLVED | [GAP-023](#detail-gap-023) | Docs | Documentation gap | Docs | Docs | N/A | Re-Do Flow Not Fully Designed | RESOLVED — stale row; the evidence was in the catalog and never reached this register. `tracker-gap-reference-catalog.md` closed it on 2026-07-20 with «YA HECHA» and named the artifact. Re-verified here rather than taken on trust: `reference/specs/design/tracker-redo-flow-design.md` is 254 lines with trigger conditions, the state model, the domain model inside the Release context, the recalculation algorithm, impact propagation, the human-authorization gate, domain events and a traceability table. Its §10 lists five scheduling decisions (RD-D1…RD-D5) explicitly registered as proposals awaiting PO ratification — that is a decision pending an owner, not a design that is missing. | 🟡 MEDIUM | 🟡 MEDIUM |
| 4 | 🟢 RESOLVED | [GAP-025](#detail-gap-025) | Docs | Documentation gap | Docs | Docs | N/A | Portal DDD incomplete — NARROWED 2026-08-01: the strategic-map half is DONE (`reference/specs/architecture/bounded-context-map.md` calls itself «the single, authoritative strategic map» of the 9 contexts, with integration patterns and cross-context events). What remains is the four support contexts. | RESOLVED — stale row, same shape. The catalog closed it on 2026-07-19; this register kept the narrowed half open. Verified directly: `bounded-context-map.md` §2 classifies all nine contexts and names the four supporting ones — Governance, Artifacts, Metrics, Integration — each linked to its own tactical model, and all ten `ddd-model.md` files exist on disk. Both halves of the row are satisfied. | 🟡 MEDIUM | 🟡 MEDIUM |
diff --git a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/CoreArtifactSchemaCatalog.cs b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/CoreArtifactSchemaCatalog.cs
new file mode 100644
index 00000000..426a4616
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/CoreArtifactSchemaCatalog.cs
@@ -0,0 +1,128 @@
+namespace Tracker.Application.Governance.ArtifactFieldSchema;
+
+///
+/// Where an artifact's canonical shape comes from — or why it has none. Three states, not a
+/// nullable string: «no schema because the Core publishes none» and «no schema because it is a
+/// tool's own output» call for opposite answers from a consumer, and collapsing them into `null`
+/// makes the difference unrecoverable at the point where it matters.
+///
+public static class ArtifactSchemaProvenance
+{
+ /// Evolith Core publishes a canonical JSON Schema for this artifact.
+ public const string CoreSchema = "core-schema";
+
+ ///
+ /// The artifact IS a tool's native output (CI run record, coverage report). The Core declares
+ /// producedBy rather than a schema on purpose: a bespoke shape would restate what the
+ /// tool already publishes and rot the day the tool changes.
+ ///
+ public const string ToolOutput = "tool-output";
+
+ ///
+ /// Nothing upstream. The shape is whatever the tenant's ArtifactFieldSchema says, and
+ /// per T-056 nothing here contradicts it.
+ ///
+ public const string None = "none";
+}
+
+/// The canonical shape of an artifact, as far as the Core publishes one.
+public sealed record ArtifactCanonicalSchemaDto
+{
+ ///
+ /// The schema's published $id, or null when there is none. It is an `$id` and not
+ /// a repository path deliberately: a path is a fact about where a file sits in one repository
+ /// at one moment, while the `$id` is the schema's own identity and survives the Core
+ /// reorganising its tree. Those paths were in fact broken until `evolith_arch32#378`.
+ ///
+ public string? SchemaId { get; init; }
+
+ /// See .
+ public string Provenance { get; init; } = ArtifactSchemaProvenance.None;
+}
+
+///
+/// The correspondence between a Tracker artifact kind and the canonical schema Evolith Core
+/// publishes for it (GAP-020).
+///
+/// THIS IS A WRITTEN DECISION, NOT A TRANSFORMATION. The two vocabularies agree for some
+/// artifacts (prd, release-notes, test-summary-report) and diverge for others:
+/// security-scan-result is the Core's *Security Scan Report*, rollback-plan is its
+/// *Rollback Procedure*, observability-readiness is its *Observability Validation*. A
+/// mapper that matched by slug would silently miss all three. Each entry below is a judgement
+/// someone made and can be argued with; none of it is derived.
+///
+/// The prose version, with the reasoning for every row, is
+/// docs/artifacts/CORE_ARTIFACT_SCHEMAS.md. That document and this table are kept identical
+/// by CoreArtifactSchemaCatalogTests, which parses the markdown and compares — because a
+/// decision recorded in two places drifts, and this repository has spent the week finding out how
+/// quietly.
+///
+/// WHAT THIS DOES NOT DO. It does not make the Core the authority over what a tenant may
+/// record. Per T-056 content validation is the tenant's configuration and not engine code:
+/// the custom fields a tenant adds through ArtifactFieldSchema stay theirs, and this
+/// reference is offered to whoever fills an artifact in — never enforced against them here.
+///
+public static class CoreArtifactSchemaCatalog
+{
+ private const string Base = "https://evolith.dev/schema/";
+
+ ///
+ /// Tracker kind → published $id. Ten entries: the Core publishes fourteen schemas, but
+ /// four of them — Discovery Canvas, Ballpark Estimation, Integration Evidence and On-Call
+ /// Handoff — have no kind in and therefore nothing here to
+ /// hang off. They are listed in the markdown so a reader looking for them finds out they exist.
+ ///
+ private static readonly IReadOnlyDictionary SchemaIdByKind =
+ new Dictionary
+ {
+ ["prd"] = Base + "prd.schema.json",
+ ["technical-feasibility-canvas"] = Base + "technical-feasibility.schema.json",
+ ["build-versus-compose-analysis"] = Base + "build-vs-compose.schema.json",
+ ["bounded-context-map"] = Base + "bounded-context-map.schema.json",
+ ["definition-of-done-checklist"] = Base + "definition-of-done-checklist.schema.json",
+ ["test-summary-report"] = Base + "test-summary-report.schema.json",
+ ["security-scan-result"] = Base + "security-scan-report.schema.json",
+ ["release-notes"] = Base + "release-notes.schema.json",
+ ["observability-readiness"] = Base + "observability-validation.schema.json",
+ ["rollback-plan"] = Base + "rollback-rehearsal.schema.json",
+ };
+
+ ///
+ /// Kinds whose content IS a tool's own output. The Core's third such artifact — Pyramid
+ /// Distribution — has no Tracker kind.
+ ///
+ private static readonly IReadOnlySet ToolOutputKinds =
+ new HashSet { "ci-pipeline-result", "coverage-report" };
+
+ ///
+ /// Note on adr-registry: it is NOT wired to adr.schema.json even though the names
+ /// invite it. A registry is a *list* of ADRs and not an ADR, so the mapping would produce a
+ /// validation that fails against the real artifact — coverage on paper, a false negative in
+ /// practice.
+ ///
+ public static ArtifactCanonicalSchemaDto For(string artifactKind)
+ {
+ if (SchemaIdByKind.TryGetValue(artifactKind, out var id))
+ {
+ return new ArtifactCanonicalSchemaDto
+ {
+ SchemaId = id,
+ Provenance = ArtifactSchemaProvenance.CoreSchema,
+ };
+ }
+
+ return new ArtifactCanonicalSchemaDto
+ {
+ SchemaId = null,
+ Provenance = ToolOutputKinds.Contains(artifactKind)
+ ? ArtifactSchemaProvenance.ToolOutput
+ : ArtifactSchemaProvenance.None,
+ };
+ }
+
+ /// The mapped kinds, for guards that need to compare the table against its source.
+ public static IReadOnlyDictionary Mappings => SchemaIdByKind;
+
+ /// The tool-output kinds, same purpose.
+ public static IReadOnlySet ToolOutputs => ToolOutputKinds;
+}
diff --git a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/IPhaseArtifactProfileSource.cs b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/IPhaseArtifactProfileSource.cs
index 878c3830..cbd72f85 100644
--- a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/IPhaseArtifactProfileSource.cs
+++ b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/IPhaseArtifactProfileSource.cs
@@ -51,6 +51,9 @@ public IReadOnlyList GetProfiles() =>
ArtifactKind = a.ArtifactKind,
Label = a.Label,
Required = a.Required,
+ // GAP-020: el perfil ya no dice sólo QUE hace falta un artefacto, sino
+ // dónde está su forma canónica — o por qué no la tiene.
+ CanonicalSchema = CoreArtifactSchemaCatalog.For(a.ArtifactKind),
})
.ToList(),
})
diff --git a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs
index 88d3fd43..92a92999 100644
--- a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs
+++ b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs
@@ -7,13 +7,14 @@ namespace Tracker.Application.Governance.ArtifactFieldSchema;
/// the tenant configures custom fields per artifact (via ArtifactFieldSchema keyed by the
/// artifact kind) and — for optional artifacts — their applicability.
///
-/// GAP-020 — este catálogo dice que un `prd` es obligatorio y NO dice qué debe contener. La
-/// forma canónica, para los 10 de 24 artefactos que la tienen aguas arriba, está enlazada en
-/// docs/artifacts/CORE_ARTIFACT_SCHEMAS.md. No se enlaza desde aquí como dato porque los
-/// nombres no coinciden mecánicamente (`security-scan-result` ⇄ Security Scan Report,
-/// `rollback-plan` ⇄ Rollback Procedure) y la correspondencia es una decisión escrita, no una
-/// transformación de cadena — y porque el `schemaRef` que publican los gates del Core es una ruta
-/// relativa ROTA: resuelve a un directorio que no existe.
+/// GAP-020 — este catálogo dice que un `prd` es obligatorio; QUÉ debe contener lo dice
+/// ahora , que cuelga de cada artefacto el `$id` del esquema
+/// canónico publicado por el Core, o la razón de que no lo tenga. La correspondencia sigue siendo una
+/// DECISIÓN ESCRITA y no una transformación de cadena — `security-scan-result` ⇄ Security Scan Report,
+/// `rollback-plan` ⇄ Rollback Procedure — y por eso vive en una tabla explícita contrastada contra
+/// `docs/artifacts/CORE_ARTIFACT_SCHEMAS.md`. Se enlaza por `$id` y no por ruta: las rutas relativas
+/// que publicaban las compuertas del Core estaban rotas hasta `evolith_arch32#378`, y una ruta es un
+/// hecho sobre dónde está un fichero hoy, mientras que el `$id` es la identidad del esquema.
///
public static class PhaseArtifactCatalog
{
@@ -78,6 +79,15 @@ public sealed class PhaseArtifactDto
public string ArtifactKind { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty;
public bool Required { get; init; }
+
+ ///
+ /// The canonical shape the Core publishes for this artifact, or the reason there is none
+ /// (GAP-020). Until this field existed a consumer learned that a `prd` is required in discovery
+ /// and had no way to find out what a PRD is supposed to contain. See
+ /// — and note that it is a POINTER, not a constraint:
+ /// per T-056 the tenant's ArtifactFieldSchema stays the authority over content.
+ ///
+ public ArtifactCanonicalSchemaDto CanonicalSchema { get; init; } = new();
}
public sealed class PhaseArtifactProfileDto
diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Governance/CoreArtifactSchemaCatalogTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Governance/CoreArtifactSchemaCatalogTests.cs
new file mode 100644
index 00000000..3a150466
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Tests/Application/Governance/CoreArtifactSchemaCatalogTests.cs
@@ -0,0 +1,145 @@
+using System.Text.RegularExpressions;
+using Tracker.Application.Governance.ArtifactFieldSchema;
+
+namespace Tracker.Tests.Application.Governance;
+
+///
+/// Contrasta la tabla de con su fuente en prosa,
+/// docs/artifacts/CORE_ARTIFACT_SCHEMAS.md.
+///
+/// POR QUÉ. La correspondencia entre los tipos del Tracker y los artefactos del Core es una
+/// decisión escrita, y las decisiones escritas en dos sitios se separan. Esta semana el repositorio
+/// encontró la misma avería cuatro veces: dos tableros de gaps que se contradecían en 5 de 34
+/// identificadores, y dos ficheros marcados «traducidos» que eran copias del español. En todos los
+/// casos había una comprobación verde al lado que miraba otra cosa.
+///
+/// Estas pruebas fallan en las DOS direcciones: una fila en el markdown que nadie cableó, y
+/// una entrada en el código que nadie documentó. Una guarda que sólo mira un sentido deja el otro
+/// como sitio cómodo donde esconder cosas.
+///
+public class CoreArtifactSchemaCatalogTests
+{
+ private const string Doc = "docs/artifacts/CORE_ARTIFACT_SCHEMAS.md";
+
+ ///
+ /// Filas de la tabla con tipo del Tracker. Las que dicen (no catalog kind) se saltan a
+ /// propósito: son artefactos con esquema en el Core y sin tipo aquí, y están en el documento
+ /// para que quien los busque descubra que existen.
+ ///
+ private static Dictionary FilasDelDocumento()
+ {
+ var texto = File.ReadAllText(Path.Combine(RaizDelRepositorio(), Doc));
+ var filas = new Dictionary();
+
+ // | fase | `tipo` | Nombre en el Core | [`fichero`](https://evolith.dev/schema/fichero) |
+ var patron = new Regex(
+ @"^\|\s*\w+\s*\|\s*`([a-z0-9-]+)`\s*\|[^|]*\|\s*\[[^\]]*\]\((https://evolith\.dev/schema/[^)]+)\)\s*\|",
+ RegexOptions.Multiline);
+
+ foreach (System.Text.RegularExpressions.Match m in patron.Matches(texto))
+ {
+ filas[m.Groups[1].Value] = m.Groups[2].Value;
+ }
+
+ return filas;
+ }
+
+ [Fact]
+ public void LaTablaDelCodigoYLaDelDocumentoDicenLoMismo()
+ {
+ var doc = FilasDelDocumento();
+
+ // Una guarda que recorre cero filas pasa siempre. Si el formato del documento cambia y el
+ // patrón deja de encajar, esto lo dice en vez de dar un verde vacío.
+ doc.Should().HaveCount(10,
+ "el documento publica catorce esquemas y diez de ellos tienen tipo en el Tracker; "
+ + "si este número cambia hay que decidir el mapeo, no ajustar la prueba");
+
+ CoreArtifactSchemaCatalog.Mappings.Should().BeEquivalentTo(doc,
+ "la correspondencia es una decisión escrita y vive en dos sitios: el documento explica "
+ + "el porqué de cada fila y el código la aplica. En cuanto discrepan, uno de los dos "
+ + "miente y nadie sabe cuál");
+ }
+
+ [Fact]
+ public void LosTiposMapeadosExistenEnElCatalogoDeFases()
+ {
+ var conocidos = PhaseArtifactCatalog.ByPhase.Values
+ .SelectMany(a => a)
+ .Select(a => a.ArtifactKind)
+ .ToHashSet();
+
+ foreach (var tipo in CoreArtifactSchemaCatalog.Mappings.Keys.Concat(CoreArtifactSchemaCatalog.ToolOutputs))
+ {
+ conocidos.Should().Contain(tipo,
+ $"`{tipo}` tiene esquema o procedencia declarada pero ningún artefacto lo pide: "
+ + "es un mapeo a la nada, y se lee como cobertura");
+ }
+ }
+
+ [Fact]
+ public void ElRegistroDeAdrNoSeMapeaAlEsquemaDeUnAdr()
+ {
+ // Los nombres invitan al atajo. Un registro es una LISTA de ADRs y no un ADR, así que el
+ // mapeo daría una validación que falla contra el artefacto real: cobertura sobre el papel
+ // y un falso negativo en la práctica.
+ CoreArtifactSchemaCatalog.For("adr-registry").Provenance
+ .Should().Be(ArtifactSchemaProvenance.None);
+ }
+
+ [Fact]
+ public void SinEsquemaYSalidaDeHerramientaNoSeConfunden()
+ {
+ // Son dos ausencias distintas y piden respuestas opuestas: a una le falta un esquema que
+ // habría que escribir; la otra NO debe tenerlo nunca. Colapsarlas en `null` perdería eso.
+ CoreArtifactSchemaCatalog.For("ci-pipeline-result").Provenance
+ .Should().Be(ArtifactSchemaProvenance.ToolOutput);
+ CoreArtifactSchemaCatalog.For("coverage-report").Provenance
+ .Should().Be(ArtifactSchemaProvenance.ToolOutput);
+ CoreArtifactSchemaCatalog.For("business-case").Provenance
+ .Should().Be(ArtifactSchemaProvenance.None);
+
+ CoreArtifactSchemaCatalog.For("ci-pipeline-result").SchemaId.Should().BeNull();
+ CoreArtifactSchemaCatalog.For("business-case").SchemaId.Should().BeNull();
+ }
+
+ [Fact]
+ public void LosTresNombresQueNoCoincidenEstanCableadosAMano()
+ {
+ // Un mapeador por slug fallaría estos tres, y fallaría en silencio.
+ CoreArtifactSchemaCatalog.For("security-scan-result").SchemaId
+ .Should().EndWith("security-scan-report.schema.json");
+ CoreArtifactSchemaCatalog.For("rollback-plan").SchemaId
+ .Should().EndWith("rollback-rehearsal.schema.json");
+ CoreArtifactSchemaCatalog.For("observability-readiness").SchemaId
+ .Should().EndWith("observability-validation.schema.json");
+ }
+
+ [Fact]
+ public void ElPerfilQueSeSirveLlevaLaReferencia()
+ {
+ var perfiles = new StandInPhaseArtifactProfileSource().GetProfiles();
+
+ var prd = perfiles.Single(p => p.Phase == "discovery")
+ .Artifacts.Single(a => a.ArtifactKind == "prd");
+
+ prd.CanonicalSchema.Provenance.Should().Be(ArtifactSchemaProvenance.CoreSchema);
+ prd.CanonicalSchema.SchemaId.Should().Be("https://evolith.dev/schema/prd.schema.json");
+
+ // Y el que no tiene esquema lo dice, en vez de venir vacío y parecer un fallo de carga.
+ var caso = perfiles.Single(p => p.Phase == "discovery")
+ .Artifacts.Single(a => a.ArtifactKind == "business-case");
+ caso.CanonicalSchema.Provenance.Should().Be(ArtifactSchemaProvenance.None);
+ }
+
+ private static string RaizDelRepositorio()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git")))
+ {
+ dir = dir.Parent;
+ }
+ dir.Should().NotBeNull("la prueba necesita ubicar la raiz del repositorio");
+ return dir!.FullName;
+ }
+}
diff --git a/src/apps/tracker-web/src/api/types.ts b/src/apps/tracker-web/src/api/types.ts
index 0cfba37e..5613a901 100644
--- a/src/apps/tracker-web/src/api/types.ts
+++ b/src/apps/tracker-web/src/api/types.ts
@@ -255,11 +255,34 @@ export interface UpsertArtifactFieldSchemaRequest {
customFields: CustomFieldDto[];
}
+/**
+ * Where an artifact's canonical shape comes from — or why it has none (GAP-020).
+ *
+ * Three states rather than a nullable id, because two of them are different absences and call for
+ * opposite answers: `tool-output` means the Core deliberately publishes NO schema (the artifact is
+ * a CI run record or a coverage report, and a bespoke shape would restate what the tool already
+ * emits), while `none` means nothing upstream covers it and the tenant's own field schema is the
+ * only authority. Collapsing both into `null` would make the difference unrecoverable in the UI.
+ */
+export type ArtifactSchemaProvenance = 'core-schema' | 'tool-output' | 'none';
+
+export interface ArtifactCanonicalSchemaDto {
+ /** The schema's published `$id` — not a repository path, which would break when Core moves it. */
+ schemaId?: string | null;
+ provenance: ArtifactSchemaProvenance | string;
+}
+
/** One Core-authoritative base artifact within a phase (read-only for the tenant). */
export interface PhaseArtifactDto {
artifactKind: string;
label: string;
required: boolean;
+ /**
+ * The canonical shape Core publishes for this artifact (GAP-020). A pointer, never a constraint:
+ * per T-056 the tenant's custom fields stay the authority over content. Absent on legacy
+ * responses.
+ */
+ canonicalSchema?: ArtifactCanonicalSchemaDto | null;
}
/** GET /api/phase-artifact-profiles — Core base artifacts per phase (PRD required in Discovery, …). */
diff --git a/src/apps/tracker-web/src/components/screens/md3/FieldSchemaManager.tsx b/src/apps/tracker-web/src/components/screens/md3/FieldSchemaManager.tsx
index be4a9f15..1f9fb668 100644
--- a/src/apps/tracker-web/src/components/screens/md3/FieldSchemaManager.tsx
+++ b/src/apps/tracker-web/src/components/screens/md3/FieldSchemaManager.tsx
@@ -12,7 +12,7 @@ import {
usePhaseArtifactProfiles,
useUpsertArtifactFieldSchema,
} from '../../../api';
-import type { CustomFieldDto } from '../../../api';
+import type { ArtifactCanonicalSchemaDto, CustomFieldDto } from '../../../api';
import {
Button,
Card,
@@ -37,10 +37,55 @@ const PHASE_LABEL: Record = {
};
/** Per-artifact custom-field editor. The artifact itself is Core-defined; only fields are editable. */
-const ArtifactFieldCard: React.FC<{ artifactKind: string; label: string; required: boolean }> = ({
+/**
+ * El puntero a la forma canonica de un artefacto (GAP-020). Las tres procedencias se muestran
+ * distinto A PROPOSITO: un esquema publicado se puede abrir, una salida de herramienta NO tiene
+ * esquema por decision del Core, y `none` es un hueco de verdad. Un guion para los tres casos
+ * ocultaria justo la informacion que hace falta para saber si falta trabajo o no.
+ */
+const CanonicalSchemaHint: React.FC<{ schema?: ArtifactCanonicalSchemaDto | null }> = ({ schema }) => {
+ const { t } = useTranslation();
+ if (!schema) return null;
+
+ if (schema.provenance === 'core-schema' && schema.schemaId) {
+ return (
+
+ );
+ }
+
+ const texto =
+ schema.provenance === 'tool-output'
+ ? t('shape defined by the emitting tool', 'forma definida por la herramienta que lo emite')
+ : t('no upstream schema — your fields define it', 'sin esquema upstream — lo definen tus campos');
+
+ return (
+
+ {texto}
+
+ );
+};
+
+const ArtifactFieldCard: React.FC<{
+ artifactKind: string;
+ label: string;
+ required: boolean;
+ canonicalSchema?: ArtifactCanonicalSchemaDto | null;
+}> = ({
artifactKind,
label,
required,
+ canonicalSchema,
}) => {
const { t } = useTranslation();
const query = useArtifactFieldSchema(artifactKind);
@@ -115,6 +160,11 @@ const ArtifactFieldCard: React.FC<{ artifactKind: string; label: string; require
{artifactKind}
{count > 0 ? ` · ${count} ${count === 1 ? t('custom field', 'campo personalizado') : t('custom fields', 'campos personalizados')}` : ''}
+ {/* GAP-020 — hasta aqui la tarjeta decia que un artefacto hace falta y no QUE debe
+ contener. El enlace apunta a la forma canonica publicada por el Core; es una
+ referencia y no una restriccion (T-056), por eso se ofrece y no se impone. Las dos
+ ausencias se distinguen: `tool-output` es deliberada, `none` es un hueco. */}
+