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 @@ -145,7 +145,7 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an
- **Acceptance criteria:**
- [ ] A satellite can hold a multi-dimensional topology set validated against Core's composition rules.
- **Dependencies:** [CR-09](#cr-09), [CR-10](#cr-10), [CR-19](#cr-19); ADR-0079.
- **Status:** `IN-PROGRESS` — topología multi-dimensional en frontend + catálogo BFF estático; el dominio aún usa un único string `Topology`, falta el agregado compuesto/validación/persistencia/sync.
- **Status:** `DONE` — `TopologyCatalog` + VO `TopologySet` (validación dura + avisos de composición), prop en `Product` + `SetProductTopologySet`, persistencia jsonb + migración `AddProductTopologySet`, endpoint `topology-set`; 473 tests. Diferido: sync con Core (GAP-004) y unificar el catálogo estático de Presentation.

#### CR-09

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 @@ -77,7 +77,7 @@ This board is the single source of truth for Tracker technical debt, gaps, oppor
| [`AR-20`](./tracker-gap-reference-catalog.md#ar-20) | Sin structured logging ni correlation IDs wiring completo | `Infra` | Cross | P3 | M | `PENDING` |
| [`AR-21`](./tracker-gap-reference-catalog.md#ar-21) | Sin rate limiting ni security headers configurados | `Security` | Cross | P3 | S | `PENDING` |
| [`CR-03`](./tracker-gap-reference-catalog.md#cr-03) | "100% mock" es falso: `CoreEvaluationGateway` (typed client → `POST evaluate`, envelope ADR-0073, ACL) está cableado en el flujo de phase-gate y persiste `CoreEvaluationTransaction`. Falta: verificación e2e contra un Core real (**GAP-004**). | `Integration/WEB` | Cross | P0 | M | `IN-PROGRESS` |
| [`CR-08`](./tracker-gap-reference-catalog.md#cr-08) | Topología multi-dimensional/componible existe en frontend + catálogo BFF estático, pero el agregado de dominio sigue con un único string `Topology` (`ProductProps`); faltan agregado de conjunto compuesto, validación de composición en dominio, persistencia y sync con Core. | `Arch/Backend` | Cross | P0 | M | `IN-PROGRESS` |
| [`CR-08`](./tracker-gap-reference-catalog.md#cr-08) | **Hecho:** modelo de topología componible en el dominio — `TopologyCatalog` autoritativo (5 dimensiones + reglas `composableWith`), VO `TopologySet` con validación dura (dimensiones requeridas + topología válida por dimensión) y avisos de composición no bloqueantes, prop en `Product` + `SetProductTopologySet` (deriva el `Topology` legacy), persistencia jsonb `topology_set` + migración aditiva `AddProductTopologySet`, endpoint `POST /{id}/topology-set`. 473 tests (+15). **Diferido:** sync con Core (depende de `GAP-004`); unificar el catálogo estático de Presentation con `TopologyCatalog` (follow-up menor). | `Arch/Backend` | Cross | P0 | M | `DONE` |
| [`CR-13`](./tracker-gap-reference-catalog.md#cr-13) | **Hecho:** VO de dominio `TechnicalEvaluationResult` (outcome compliant/non_compliant/error + `FromCoreVerdict` cableado al `CoreVerdict` real); `GateDecisionStatus` canónico (pending/approved/rejected/approved_with_exception) validado en `Decide()` con la regla `approved_with_exception` ⇒ requiere ≥1 exceptionId; refs `policySnapshotRef`/`evidenceSnapshotRef`/`approvalIds`/`exceptionIds` en el agregado + migración aditiva `AddGateDecisionEvaluationRefs`. Build 0-warnings, 443 tests (+15). | `Backend/WEB` | Cross | P1 | M | `DONE` |
| [`CR-24`](./tracker-gap-reference-catalog.md#cr-24) | Specs de diseño cubren evaluación satélite, provider ports/ACL y evidencia; pero las **user stories** de capacidad enumeradas (incl. US-ARC-001/006 que el estado citaba) **no existen** en `reference/specs/` — autorar esas historias o replantear el ítem. | `Specs` | Cross | P1 | M | `IN-PROGRESS` |
| [`CR-28`](./tracker-gap-reference-catalog.md#cr-28) | README phase model drift (5 vs 6 gates) + canonical PhaseId vocabulary not adopted | `Docs` | Cross | P2 | S | `IN-PROGRESS` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Tracker.Application.Abstractions.Messaging;
using Tracker.Application.Products.Product.DTOs;

namespace Tracker.Application.Products.Product.Commands.SetProductTopologySet;

/// <summary>
/// CR-08 (ADR-0079). Sets the composed multi-dimensional topology of a Product.
/// <paramref name="Assignments"/> maps each dimension key to a topology id.
/// </summary>
public sealed record SetProductTopologySetCommand(
Guid TenantId,
Guid ProductId,
IReadOnlyDictionary<string, string> Assignments
) : ICommand<ProductDto>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Tracker.Application.Abstractions.Messaging;
using Tracker.Application.Products.Product.DTOs;
using Tracker.Domain.Kernel;
using Tracker.Domain.Products.Product.Topology;

namespace Tracker.Application.Products.Product.Commands.SetProductTopologySet;

internal sealed class SetProductTopologySetCommandHandler
: ICommandHandler<SetProductTopologySetCommand, ProductDto>
{
private readonly Tracker.Domain.Products.Product.IProductRepository _repository;
private readonly IUnitOfWork _unitOfWork;

public SetProductTopologySetCommandHandler(
Tracker.Domain.Products.Product.IProductRepository repository,
IUnitOfWork unitOfWork)
{
_repository = repository;
_unitOfWork = unitOfWork;
}

public async Task<Result<ProductDto>> Handle(
SetProductTopologySetCommand request,
CancellationToken cancellationToken)
{
var product = await _repository.GetByIdAsync(request.ProductId, cancellationToken);

if (product is null)
return Result<ProductDto>.Failure("Product.NotFound");

if (product.TenantId != request.TenantId)
return Result<ProductDto>.Failure("Product.AccessDenied");

var setResult = TopologySet.Create(request.Assignments);
if (setResult.IsFailure)
return Result<ProductDto>.Failure(setResult.Error);

var result = product.SetTopologySet(setResult.Value);
if (result.IsFailure)
return Result<ProductDto>.Failure(result.Error);

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

return Result<ProductDto>.Success(ProductDtoMapper.ToDto(product));
}
}
24 changes: 24 additions & 0 deletions src/apps/tracker-api/Tracker.Domain/Products/Product/Product.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Tracker.Domain.Kernel;
using Tracker.Domain.Products.Product.Topology;

namespace Tracker.Domain.Products.Product;

Expand All @@ -13,6 +14,7 @@ private Product(ProductProps props) : base(props) { }
public string Description => Props.Description;
public string Status => Props.Status;
public string? Topology => Props.Topology;
public TopologySet? TopologySet => Props.TopologySet;
public string? RepositoryUrl => Props.RepositoryUrl;
public string? OwnerIdentity => Props.OwnerIdentity;
public Dictionary<string, object> Metadata => Props.Metadata;
Expand All @@ -34,6 +36,7 @@ public static Result<Product> Create(
Description = description,
Status = "active",
Topology = null,
TopologySet = null,
RepositoryUrl = null,
OwnerIdentity = null,
Metadata = new(),
Expand Down Expand Up @@ -96,6 +99,27 @@ public Result SetTopology(string topology)
return Result.Success();
}

/// <summary>
/// CR-08 (ADR-0079). Stores the composed multi-dimensional <see cref="TopologySet"/> and derives
/// the legacy scalar <see cref="Topology"/> from its progressive-axis for back-compat. The set is
/// assumed already validated via <see cref="Tracker.Domain.Products.Product.Topology.TopologySet.Create"/>.
/// </summary>
public Result SetTopologySet(TopologySet set)
{
if (set is null)
return Result.Failure("Product.TopologySetRequired");

SetProps(Props with
{
TopologySet = set,
Topology = set.ProgressiveAxis
});
TrackingState.MarkAsDirty();

// TODO CR-08: sync with Core
return Result.Success();
}

public Result Archive()
{
if (Props.Status == "archived")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Tracker.Domain.Kernel;
using Tracker.Domain.Products.Product.Topology;

namespace Tracker.Domain.Products.Product;

Expand All @@ -11,7 +12,12 @@ public sealed record ProductProps : IProps
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;

/// <summary>Legacy scalar topology (kept for back-compat; derived from the progressive-axis of <see cref="TopologySet"/>).</summary>
public string? Topology { get; init; }

/// <summary>CR-08 (ADR-0079). Composed multi-dimensional topology. Nullable until set.</summary>
public TopologySet? TopologySet { get; init; }
public string? RepositoryUrl { get; init; }
public string? OwnerIdentity { get; init; }
public Dictionary<string, object> Metadata { get; init; } = new();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
namespace Tracker.Domain.Products.Product.Topology;

/// <summary>
/// CR-08 (ADR-0079). Authoritative, Tracker-owned catalog of the composable
/// multi-dimensional architecture topology. Encoded VERBATIM from the canonical model
/// shared with the frontend and the static BFF catalog:
/// five dimensions (four required, one optional) plus a lenient <c>composableWith</c> map
/// used only for ADVISORY composition warnings — never for hard validation.
/// Uses <c>static readonly</c> collections so there are no uninitialized fields (no CS8618).
/// </summary>
public static class TopologyCatalog
{
/// <summary>Canonical dimension keys in authoritative order.</summary>
public const string ProgressiveAxisDimension = "progressive-axis";
public const string ExecutionDimension = "execution";
public const string IntegrationDimension = "integration";
public const string DataDimension = "data";
public const string AiDimension = "ai";

private static readonly IReadOnlyList<DimensionDefinition> DimensionDefinitions = new List<DimensionDefinition>
{
new(ProgressiveAxisDimension, Required: true, new[] { "modular-monolith", "distributed-modules", "microservices" }),
new(ExecutionDimension, Required: true, new[] { "containerized-services", "serverless", "edge-computing" }),
new(IntegrationDimension, Required: true, new[] { "api-led", "event-driven" }),
new(DataDimension, Required: true, new[] { "shared-database", "data-mesh", "event-sourced" }),
new(AiDimension, Required: false, new[] { "human-in-loop", "agentic-ai" })
};

private static readonly IReadOnlyDictionary<string, DimensionDefinition> DimensionsByKey =
DimensionDefinitions.ToDictionary(d => d.Key, StringComparer.Ordinal);

/// <summary>
/// Advisory composition adjacency (per topology id). NOT hard validation:
/// canonical blueprint sets (e.g. event-sourced + agentic-ai together) remain VALID.
/// </summary>
private static readonly IReadOnlyDictionary<string, IReadOnlyList<string>> ComposableWith =
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
{
["modular-monolith"] = new[] { "containerized-services", "api-led", "shared-database", "human-in-loop" },
["distributed-modules"] = new[] { "containerized-services", "event-driven", "api-led", "event-sourced", "human-in-loop" },
["microservices"] = new[] { "containerized-services", "serverless", "event-driven", "data-mesh", "event-sourced", "agentic-ai" },
["containerized-services"] = new[] { "modular-monolith", "distributed-modules", "microservices", "api-led", "event-driven", "shared-database", "data-mesh" },
["serverless"] = new[] { "microservices", "event-driven", "data-mesh", "agentic-ai" },
["edge-computing"] = new[] { "microservices", "event-driven", "agentic-ai" },
["api-led"] = new[] { "modular-monolith", "distributed-modules", "microservices", "containerized-services", "shared-database", "human-in-loop" },
["event-driven"] = new[] { "distributed-modules", "microservices", "serverless", "event-sourced", "data-mesh", "agentic-ai" },
["shared-database"] = new[] { "modular-monolith", "containerized-services", "api-led", "human-in-loop" },
["data-mesh"] = new[] { "microservices", "event-driven", "serverless", "agentic-ai" },
["event-sourced"] = new[] { "distributed-modules", "microservices", "event-driven", "serverless" },
["human-in-loop"] = new[] { "modular-monolith", "api-led", "shared-database", "containerized-services" },
["agentic-ai"] = new[] { "microservices", "event-driven", "serverless", "data-mesh" }
};

/// <summary>All canonical dimension keys in authoritative order.</summary>
public static IReadOnlyList<string> Dimensions { get; } =
DimensionDefinitions.Select(d => d.Key).ToList();

/// <summary>True if <paramref name="dimension"/> is a known dimension and is required.</summary>
public static bool IsRequired(string dimension)
=> DimensionsByKey.TryGetValue(dimension, out var def) && def.Required;

/// <summary>True if <paramref name="dimension"/> is a known dimension key.</summary>
public static bool IsKnownDimension(string dimension)
=> DimensionsByKey.ContainsKey(dimension);

/// <summary>True if <paramref name="topologyId"/> is an allowed topology for <paramref name="dimension"/>.</summary>
public static bool IsValidTopology(string dimension, string topologyId)
=> DimensionsByKey.TryGetValue(dimension, out var def)
&& def.AllowedTopologies.Contains(topologyId);

/// <summary>
/// Lenient symmetric composition check: true if EITHER topology lists the other in its
/// <c>composableWith</c> set. Identical ids are trivially composable. Advisory only.
/// </summary>
public static bool AreComposable(string idA, string idB)
{
if (string.Equals(idA, idB, StringComparison.Ordinal))
return true;

var aLists = ComposableWith.TryGetValue(idA, out var a) && a.Contains(idB);
var bLists = ComposableWith.TryGetValue(idB, out var b) && b.Contains(idA);
return aLists || bLists;
}

private sealed record DimensionDefinition(
string Key,
bool Required,
IReadOnlyList<string> AllowedTopologies);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using Tracker.Domain.Kernel;

namespace Tracker.Domain.Products.Product.Topology;

/// <summary>
/// CR-08 (ADR-0079). Value object for a composed, multi-dimensional architecture topology:
/// an immutable, ordered map of dimension key -> topology id.
/// <para>
/// The HARD validation rule mirrors the frontend <c>validateTopologySet</c> EXACTLY: a set is
/// valid iff (1) every REQUIRED dimension is assigned, (2) every assigned topology id is allowed
/// for its dimension, and (3) there are no unknown dimension keys. Composability is ADVISORY only
/// (see <see cref="CompositionWarnings"/>) and never blocks creation, so canonical blueprint sets
/// (e.g. event-sourced + agentic-ai in the same set) remain VALID.
/// </para>
/// </summary>
public sealed record TopologySet
{
private TopologySet(IReadOnlyDictionary<string, string> assignments)
{
Assignments = assignments;
}

/// <summary>Immutable, ordered map of dimension key -> topology id.</summary>
public IReadOnlyDictionary<string, string> Assignments { get; }

/// <summary>The progressive-axis topology id, if assigned. Used to derive the legacy scalar Topology.</summary>
public string? ProgressiveAxis => Assignments.GetValueOrDefault(TopologyCatalog.ProgressiveAxisDimension);

/// <summary>
/// Builds a validated <see cref="TopologySet"/> applying the HARD rule. Assignments are stored
/// in the catalog's authoritative dimension order for deterministic persistence.
/// </summary>
public static Result<TopologySet> Create(IReadOnlyDictionary<string, string> assignments)
{
if (assignments is null)
return Result<TopologySet>.Failure("TopologySet.AssignmentsRequired");

// (3) No unknown dimension keys, and (2) each topology id is allowed for its dimension.
foreach (var (dimension, topologyId) in assignments)
{
if (!TopologyCatalog.IsKnownDimension(dimension))
return Result<TopologySet>.Failure("TopologySet.UnknownDimension");

if (!TopologyCatalog.IsValidTopology(dimension, topologyId))
return Result<TopologySet>.Failure("TopologySet.InvalidTopologyForDimension");
}

// (1) Every required dimension must be assigned.
foreach (var dimension in TopologyCatalog.Dimensions)
{
if (TopologyCatalog.IsRequired(dimension) && !assignments.ContainsKey(dimension))
return Result<TopologySet>.Failure("TopologySet.MissingRequiredDimension");
}

// Store in authoritative dimension order for deterministic jsonb round-trips.
var ordered = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var dimension in TopologyCatalog.Dimensions)
{
if (assignments.TryGetValue(dimension, out var topologyId))
ordered[dimension] = topologyId;
}

return Result<TopologySet>.Success(new TopologySet(ordered));
}

/// <summary>
/// ADVISORY, non-blocking composition warnings: distinct cross-dimension topology pairs whose
/// ids are NOT <see cref="TopologyCatalog.AreComposable"/>. Each pair is reported once.
/// </summary>
public IReadOnlyList<(string, string)> CompositionWarnings()
{
var ids = Assignments.Values.ToList();
var warnings = new List<(string, string)>();

for (var i = 0; i < ids.Count; i++)
{
for (var j = i + 1; j < ids.Count; j++)
{
if (!TopologyCatalog.AreComposable(ids[i], ids[j]))
warnings.Add((ids[i], ids[j]));
}
}

return warnings;
}

/// <summary>Value equality over the ordered assignment pairs (records compare the dictionary by reference).</summary>
public bool Equals(TopologySet? other)
{
if (other is null)
return false;
if (ReferenceEquals(this, other))
return true;
if (Assignments.Count != other.Assignments.Count)
return false;

foreach (var (dimension, topologyId) in Assignments)
{
if (!other.Assignments.TryGetValue(dimension, out var otherId)
|| !string.Equals(topologyId, otherId, StringComparison.Ordinal))
{
return false;
}
}

return true;
}

public override int GetHashCode()
{
var hash = new HashCode();
foreach (var (dimension, topologyId) in Assignments)
{
hash.Add(dimension, StringComparer.Ordinal);
hash.Add(topologyId, StringComparer.Ordinal);
}

return hash.ToHashCode();
}
}
Loading
Loading