From 45166a5db03716b91c33b6ac8f4dab7172e1d9df Mon Sep 17 00:00:00 2001 From: Alberto Arroyo Raygada Date: Wed, 8 Jul 2026 00:00:55 -0500 Subject: [PATCH] feat(products): composable multi-dimensional topology in the domain (CR-08, ADR-0079) Brings the composed topology model into the domain instead of a single Topology string. New authoritative TopologyCatalog (5 dimensions + composableWith rules) and TopologySet value object: hard validation mirrors the frontend validateTopologySet (required dimensions present + topology valid for its dimension; UnknownDimension/InvalidTopologyForDimension/MissingRequiredDimension), with composability surfaced as non-blocking CompositionWarnings so canonical blueprint sets stay valid. Product gains a TopologySet prop + SetProductTopologySet (derives the legacy Topology from progressive-axis); persisted as jsonb topology_set via an additive migration AddProductTopologySet; new POST /{id}/topology-set endpoint. Build Release -warnaserror 0 warnings; 458->473 tests. Marks CR-08 DONE (Core sync deferred on GAP-004; unifying the Presentation static catalog with TopologyCatalog is a noted follow-up). Co-Authored-By: Claude Opus 4.8 --- docs/audit/tracker-gap-reference-catalog.md | 2 +- docs/audit/tracker-gap-tracking.md | 2 +- .../SetProductTopologySetCommand.cs | 14 + .../SetProductTopologySetCommandHandler.cs | 47 + .../Products/Product/Product.cs | 24 + .../Products/Product/ProductProps.cs | 6 + .../Product/Topology/TopologyCatalog.cs | 89 + .../Products/Product/Topology/TopologySet.cs | 120 + ...08045714_AddProductTopologySet.Designer.cs | 2783 +++++++++++++++++ .../20260708045714_AddProductTopologySet.cs | 30 + .../TrackerDbContextModelSnapshot.cs | 4 + .../ProductRecordConfiguration.cs | 1 + .../Products/Entities/ProductRecord.cs | 3 + .../Products/PostgreSqlProductRepository.cs | 33 + .../Endpoints/Products/ProductEndpoints.cs | 24 + ...etProductTopologySetCommandHandlerTests.cs | 100 + .../Domain/Products/ProductTests.cs | 22 + .../Domain/Products/TopologySetTests.cs | 133 + .../ProductTopologySetRoundTripTests.cs | 68 + 19 files changed, 3503 insertions(+), 2 deletions(-) create mode 100644 src/apps/tracker-api/Tracker.Application/Products/Product/Commands/SetProductTopologySet/SetProductTopologySetCommand.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Products/Product/Commands/SetProductTopologySet/SetProductTopologySetCommandHandler.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Products/Product/Topology/TopologyCatalog.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Products/Product/Topology/TopologySet.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708045714_AddProductTopologySet.Designer.cs create mode 100644 src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708045714_AddProductTopologySet.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Application/Products/SetProductTopologySetCommandHandlerTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Domain/Products/TopologySetTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/ProductTopologySetRoundTripTests.cs diff --git a/docs/audit/tracker-gap-reference-catalog.md b/docs/audit/tracker-gap-reference-catalog.md index a7de74ab..678d8a66 100644 --- a/docs/audit/tracker-gap-reference-catalog.md +++ b/docs/audit/tracker-gap-reference-catalog.md @@ -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 diff --git a/docs/audit/tracker-gap-tracking.md b/docs/audit/tracker-gap-tracking.md index 0d20f7f8..5cecd21a 100644 --- a/docs/audit/tracker-gap-tracking.md +++ b/docs/audit/tracker-gap-tracking.md @@ -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` | diff --git a/src/apps/tracker-api/Tracker.Application/Products/Product/Commands/SetProductTopologySet/SetProductTopologySetCommand.cs b/src/apps/tracker-api/Tracker.Application/Products/Product/Commands/SetProductTopologySet/SetProductTopologySetCommand.cs new file mode 100644 index 00000000..534e9e96 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Products/Product/Commands/SetProductTopologySet/SetProductTopologySetCommand.cs @@ -0,0 +1,14 @@ +using Tracker.Application.Abstractions.Messaging; +using Tracker.Application.Products.Product.DTOs; + +namespace Tracker.Application.Products.Product.Commands.SetProductTopologySet; + +/// +/// CR-08 (ADR-0079). Sets the composed multi-dimensional topology of a Product. +/// maps each dimension key to a topology id. +/// +public sealed record SetProductTopologySetCommand( + Guid TenantId, + Guid ProductId, + IReadOnlyDictionary Assignments +) : ICommand; diff --git a/src/apps/tracker-api/Tracker.Application/Products/Product/Commands/SetProductTopologySet/SetProductTopologySetCommandHandler.cs b/src/apps/tracker-api/Tracker.Application/Products/Product/Commands/SetProductTopologySet/SetProductTopologySetCommandHandler.cs new file mode 100644 index 00000000..61d14939 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Products/Product/Commands/SetProductTopologySet/SetProductTopologySetCommandHandler.cs @@ -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 +{ + 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> Handle( + SetProductTopologySetCommand request, + CancellationToken cancellationToken) + { + var product = await _repository.GetByIdAsync(request.ProductId, cancellationToken); + + if (product is null) + return Result.Failure("Product.NotFound"); + + if (product.TenantId != request.TenantId) + return Result.Failure("Product.AccessDenied"); + + var setResult = TopologySet.Create(request.Assignments); + if (setResult.IsFailure) + return Result.Failure(setResult.Error); + + var result = product.SetTopologySet(setResult.Value); + if (result.IsFailure) + return Result.Failure(result.Error); + + await _repository.UpdateAsync(product, cancellationToken); + await _unitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(ProductDtoMapper.ToDto(product)); + } +} diff --git a/src/apps/tracker-api/Tracker.Domain/Products/Product/Product.cs b/src/apps/tracker-api/Tracker.Domain/Products/Product/Product.cs index a7e07080..4714c997 100644 --- a/src/apps/tracker-api/Tracker.Domain/Products/Product/Product.cs +++ b/src/apps/tracker-api/Tracker.Domain/Products/Product/Product.cs @@ -1,4 +1,5 @@ using Tracker.Domain.Kernel; +using Tracker.Domain.Products.Product.Topology; namespace Tracker.Domain.Products.Product; @@ -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 Metadata => Props.Metadata; @@ -34,6 +36,7 @@ public static Result Create( Description = description, Status = "active", Topology = null, + TopologySet = null, RepositoryUrl = null, OwnerIdentity = null, Metadata = new(), @@ -96,6 +99,27 @@ public Result SetTopology(string topology) return Result.Success(); } + /// + /// CR-08 (ADR-0079). Stores the composed multi-dimensional and derives + /// the legacy scalar from its progressive-axis for back-compat. The set is + /// assumed already validated via . + /// + 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") diff --git a/src/apps/tracker-api/Tracker.Domain/Products/Product/ProductProps.cs b/src/apps/tracker-api/Tracker.Domain/Products/Product/ProductProps.cs index a52b9d40..c0660f74 100644 --- a/src/apps/tracker-api/Tracker.Domain/Products/Product/ProductProps.cs +++ b/src/apps/tracker-api/Tracker.Domain/Products/Product/ProductProps.cs @@ -1,4 +1,5 @@ using Tracker.Domain.Kernel; +using Tracker.Domain.Products.Product.Topology; namespace Tracker.Domain.Products.Product; @@ -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; + + /// Legacy scalar topology (kept for back-compat; derived from the progressive-axis of ). public string? Topology { get; init; } + + /// CR-08 (ADR-0079). Composed multi-dimensional topology. Nullable until set. + public TopologySet? TopologySet { get; init; } public string? RepositoryUrl { get; init; } public string? OwnerIdentity { get; init; } public Dictionary Metadata { get; init; } = new(); diff --git a/src/apps/tracker-api/Tracker.Domain/Products/Product/Topology/TopologyCatalog.cs b/src/apps/tracker-api/Tracker.Domain/Products/Product/Topology/TopologyCatalog.cs new file mode 100644 index 00000000..55aa29ae --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Products/Product/Topology/TopologyCatalog.cs @@ -0,0 +1,89 @@ +namespace Tracker.Domain.Products.Product.Topology; + +/// +/// 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 composableWith map +/// used only for ADVISORY composition warnings — never for hard validation. +/// Uses static readonly collections so there are no uninitialized fields (no CS8618). +/// +public static class TopologyCatalog +{ + /// Canonical dimension keys in authoritative order. + 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 DimensionDefinitions = new List + { + 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 DimensionsByKey = + DimensionDefinitions.ToDictionary(d => d.Key, StringComparer.Ordinal); + + /// + /// Advisory composition adjacency (per topology id). NOT hard validation: + /// canonical blueprint sets (e.g. event-sourced + agentic-ai together) remain VALID. + /// + private static readonly IReadOnlyDictionary> ComposableWith = + new Dictionary>(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" } + }; + + /// All canonical dimension keys in authoritative order. + public static IReadOnlyList Dimensions { get; } = + DimensionDefinitions.Select(d => d.Key).ToList(); + + /// True if is a known dimension and is required. + public static bool IsRequired(string dimension) + => DimensionsByKey.TryGetValue(dimension, out var def) && def.Required; + + /// True if is a known dimension key. + public static bool IsKnownDimension(string dimension) + => DimensionsByKey.ContainsKey(dimension); + + /// True if is an allowed topology for . + public static bool IsValidTopology(string dimension, string topologyId) + => DimensionsByKey.TryGetValue(dimension, out var def) + && def.AllowedTopologies.Contains(topologyId); + + /// + /// Lenient symmetric composition check: true if EITHER topology lists the other in its + /// composableWith set. Identical ids are trivially composable. Advisory only. + /// + 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 AllowedTopologies); +} diff --git a/src/apps/tracker-api/Tracker.Domain/Products/Product/Topology/TopologySet.cs b/src/apps/tracker-api/Tracker.Domain/Products/Product/Topology/TopologySet.cs new file mode 100644 index 00000000..6695314f --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Products/Product/Topology/TopologySet.cs @@ -0,0 +1,120 @@ +using Tracker.Domain.Kernel; + +namespace Tracker.Domain.Products.Product.Topology; + +/// +/// CR-08 (ADR-0079). Value object for a composed, multi-dimensional architecture topology: +/// an immutable, ordered map of dimension key -> topology id. +/// +/// The HARD validation rule mirrors the frontend validateTopologySet 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 ) and never blocks creation, so canonical blueprint sets +/// (e.g. event-sourced + agentic-ai in the same set) remain VALID. +/// +/// +public sealed record TopologySet +{ + private TopologySet(IReadOnlyDictionary assignments) + { + Assignments = assignments; + } + + /// Immutable, ordered map of dimension key -> topology id. + public IReadOnlyDictionary Assignments { get; } + + /// The progressive-axis topology id, if assigned. Used to derive the legacy scalar Topology. + public string? ProgressiveAxis => Assignments.GetValueOrDefault(TopologyCatalog.ProgressiveAxisDimension); + + /// + /// Builds a validated applying the HARD rule. Assignments are stored + /// in the catalog's authoritative dimension order for deterministic persistence. + /// + public static Result Create(IReadOnlyDictionary assignments) + { + if (assignments is null) + return Result.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.Failure("TopologySet.UnknownDimension"); + + if (!TopologyCatalog.IsValidTopology(dimension, topologyId)) + return Result.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.Failure("TopologySet.MissingRequiredDimension"); + } + + // Store in authoritative dimension order for deterministic jsonb round-trips. + var ordered = new Dictionary(StringComparer.Ordinal); + foreach (var dimension in TopologyCatalog.Dimensions) + { + if (assignments.TryGetValue(dimension, out var topologyId)) + ordered[dimension] = topologyId; + } + + return Result.Success(new TopologySet(ordered)); + } + + /// + /// ADVISORY, non-blocking composition warnings: distinct cross-dimension topology pairs whose + /// ids are NOT . Each pair is reported once. + /// + 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; + } + + /// Value equality over the ordered assignment pairs (records compare the dictionary by reference). + 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(); + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708045714_AddProductTopologySet.Designer.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708045714_AddProductTopologySet.Designer.cs new file mode 100644 index 00000000..0da13a79 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708045714_AddProductTopologySet.Designer.cs @@ -0,0 +1,2783 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tracker.Infrastructure.Persistence; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + [DbContext(typeof(TrackerDbContext))] + [Migration("20260708045714_AddProductTopologySet")] + partial class AddProductTopologySet + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("tracker_governance") + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Artifacts.Entities.EvidenceRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArtifactId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("artifact_type"); + + b.Property("Classification") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("classification"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentHash") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("content_hash"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("evidence_type"); + + b.Property("Immutable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("immutable"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Producer") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("producer"); + + b.Property("ReferencesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("references"); + + b.Property("RetentionPolicyRef") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("retention_policy_ref"); + + b.Property("SourceProvider") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("source_provider"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("SubmittedBy") + .HasColumnType("uuid") + .HasColumnName("submitted_by"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Classification"); + + b.HasIndex("TenantId"); + + b.HasIndex("ArtifactType", "ArtifactId"); + + b.ToTable("evidence_records", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Audit.Entities.AuditEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("ChangesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("entity_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ActorId"); + + b.HasIndex("TenantId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_entries", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Design.Entities.TechnicalBlueprintRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IntegrationAdrRef") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("integration_adr_ref"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("technical_blueprints", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.InitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalComplete") + .HasColumnType("boolean") + .HasColumnName("approval_complete"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedBy") + .HasColumnType("uuid") + .HasColumnName("approved_by"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConstituentsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("constituents"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("FacetsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("facets"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("InitiativeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("initiative_type"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("origin"); + + b.Property("OriginRef") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("origin_ref"); + + b.Property("ProductScopeJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("product_scope"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("RequiredApproversJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_approvers"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SupersededByInitiativeId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_initiative_id"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TypeFrozen") + .HasColumnType("boolean") + .HasColumnName("type_frozen"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code"); + + b.ToTable("initiatives", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.OpportunityRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("ContextJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("context"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("opportunities", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CatalogReleaseRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("catalog_release", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CountryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DefaultCurrencyCode") + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("default_currency_code"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsoAlpha2") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character(2)") + .HasColumnName("iso_alpha2") + .IsFixedLength(); + + b.Property("IsoAlpha3") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("iso_alpha3") + .IsFixedLength(); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumericCode") + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("numeric_code") + .IsFixedLength(); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IsoAlpha2") + .IsUnique(); + + b.HasIndex("IsoAlpha3") + .IsUnique(); + + b.ToTable("country", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CurrencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MinorUnits") + .HasColumnType("smallint") + .HasColumnName("minor_units"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("symbol"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currency", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.ExchangeRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("base_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("QuoteCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("quote_code"); + + b.Property("Rate") + .HasColumnType("numeric(20,10)") + .HasColumnName("rate"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("BaseCode", "QuoteCode", "ValidFrom") + .HasDatabaseName("ix_exchange_rate_pair"); + + b.ToTable("exchange_rate", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LanguageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("language", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocaleProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DateFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("date_format"); + + b.Property("FirstDayOfWeek") + .HasColumnType("smallint") + .HasColumnName("first_day_of_week"); + + b.Property("Name") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("NumberFormat") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("number_format"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CountryId"); + + b.ToTable("locale_profile", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scheme"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("LocationId", "Scheme") + .IsUnique(); + + b.HasIndex("Scheme", "Value") + .IsUnique(); + + b.ToTable("location_code", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.HasKey("LocationId", "Locale"); + + b.ToTable("location_i18n", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationLevelRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("Ordinal") + .HasColumnType("smallint") + .HasColumnName("ordinal"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Ordinal") + .IsUnique(); + + b.ToTable("location_level", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CatalogReleaseId") + .HasColumnType("uuid") + .HasColumnName("catalog_release_id"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LevelId") + .HasColumnType("uuid") + .HasColumnName("level_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("SupersedesId") + .HasColumnType("uuid") + .HasColumnName("supersedes_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_location_parent"); + + b.HasIndex("Path") + .HasDatabaseName("ix_location_path"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist"); + + b.HasIndex("CountryId", "ParentId") + .HasDatabaseName("ix_location_country_parent"); + + b.ToTable("location", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxRateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("LocationId") + .HasColumnType("uuid") + .HasColumnName("location_id"); + + b.Property("OperationType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation_type"); + + b.Property("Rate") + .HasColumnType("numeric(6,4)") + .HasColumnName("rate"); + + b.Property("TaxSchemeId") + .HasColumnType("uuid") + .HasColumnName("tax_scheme_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.HasKey("Id"); + + b.HasIndex("TaxSchemeId", "LocationId", "ValidFrom") + .HasDatabaseName("ix_tax_rate_scheme_location"); + + b.ToTable("tax_rate", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxSchemeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("code"); + + b.Property("CountryId") + .HasColumnType("uuid") + .HasColumnName("country_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("name"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryId", "Code") + .IsUnique(); + + b.ToTable("tax_scheme", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TimeZoneRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("country_code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IanaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("iana_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.HasIndex("IanaId") + .IsUnique(); + + b.ToTable("time_zone", "geo"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.ArtifactFieldSchemaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("artifact_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomFieldsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("custom_fields"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ArtifactType") + .IsUnique(); + + b.ToTable("artifact_field_schemas", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("approval_ids"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DecisionBy") + .HasColumnType("uuid") + .HasColumnName("decision_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationIdsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evaluation_ids"); + + b.Property("EvidenceSnapshotRef") + .HasColumnType("text") + .HasColumnName("evidence_snapshot_ref"); + + b.Property("ExceptionIdsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("exception_ids"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("phase"); + + b.Property("PolicySnapshotRef") + .HasColumnType("text") + .HasColumnName("policy_snapshot_ref"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rationale"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Phase"); + + b.HasIndex("SdlcExecutionId"); + + b.HasIndex("TenantId"); + + b.ToTable("gate_decisions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GatePolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("ApprovalPolicyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approval_policy"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluationCriteriaJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("evaluation_criteria"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequiredEvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("required_evidence"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase") + .IsUnique(); + + b.ToTable("gate_policies", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.GateSubmissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApprovalsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("approvals"); + + b.Property("CoreVerdictJson") + .HasColumnType("jsonb") + .HasColumnName("core_verdict"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CriteriaEvaluationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]") + .HasColumnName("criteria_evaluation"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("mode"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("requested_by"); + + b.Property("RequiresCoreVerdict") + .HasColumnType("boolean") + .HasColumnName("requires_core_verdict"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("state"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase"); + + b.ToTable("gate_submissions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.PhaseGateStateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid") + .HasColumnName("evaluated_by"); + + b.Property("GateStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gate_status"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phase"); + + b.Property("SdlcExecutionId") + .HasColumnType("uuid") + .HasColumnName("sdlc_execution_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Phase"); + + b.HasIndex("TenantId", "SdlcExecutionId"); + + b.ToTable("phase_gate_states", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Governance.Entities.SDLCExecutionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BlockedReason") + .HasColumnType("text") + .HasColumnName("blocked_reason"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ProductCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ProductCode"); + + b.HasIndex("TenantId"); + + b.ToTable("sdlc_executions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeDeadLetterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeadLetteredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dead_lettered_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("intake_dead_letter", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.IntakeOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("dispatched_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("event_type"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("Status", "NextAttemptAtUtc"); + + b.ToTable("intake_outbox", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.PpmEndpointRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("none") + .HasColumnName("auth_type"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("FieldBindingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("field_bindings"); + + b.Property("HealthState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("health_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LastSyncUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sync_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasColumnType("text") + .HasColumnName("secret_ref"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("TelemetryUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("telemetry_url"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ppm_endpoints", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicBaselineRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AnchoredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("anchored_at"); + + b.Property("Budget") + .IsRequired() + .HasColumnType("text") + .HasColumnName("budget"); + + b.Property("ExpectedRoi") + .IsRequired() + .HasColumnType("text") + .HasColumnName("expected_roi"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("RiskLevel") + .IsRequired() + .HasColumnType("text") + .HasColumnName("risk_level"); + + b.Property("StrategicInitiativeId") + .HasColumnType("uuid") + .HasColumnName("strategic_initiative_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("StrategicInitiativeId"); + + b.HasIndex("StrategicInitiativeId", "Version") + .IsUnique(); + + b.ToTable("strategic_baselines", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Intake.Entities.StrategicInitiativeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BusinessCaseJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("business_case"); + + b.Property("ConsolidatedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("consolidated_initiative_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("external_id"); + + b.Property("FeasibilityJson") + .HasColumnType("jsonb") + .HasColumnName("feasibility"); + + b.Property("FunctionalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("functional_brief"); + + b.Property("IntakeState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("intake_state"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("ProductCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("product_code"); + + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("ProductName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("product_name"); + + b.Property("PromotedInitiativeId") + .HasColumnType("uuid") + .HasColumnName("promoted_initiative_id"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_system"); + + b.Property("StakeholdersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("stakeholders"); + + b.Property("TechnicalBriefJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("technical_brief"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("IntakeState"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ExternalId") + .IsUnique(); + + b.ToTable("strategic_initiatives", "tracker_intake"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Integration.Entities.CoreEvaluationTransactionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("ErrorCode") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("error_code"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("operation_id"); + + b.Property("RepositoryRevision") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("repository_revision"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at"); + + b.Property("ResponseDataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_data"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("core_evaluation_transactions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Products.Entities.ProductRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("OwnerIdentity") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("owner_identity"); + + b.Property("RepositoryAccessJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("repository_access"); + + b.Property("RepositoryUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("repository_url"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Topology") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("topology"); + + b.Property("TopologySetJson") + .HasColumnType("jsonb") + .HasColumnName("topology_set"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("products", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseArtifactRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ItemsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("items"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phase"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("InitiativeId", "Phase") + .IsUnique(); + + b.ToTable("phase_artifacts", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Sdlc.Entities.PhaseProgressionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("current_phase"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("HistoryJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("history"); + + b.Property("InitiativeId") + .HasColumnType("uuid") + .HasColumnName("initiative_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("InitiativeId") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("phase_progressions", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantIntelligenceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AgentRegistrationsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("agent_registrations"); + + b.Property("CorpusRefsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("corpus_refs"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("ModelRoutingJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("model_routing"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("tenant_intelligence", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Tenancy.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("code"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("contact_email"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("text") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("display_name"); + + b.Property("GeoCatalogRelease") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("geo_catalog_release"); + + b.Property("GeoCountryId") + .HasColumnType("uuid") + .HasColumnName("geo_country_id"); + + b.Property("GeoDisplayPath") + .HasColumnType("text") + .HasColumnName("geo_display_path"); + + b.Property("GeoUbigeo") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("geo_ubigeo"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("LocalizationJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}") + .HasColumnName("localization"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("tier"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("tenants", "tracker_governance"); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany("Codes") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b => + { + b.HasOne("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", null) + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b => + { + b.Navigation("Codes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708045714_AddProductTopologySet.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708045714_AddProductTopologySet.cs new file mode 100644 index 00000000..9375a2b9 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260708045714_AddProductTopologySet.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tracker.Infrastructure.Migrations +{ + /// + public partial class AddProductTopologySet : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "topology_set", + schema: "tracker_governance", + table: "products", + type: "jsonb", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "topology_set", + schema: "tracker_governance", + table: "products"); + } + } +} diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs index 5d544d92..33095f92 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/TrackerDbContextModelSnapshot.cs @@ -2409,6 +2409,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(64)") .HasColumnName("topology"); + b.Property("TopologySetJson") + .HasColumnType("jsonb") + .HasColumnName("topology_set"); + b.Property("UpdatedAtUtc") .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/Configurations/ProductRecordConfiguration.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/Configurations/ProductRecordConfiguration.cs index 20e6cefc..161b5d89 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/Configurations/ProductRecordConfiguration.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/Configurations/ProductRecordConfiguration.cs @@ -20,6 +20,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Description).HasColumnName("description"); builder.Property(x => x.Status).HasColumnName("status").IsRequired().HasMaxLength(20); builder.Property(x => x.Topology).HasColumnName("topology").HasMaxLength(64); + builder.Property(x => x.TopologySetJson).HasColumnName("topology_set").HasColumnType("jsonb"); builder.Property(x => x.RepositoryUrl).HasColumnName("repository_url").HasMaxLength(512); builder.Property(x => x.OwnerIdentity).HasColumnName("owner_identity").HasMaxLength(256); builder.Property(x => x.MetadataJson).HasColumnName("metadata").HasColumnType("jsonb"); diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/Entities/ProductRecord.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/Entities/ProductRecord.cs index e7616d71..000b3bb6 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/Entities/ProductRecord.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/Entities/ProductRecord.cs @@ -13,6 +13,9 @@ public sealed class ProductRecord : IAuditableRecord public string Description { get; set; } = string.Empty; public string Status { get; set; } = "active"; public string? Topology { get; set; } + + /// CR-08 (ADR-0079). Serialized composed topology (dimension -> topology id) as jsonb. Null until set. + public string? TopologySetJson { get; set; } public string? RepositoryUrl { get; set; } public string? OwnerIdentity { get; set; } public string MetadataJson { get; set; } = "{}"; diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/PostgreSqlProductRepository.cs b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/PostgreSqlProductRepository.cs index bbf949fe..e95b4459 100644 --- a/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/PostgreSqlProductRepository.cs +++ b/src/apps/tracker-api/Tracker.Infrastructure/Persistence/Products/PostgreSqlProductRepository.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Tracker.Domain.Kernel; using Tracker.Domain.Products.Product; +using Tracker.Domain.Products.Product.Topology; using Tracker.Infrastructure.Persistence.Products.Entities; namespace Tracker.Infrastructure.Persistence.Products; @@ -62,6 +63,7 @@ public async Task UpdateAsync(Product aggregate, CancellationToken cancellationT record.Description = aggregate.Description; record.Status = aggregate.Status; record.Topology = aggregate.Topology; + record.TopologySetJson = SerializeTopologySet(aggregate.TopologySet); record.RepositoryUrl = aggregate.RepositoryUrl; record.OwnerIdentity = aggregate.OwnerIdentity; record.MetadataJson = JsonSerializer.Serialize(aggregate.Metadata, JsonOptions); @@ -93,6 +95,7 @@ private static Product MapToDomain(ProductRecord record) Description = record.Description, Status = record.Status, Topology = record.Topology, + TopologySet = DeserializeTopologySet(record.TopologySetJson), RepositoryUrl = record.RepositoryUrl, OwnerIdentity = record.OwnerIdentity, Metadata = DeserializeMetadata(record.MetadataJson), @@ -122,6 +125,7 @@ private static ProductRecord MapToRecord(Product aggregate) Description = aggregate.Description, Status = aggregate.Status, Topology = aggregate.Topology, + TopologySetJson = SerializeTopologySet(aggregate.TopologySet), RepositoryUrl = aggregate.RepositoryUrl, OwnerIdentity = aggregate.OwnerIdentity, MetadataJson = JsonSerializer.Serialize(aggregate.Metadata, JsonOptions), @@ -133,6 +137,35 @@ private static ProductRecord MapToRecord(Product aggregate) }; } + private static string? SerializeTopologySet(TopologySet? set) + => set is null ? null : JsonSerializer.Serialize(set.Assignments, JsonOptions); + + private static TopologySet? DeserializeTopologySet(string? json) + { + if (string.IsNullOrWhiteSpace(json) || json == "{}") + { + return null; + } + + try + { + var assignments = JsonSerializer.Deserialize>(json, JsonOptions); + if (assignments is null || assignments.Count == 0) + { + return null; + } + + // A stored set was valid when written; if Create ever fails on read, degrade to null + // rather than throwing so a legacy/malformed row cannot break loading the aggregate. + var result = TopologySet.Create(assignments); + return result.IsSuccess ? result.Value : null; + } + catch (JsonException) + { + return null; + } + } + private static Dictionary DeserializeMetadata(string json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Products/ProductEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Products/ProductEndpoints.cs index b3249d73..d57515f3 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Products/ProductEndpoints.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Products/ProductEndpoints.cs @@ -7,6 +7,7 @@ using Tracker.Application.Products.Product.Commands.AssignProductOwner; using Tracker.Application.Products.Product.Commands.CreateProduct; using Tracker.Application.Products.Product.Commands.SetProductTopology; +using Tracker.Application.Products.Product.Commands.SetProductTopologySet; using Tracker.Application.Products.Product.Commands.SetRepositoryAccess; using Tracker.Application.Products.Product.Commands.UpdateProduct; using Tracker.Application.Products.Product.Queries.GetProduct; @@ -95,6 +96,24 @@ public static void MapProductEndpoints(this IEndpointRouteBuilder app) .RequireTrackerPermission(TrackerPermissions.ProductUpdate) .WithName("SetProductTopology"); + // CR-08 (ADR-0079): set the composed multi-dimensional topology. + group.MapPost("/{id:guid}/topology-set", async ( + Guid id, + SetProductTopologySetRequest request, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var result = await mediator.Send( + new SetProductTopologySetCommand( + user.TenantId, + id, + request.Assignments ?? new Dictionary()), ct); + return result.ToOk(); + }) + .RequireTrackerPermission(TrackerPermissions.ProductUpdate) + .WithName("SetProductTopologySet"); + group.MapPost("/{id:guid}/owner", async ( Guid id, AssignProductOwnerRequest request, @@ -251,6 +270,11 @@ public sealed class SetProductTopologyRequest public string Topology { get; init; } = string.Empty; } +public sealed class SetProductTopologySetRequest +{ + public Dictionary? Assignments { get; init; } +} + public sealed class AssignProductOwnerRequest { public string OwnerIdentity { get; init; } = string.Empty; diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Products/SetProductTopologySetCommandHandlerTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Products/SetProductTopologySetCommandHandlerTests.cs new file mode 100644 index 00000000..b55bcb3c --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Application/Products/SetProductTopologySetCommandHandlerTests.cs @@ -0,0 +1,100 @@ +using Xunit; +using Moq; +using FluentAssertions; +using Tracker.Application.Products.Product.Commands.SetProductTopologySet; +using Tracker.Domain.Kernel; +using Tracker.Domain.Products.Product; + +namespace Tracker.Tests.Application.Products; + +/// CR-08 (ADR-0079). Handler for setting the composed multi-dimensional topology. +public class SetProductTopologySetCommandHandlerTests +{ + private readonly Mock _repositoryMock = new(); + private readonly Mock _unitOfWorkMock = new(); + private readonly SetProductTopologySetCommandHandler _handler; + + private readonly Guid _tenantId = Guid.NewGuid(); + private readonly Guid _actorId = Guid.NewGuid(); + + public SetProductTopologySetCommandHandlerTests() + { + _handler = new SetProductTopologySetCommandHandler( + _repositoryMock.Object, _unitOfWorkMock.Object); + } + + private Product CreateProduct() => + Product.Create(_tenantId, "PROD-001", "Checkout", "desc", _actorId).Value; + + private void SetupProduct(Product product) => + _repositoryMock + .Setup(r => r.GetByIdAsync(product.Id, It.IsAny())) + .ReturnsAsync(product); + + private static Dictionary ValidAssignments() => new() + { + ["progressive-axis"] = "modular-monolith", + ["execution"] = "containerized-services", + ["integration"] = "api-led", + ["data"] = "shared-database", + ["ai"] = "human-in-loop" + }; + + [Fact] + public async Task Handle_WithValidAssignments_ShouldSave_AndDeriveLegacyTopology() + { + var product = CreateProduct(); + SetupProduct(product); + var command = new SetProductTopologySetCommand(_tenantId, product.Id, ValidAssignments()); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Topology.Should().Be("modular-monolith"); + product.TopologySet.Should().NotBeNull(); + _repositoryMock.Verify(r => r.UpdateAsync(product, It.IsAny()), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithInvalidAssignments_ShouldReturnDomainError_AndNotSave() + { + var product = CreateProduct(); + SetupProduct(product); + var assignments = ValidAssignments(); + assignments.Remove("data"); // required dimension missing + var command = new SetProductTopologySetCommand(_tenantId, product.Id, assignments); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("TopologySet.MissingRequiredDimension"); + _repositoryMock.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WhenTenantMismatch_ShouldDeny() + { + var product = CreateProduct(); + SetupProduct(product); + var command = new SetProductTopologySetCommand(Guid.NewGuid(), product.Id, ValidAssignments()); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("Product.AccessDenied"); + _repositoryMock.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WhenProductNotFound_ShouldFail() + { + var command = new SetProductTopologySetCommand(_tenantId, Guid.NewGuid(), ValidAssignments()); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("Product.NotFound"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Products/ProductTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Products/ProductTests.cs index b58034b8..15e12782 100644 --- a/src/apps/tracker-api/Tracker.Tests/Domain/Products/ProductTests.cs +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Products/ProductTests.cs @@ -1,5 +1,6 @@ using Xunit; using Tracker.Domain.Products.Product; +using Tracker.Domain.Products.Product.Topology; using FluentAssertions; namespace Tracker.Tests.Domain.Products; @@ -31,6 +32,27 @@ public void SetTopology_ShouldUpdateTopology() product.Topology.Should().Be("t3-modular-monolith"); } + [Fact] + public void SetTopologySet_ShouldStoreSet_AndDeriveLegacyTopology() + { + var product = Product.Create(_tenantId, "PROD-001", "Checkout", "Handles checkout", _userId).Value; + var set = TopologySet.Create(new Dictionary + { + ["progressive-axis"] = "microservices", + ["execution"] = "serverless", + ["integration"] = "event-driven", + ["data"] = "event-sourced", + ["ai"] = "agentic-ai" + }).Value; + + var result = product.SetTopologySet(set); + + result.IsSuccess.Should().BeTrue(); + product.TopologySet.Should().Be(set); + // Legacy scalar Topology is derived from the progressive-axis for back-compat. + product.Topology.Should().Be("microservices"); + } + [Fact] public void AssignOwner_ShouldSetOwnerIdentity() { diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Products/TopologySetTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Products/TopologySetTests.cs new file mode 100644 index 00000000..20649a7f --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Products/TopologySetTests.cs @@ -0,0 +1,133 @@ +using FluentAssertions; +using Tracker.Domain.Products.Product.Topology; +using Xunit; + +namespace Tracker.Tests.Domain.Products; + +/// +/// CR-08 (ADR-0079). Covers the HARD validation rule (mirrors the frontend validateTopologySet: +/// required dimensions assigned, valid ids per dimension, no unknown keys) and the ADVISORY-only +/// composition warnings that never block creation. +/// +public class TopologySetTests +{ + private static readonly Dictionary CanonicalCurrent = new() + { + ["progressive-axis"] = "modular-monolith", + ["execution"] = "containerized-services", + ["integration"] = "api-led", + ["data"] = "shared-database", + ["ai"] = "human-in-loop" + }; + + private static readonly Dictionary CanonicalTarget = new() + { + ["progressive-axis"] = "microservices", + ["execution"] = "serverless", + ["integration"] = "event-driven", + ["data"] = "event-sourced", + ["ai"] = "agentic-ai" + }; + + [Fact] + public void Create_CanonicalCurrentBlueprint_ShouldSucceed() + { + var result = TopologySet.Create(CanonicalCurrent); + + result.IsSuccess.Should().BeTrue(); + result.Value.ProgressiveAxis.Should().Be("modular-monolith"); + result.Value.Assignments.Should().HaveCount(5); + } + + [Fact] + public void Create_CanonicalTargetBlueprint_ShouldSucceed() + { + var result = TopologySet.Create(CanonicalTarget); + + result.IsSuccess.Should().BeTrue(); + result.Value.ProgressiveAxis.Should().Be("microservices"); + } + + [Fact] + public void Create_WithoutAiDimension_ShouldSucceed_BecauseAiIsOptional() + { + var assignments = new Dictionary(CanonicalCurrent); + assignments.Remove("ai"); + + var result = TopologySet.Create(assignments); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public void Create_MissingRequiredDimension_ShouldFail() + { + var assignments = new Dictionary(CanonicalCurrent); + assignments.Remove("data"); // required + + var result = TopologySet.Create(assignments); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("TopologySet.MissingRequiredDimension"); + } + + [Fact] + public void Create_InvalidTopologyForDimension_ShouldFail() + { + var assignments = new Dictionary(CanonicalCurrent) + { + ["data"] = "microservices" // valid id, but not for the data dimension + }; + + var result = TopologySet.Create(assignments); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("TopologySet.InvalidTopologyForDimension"); + } + + [Fact] + public void Create_UnknownDimensionKey_ShouldFail() + { + var assignments = new Dictionary(CanonicalCurrent) + { + ["observability"] = "otel" // not a known dimension + }; + + var result = TopologySet.Create(assignments); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("TopologySet.UnknownDimension"); + } + + [Fact] + public void CompositionWarnings_ForNonComposablePair_ShouldFlag_ButCreateStillSucceeds() + { + // serverless is NOT composable with modular-monolith / api-led / shared-database. + var assignments = new Dictionary + { + ["progressive-axis"] = "modular-monolith", + ["execution"] = "serverless", + ["integration"] = "api-led", + ["data"] = "shared-database" + }; + + var result = TopologySet.Create(assignments); + + result.IsSuccess.Should().BeTrue(); // advisory only, never blocks + result.Value.CompositionWarnings().Should().NotBeEmpty(); + } + + [Fact] + public void CanonicalTargetBlueprint_WithNonComposablePair_ShouldStayValid_ButWarn() + { + // event-sourced + agentic-ai are NOT in each other's composableWith set, yet the canonical + // target blueprint must remain VALID (composability is advisory, never blocking). + var result = TopologySet.Create(CanonicalTarget); + + result.IsSuccess.Should().BeTrue(); + result.Value.CompositionWarnings() + .Should().Contain(pair => + (pair.Item1 == "event-sourced" && pair.Item2 == "agentic-ai") + || (pair.Item1 == "agentic-ai" && pair.Item2 == "event-sourced")); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/ProductTopologySetRoundTripTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/ProductTopologySetRoundTripTests.cs new file mode 100644 index 00000000..3923e282 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Persistence/ProductTopologySetRoundTripTests.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using FluentAssertions; +using Tracker.Domain.Products.Product.Topology; +using Tracker.Infrastructure.Persistence.Products.Entities; +using Xunit; + +namespace Tracker.Tests.Infrastructure.Persistence; + +/// +/// CR-08 (ADR-0079). Verifies the composed TopologySet survives a full round trip through +/// PostgreSqlProductRepository's private MapToRecord / MapToDomain mappers (including the jsonb +/// (de)serialization of the dimension -> topology id assignments). The mappers are private statics +/// so we reach them via reflection, exercising the exact persistence contract. +/// +public class ProductTopologySetRoundTripTests +{ + private static readonly Type RepoType = + typeof(ProductRecord).Assembly.GetType( + "Tracker.Infrastructure.Persistence.Products.PostgreSqlProductRepository")!; + + private static ProductRecord MapToRecord(Tracker.Domain.Products.Product.Product aggregate) + => (ProductRecord)RepoType + .GetMethod("MapToRecord", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, new object[] { aggregate })!; + + private static Tracker.Domain.Products.Product.Product MapToDomain(ProductRecord record) + => (Tracker.Domain.Products.Product.Product)RepoType + .GetMethod("MapToDomain", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, new object[] { record })!; + + [Fact] + public void TopologySet_ShouldRoundTrip_ThroughRepositoryMappers() + { + var assignments = new Dictionary + { + ["progressive-axis"] = "microservices", + ["execution"] = "serverless", + ["integration"] = "event-driven", + ["data"] = "event-sourced", + ["ai"] = "agentic-ai" + }; + var product = Tracker.Domain.Products.Product.Product + .Create(Guid.NewGuid(), "PROD-001", "Checkout", "desc", Guid.NewGuid()).Value; + product.SetTopologySet(TopologySet.Create(assignments).Value); + + var record = MapToRecord(product); + record.TopologySetJson.Should().NotBeNullOrEmpty(); + + var rehydrated = MapToDomain(record); + + rehydrated.TopologySet.Should().NotBeNull(); + rehydrated.TopologySet!.Assignments.Should().BeEquivalentTo(assignments); + rehydrated.Topology.Should().Be("microservices"); + } + + [Fact] + public void NoTopologySet_ShouldRoundTripToNull() + { + var product = Tracker.Domain.Products.Product.Product + .Create(Guid.NewGuid(), "PROD-001", "Checkout", "desc", Guid.NewGuid()).Value; + + var record = MapToRecord(product); + record.TopologySetJson.Should().BeNull(); + + var rehydrated = MapToDomain(record); + rehydrated.TopologySet.Should().BeNull(); + } +}