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
2 changes: 1 addition & 1 deletion docs/audit/tracker-gap-reference-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an
- **Acceptance criteria:**
- [ ] Evidence is a first-class immutable entity with lineage/integrity, referenced by gate decisions.
- **Dependencies:** [CR-13](#cr-13), [CR-15](#cr-15).
- **Status:** `IN-PROGRESS` — `EvidenceRecord` es un agregado persistido (Create/Verify/Reject + hash); falta el Evidence Graph (aristas de linaje + integridad/clasificación/retención).
- **Status:** `DONE` — `EvidenceRecord` como nodo de Evidence Graph: procedencia/integridad de primera clase (sourceProvider/producer/contentHash/classification/retentionPolicyRef/immutable), aristas `references[]` vía `AddReference` (dedup) + `Seal`, endpoint `POST /{id}/references`, migración aditiva `AddEvidenceGraphFields`; 458 tests.

#### CR-15

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 @@ -27,7 +27,7 @@ This board is the single source of truth for Tracker technical debt, gaps, oppor
| [`CR-21`](./tracker-gap-reference-catalog.md#cr-21) | Las 5 pantallas canónicas están ruteadas y accesibles (Gate Decision/Evaluation = `GateEvaluationMd3` ya existe — la nota "pendiente" estaba desactualizada); único faltante: un **browser de ADRs** dedicado (hoy los ADRs son solo tags en blueprints). | `WEB` | Cross | P1 | S | `IN-PROGRESS` |
| [`GAP-010`](./tracker-gap-reference-catalog.md#gap-010) | **Hecho:** existe `reference/knowledge/domain/PRODUCT_VISION.md` (EN) con navegación bilingüe a `PRODUCT_VISION.es.md`. | `Docs` | Cross | P1 | M | `DONE` |
| [`CR-10`](./tracker-gap-reference-catalog.md#cr-10) | Pantalla + endpoint de navegación de arquitectura (independiente del SDLC) existen para topologías/blueprints/evidencia/providers; faltan registro/browser de ADRs (estado/supersesión) y sync respaldado por Core (el catálogo hoy es estático). | `WEB/Backend` | Cross | P1 | M | `IN-PROGRESS` |
| [`CR-14`](./tracker-gap-reference-catalog.md#cr-14) | "evidencia = conteos enteros" desactualizado: `EvidenceRecord` es un agregado persistido (Create/Verify/Reject + hash). Falta el Evidence **Graph**: aristas de linaje/referencia y campos de integridad/clasificación/retención en el agregado (hoy solo en mock frontend + doc de diseño). | `Backend` | Cross | P1 | M | `IN-PROGRESS` |
| [`CR-14`](./tracker-gap-reference-catalog.md#cr-14) | **Hecho:** `EvidenceRecord` es ahora un nodo de Evidence **Graph** — campos de primera clase `sourceProvider`/`producer`/`contentHash`/`classification`(validada)/`retentionPolicyRef`/`immutable`, aristas de linaje `references[]` con operación de dominio `AddReference` (dedup) + `Seal`, comando/endpoint `POST /{id}/references`, migración aditiva `AddEvidenceGraphFields`. Build 0-warnings, 458 tests (+15). | `Backend` | Cross | P1 | M | `DONE` |
| [`CR-15`](./tracker-gap-reference-catalog.md#cr-15) | Solo existe un display estático de Provider Connections (catálogo de referencia + panel UI). El agregado `ProviderConnection`, los ports por capacidad, el mapeo ACL y los adaptadores siguen **sin construir** en Domain/Application/Infrastructure. | `Backend/Integration` | Cross | P1 | M | `IN-PROGRESS` |
| [`GAP-019`](./tracker-gap-reference-catalog.md#gap-019) | **Hecho:** requisitos Discovery desglosados a detalle implementable — PRD §2 REQ-DIS-01…13, cada uno con cláusula de aceptación, + catálogo de artefactos por gate (`functional-scope.md` §6). Sin user-stories atómicas, coherente con "sin backlog propio". | `Docs` | Cross | P1 | M | `DONE` |
| [`CR-27`](./tracker-gap-reference-catalog.md#cr-27) | Phantom navigation: `aigov` + 4 nav items have no screen | `WEB` | Cross | P2 | S | `PENDING` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Tracker.Application.Abstractions.Messaging;

namespace Tracker.Application.Artifacts.EvidenceRecord.Commands.AddEvidenceReference;

/// <summary>
/// Adds a lineage edge from an evidence node to another Evidence Graph node
/// (evidence, gate decision, ADR, context, …).
/// </summary>
public sealed record AddEvidenceReferenceCommand(
Guid EvidenceId,
Guid TenantId,
string Reference
) : ICommand;
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Tracker.Application.Abstractions.Messaging;
using Tracker.Domain.Kernel;

namespace Tracker.Application.Artifacts.EvidenceRecord.Commands.AddEvidenceReference;

internal sealed class AddEvidenceReferenceCommandHandler
: ICommandHandler<AddEvidenceReferenceCommand>
{
private readonly Tracker.Domain.Artifacts.EvidenceRecord.IEvidenceRecordRepository _repository;
private readonly IUnitOfWork _unitOfWork;

public AddEvidenceReferenceCommandHandler(
Tracker.Domain.Artifacts.EvidenceRecord.IEvidenceRecordRepository repository,
IUnitOfWork unitOfWork)
{
_repository = repository;
_unitOfWork = unitOfWork;
}

public async Task<Result> Handle(
AddEvidenceReferenceCommand request,
CancellationToken cancellationToken)
{
var record = await _repository.GetByIdAsync(request.EvidenceId, cancellationToken);

if (record is null)
return Result.Failure("EvidenceRecord.NotFound");

if (record.TenantId != request.TenantId)
return Result.Failure("EvidenceRecord.AccessDenied");

var result = record.AddReference(request.Reference);
if (result.IsFailure)
return Result.Failure(result.Error);

await _repository.UpdateAsync(record, cancellationToken);
await _unitOfWork.SaveEntitiesAsync(cancellationToken);

return Result.Success();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,10 @@ public sealed record CreateEvidenceRecordCommand(
string ArtifactId,
string EvidenceType,
string Content,
Guid SubmittedBy
Guid SubmittedBy,
string SourceProvider = "",
string Producer = "",
string Classification = Tracker.Domain.Artifacts.EvidenceRecord.EvidenceClassification.Internal,
string RetentionPolicyRef = "",
IReadOnlyList<string>? References = null
) : ICommand<Guid>;
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,30 @@ public async Task<Result<Guid>> Handle(
CreateEvidenceRecordCommand request,
CancellationToken cancellationToken)
{
// Compute an integrity hash of the content so evidence is tamper-evident, then
// promote it to the first-class ContentHash node field of the Evidence Graph.
var contentSha256 = ComputeSha256Hex(request.Content);

var result = Tracker.Domain.Artifacts.EvidenceRecord.EvidenceRecord.Create(
request.TenantId,
request.ArtifactType,
request.ArtifactId,
request.EvidenceType,
request.Content,
request.SubmittedBy);
request.SubmittedBy,
request.SourceProvider,
request.Producer,
contentSha256,
request.Classification,
request.RetentionPolicyRef,
request.References?.ToList());

if (result.IsFailure)
return Result<Guid>.Failure(result.Error);

var evidence = result.Value;

// Carry an integrity hash of the content so evidence is tamper-evident.
var contentSha256 = ComputeSha256Hex(request.Content);
// Keep the legacy metadata copy for backward compatibility with existing readers.
var metadata = new Dictionary<string, object>(evidence.Metadata)
{
["contentSha256"] = contentSha256
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,13 @@ public sealed class EvidenceRecordDto
public string Status { get; init; } = string.Empty;
public Guid SubmittedBy { get; init; }
public DateTime SubmittedAtUtc { get; init; }

// Evidence Graph node: provenance / integrity / lineage.
public string SourceProvider { get; init; } = string.Empty;
public string Producer { get; init; } = string.Empty;
public string ContentHash { get; init; } = string.Empty;
public string Classification { get; init; } = string.Empty;
public string RetentionPolicyRef { get; init; } = string.Empty;
public IReadOnlyList<string> References { get; init; } = new List<string>();
public bool Immutable { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ public GetEvidenceRecordQueryHandler(Tracker.Domain.Artifacts.EvidenceRecord.IEv
Content = record.Content,
Status = record.Status,
SubmittedBy = record.SubmittedBy,
SubmittedAtUtc = record.SubmittedAtUtc
SubmittedAtUtc = record.SubmittedAtUtc,
SourceProvider = record.SourceProvider,
Producer = record.Producer,
ContentHash = record.ContentHash,
Classification = record.Classification,
RetentionPolicyRef = record.RetentionPolicyRef,
References = record.References,
Immutable = record.Immutable
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,31 @@ private EvidenceRecord(EvidenceRecordProps props) : base(props) { }
public DateTime SubmittedAtUtc => Props.SubmittedAtUtc;
public Dictionary<string, object> Metadata => Props.Metadata;

public string SourceProvider => Props.SourceProvider;
public string Producer => Props.Producer;
public string ContentHash => Props.ContentHash;
public string Classification => Props.Classification;
public string RetentionPolicyRef => Props.RetentionPolicyRef;
public IReadOnlyList<string> References => Props.References;
public bool Immutable => Props.Immutable;

public static Result<EvidenceRecord> Create(
Guid tenantId,
string artifactType,
string artifactId,
string evidenceType,
string content,
Guid submittedBy)
Guid submittedBy,
string sourceProvider = "",
string producer = "",
string contentHash = "",
string classification = EvidenceClassification.Internal,
string retentionPolicyRef = "",
List<string>? references = null)
{
if (!EvidenceClassification.IsValid(classification))
return Result<EvidenceRecord>.Failure("EvidenceRecord.InvalidClassification");

var props = new EvidenceRecordProps
{
Id = TrackerId.Create(),
Expand All @@ -37,12 +54,52 @@ public static Result<EvidenceRecord> Create(
SubmittedBy = submittedBy,
SubmittedAtUtc = DateTime.UtcNow,
Metadata = new(),
SourceProvider = sourceProvider,
Producer = producer,
ContentHash = contentHash,
Classification = classification,
RetentionPolicyRef = retentionPolicyRef,
References = references is null ? new() : new List<string>(references),
Immutable = false,
Audit = new AuditProps(submittedBy.ToString(), DateTime.UtcNow)
};

return Result<EvidenceRecord>.Success(new EvidenceRecord(props));
}

/// <summary>
/// Adds a lineage edge from this evidence node to another graph node (evidence,
/// gate decision, ADR, context, …). Idempotent: adding a reference that is already
/// present is a no-op success, so the adjacency list stays a set of distinct edges.
/// </summary>
public Result AddReference(string reference)
{
if (string.IsNullOrWhiteSpace(reference))
return Result.Failure("EvidenceRecord.EmptyReference");

if (Props.References.Contains(reference))
return Result.Success();

var references = new List<string>(Props.References) { reference };
SetProps(Props with { References = references });
TrackingState.MarkAsDirty();
return Result.Success();
}

/// <summary>
/// Seals the evidence node: once sealed the content and integrity hash are frozen by
/// convention. Idempotent — sealing an already-sealed node is a no-op success.
/// </summary>
public Result Seal()
{
if (Props.Immutable)
return Result.Success();

SetProps(Props with { Immutable = true });
TrackingState.MarkAsDirty();
return Result.Success();
}

public Result Verify(Guid verifiedBy)
{
if (Props.Status != "submitted")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,21 @@ public sealed record EvidenceRecordProps : IProps
public DateTime SubmittedAtUtc { get; init; }
public Dictionary<string, object> Metadata { get; init; } = new();

// Evidence Graph: provenance / integrity fields promoting the record to a first-class node.
public string SourceProvider { get; init; } = string.Empty;
public string Producer { get; init; } = string.Empty;
public string ContentHash { get; init; } = string.Empty;
public string Classification { get; init; } = string.Empty;
public string RetentionPolicyRef { get; init; } = string.Empty;

// Evidence Graph: lineage edges (adjacency list) to other evidence, gate decisions, ADRs, contexts.
public List<string> References { get; init; } = new();

// Evidence Graph: integrity seal — once true the content/hash are frozen by convention.
public bool Immutable { get; init; }

/// <summary>Copy hook required by the Core shared-kernel <c>IProps : ICloneable</c>. Explicit
/// interface impl because records cannot declare a member literally named <c>Clone</c> (CS8859).</summary>
object ICloneable.Clone() => this with { };
/// interface impl because records cannot declare a member literally named <c>Clone</c> (CS8859).
/// The <see cref="References"/> list is copied so clones never share the mutable backing store.</summary>
object ICloneable.Clone() => this with { References = new List<string>(References) };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Tracker.Domain.Artifacts.EvidenceRecord;

/// <summary>
/// Canonical, Tracker-owned evidence classification levels. This is the authoritative
/// data-sensitivity taxonomy for an <see cref="EvidenceRecord"/> node in the Evidence Graph,
/// governing how provenance/integrity of the artifact is disclosed and retained.
/// </summary>
public static class EvidenceClassification
{
public const string Public = "public";
public const string Internal = "internal";
public const string Confidential = "confidential";
public const string Restricted = "restricted";

/// <summary>True if <paramref name="classification"/> is one of the canonical classification levels.</summary>
public static bool IsValid(string classification)
=> classification is Public or Internal or Confidential or Restricted;
}
Loading
Loading