Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using MediatR;
using Tracker.Application.Governance.ArtifactFieldSchema.Commands.UpsertArtifactFieldSchema;
using Tracker.Application.Governance.GatePolicy.Commands.UpsertGatePolicy;

namespace Tracker.Application.Governance.GovernancePackage;

using Package = Domain.Governance.GovernancePackage.GovernancePackage;

/// <summary>What an application did, item by item. Never a bare «ok».</summary>
public sealed record GovernancePackageApplyReport
{
public string PackageName { get; init; } = string.Empty;
public string PackageVersion { get; init; } = string.Empty;
public IReadOnlyList<string> AppliedGatePolicies { get; init; } = new List<string>();
public IReadOnlyList<string> AppliedArtifactSchemas { get; init; } = new List<string>();
public IReadOnlyList<string> Failures { get; init; } = new List<string>();
public bool FullyApplied => Failures.Count == 0;
}

/// <summary>
/// GT-532 — aplica un paquete de gobernanza SOBRE EL TENANT DE LA SESIÓN.
///
/// <para>El destino no es un parámetro que el llamante elija: viene del contexto de sesión en la
/// capa de presentación. Un endpoint que aceptara «aplica este paquete al tenant X» sería una
/// forma de reescribir la gobernanza de otro cliente, y el paquete tampoco lleva dentro su tenant
/// de origen precisamente para que esto no se pueda hacer por accidente.</para>
///
/// <para><b>Es idempotente porque reutiliza los upsert que ya existen</b>, no porque compare
/// estados: aplicar dos veces el mismo paquete deja el mismo resultado. Esto también significa que
/// aplicar un paquete SOBRESCRIBE la configuración de las fases y tipos que nombra, y no toca las
/// demás. Es lo que hace que un paquete sea revisable —lo que dice es exactamente lo que cambia—
/// pero conviene decirlo, porque «aplicar» suena a fusionar y no lo es.</para>
///
/// <para><b>No se detiene en el primer fallo.</b> Un paquete parcialmente aplicado es un hecho que
/// hay que ver entero: pararse en el primero dejaría al tenant en un estado intermedio del que el
/// informe no dice nada. Se intenta todo y se informa de cada fallo con su fase o su tipo.</para>
/// </summary>
public sealed record ApplyGovernancePackageCommand(
Guid TenantId,
Guid ActorId,
Package Package) : ICommand<GovernancePackageApplyReport>;

internal sealed class ApplyGovernancePackageCommandHandler
: ICommandHandler<ApplyGovernancePackageCommand, GovernancePackageApplyReport>
{
private readonly IMediator _mediator;

public ApplyGovernancePackageCommandHandler(IMediator mediator) => _mediator = mediator;

public async Task<Result<GovernancePackageApplyReport>> Handle(
ApplyGovernancePackageCommand request, CancellationToken ct)
{
var errores = request.Package.Validate();
if (errores.Count > 0)
{
// Un paquete inválido se rechaza ENTERO antes de tocar nada. Aplicar «lo que se pueda»
// de un documento que no valida deja al tenant en un estado que nadie declaró.
return Result<GovernancePackageApplyReport>.Failure(string.Join("; ", errores));
}

var policies = new List<string>();
var schemas = new List<string>();
var failures = new List<string>();

foreach (var p in request.Package.GatePolicies)
{
var result = await _mediator.Send(new UpsertGatePolicyCommand(
request.TenantId,
request.ActorId,
p.Phase,
p.Mode,
p.RequiredEvidence.Select(e => new UpsertGatePolicyEvidence(e.Type, e.Mandatory)).ToList(),
p.ApprovalStrategy,
p.ApprovalStages,
p.Approvers.Select(a => new UpsertGatePolicyApprover(a.RoleOrIdentity, a.Stage, a.Quorum)).ToList(),
p.RequiresCoreVerdict,
p.Active,
p.EvaluationCriteria.Select(c => new UpsertGatePolicyCriterion(
c.Id, c.Label, c.ArtifactType, c.FieldPath, c.Operator,
c.Expected.ToList(), c.Mandatory, c.Severity)).ToList()), ct);

if (result.IsSuccess) policies.Add(p.Phase);
else failures.Add($"gate-policy:{p.Phase}: {result.Error}");
}

foreach (var s in request.Package.ArtifactFieldSchemas)
{
var result = await _mediator.Send(new UpsertArtifactFieldSchemaCommand(
request.TenantId,
request.ActorId,
s.ArtifactType,
s.CustomFields.Select(f => new UpsertCustomField(
f.Name, f.Label, f.DataType, f.EligibleValues.ToList(), f.Required)).ToList()), ct);

if (result.IsSuccess) schemas.Add(s.ArtifactType);
else failures.Add($"artifact-schema:{s.ArtifactType}: {result.Error}");
}

return Result<GovernancePackageApplyReport>.Success(new GovernancePackageApplyReport
{
PackageName = request.Package.Name,
PackageVersion = request.Package.Version,
AppliedGatePolicies = policies,
AppliedArtifactSchemas = schemas,
Failures = failures,
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using Tracker.Domain.Governance.ArtifactFieldSchema;
using Tracker.Domain.Governance.GatePolicy;
using Tracker.Domain.Governance.GovernancePackage;

namespace Tracker.Application.Governance.GovernancePackage;

using Package = Domain.Governance.GovernancePackage.GovernancePackage;
// Alias explicitos: `GatePolicy` y `ArtifactFieldSchema` son tambien NOMBRES DE ESPACIO aqui.
using GatePolicyAggregate = Domain.Governance.GatePolicy.GatePolicy;
using ArtifactFieldSchemaAggregate = Domain.Governance.ArtifactFieldSchema.ArtifactFieldSchema;

/// <summary>
/// GT-532 — exporta la configuración de gobernanza del tenant de la sesión como un paquete
/// portable.
///
/// <para>El tenant NO viaja en la petición: sale del contexto de sesión en la capa de
/// presentación. Aceptarlo como parámetro convertiría este endpoint en una forma de leer la
/// configuración de otro cliente, que es exactamente lo que el aislamiento del producto prohíbe.</para>
/// </summary>
public sealed record ExportGovernancePackageQuery(
Guid TenantId,
string Name,
string Version,
string Description,
string ExportedFrom) : IQuery<Package>;

internal sealed class ExportGovernancePackageQueryHandler
: IQueryHandler<ExportGovernancePackageQuery, Package>
{
private readonly IGatePolicyRepository _policies;
private readonly IArtifactFieldSchemaRepository _schemas;

public ExportGovernancePackageQueryHandler(
IGatePolicyRepository policies, IArtifactFieldSchemaRepository schemas)
{
_policies = policies;
_schemas = schemas;
}

public async Task<Package> Handle(ExportGovernancePackageQuery request, CancellationToken ct)
{
var policies = await _policies.GetByTenantAsync(request.TenantId, ct);
var schemas = await _schemas.GetByTenantAsync(request.TenantId, ct);

return new Package
{
Name = request.Name,
Version = request.Version,
Description = request.Description,
ExportedFrom = request.ExportedFrom,
ExportedAtUtc = DateTime.UtcNow,
// Ordenado por fase y por tipo, de forma estable: dos exportaciones del mismo estado
// deben producir el mismo documento, o nadie puede diferenciar dos paquetes ni revisar
// un cambio de gobernanza en un pull request.
GatePolicies = policies
.OrderBy(p => p.Phase, StringComparer.Ordinal)
.Select(ToPackaged)
.ToList(),
ArtifactFieldSchemas = schemas
.OrderBy(s => s.ArtifactType, StringComparer.Ordinal)
.Select(ToPackaged)
.ToList(),
};
}

private static PackagedGatePolicy ToPackaged(GatePolicyAggregate p) => new()
{
Phase = p.Phase,
Mode = p.Mode,
RequiredEvidence = p.RequiredEvidence
.OrderBy(e => e.Type, StringComparer.Ordinal)
.Select(e => new PackagedEvidence(e.Type, e.Mandatory))
.ToList(),
ApprovalStrategy = p.ApprovalPolicy.Strategy,
ApprovalStages = p.ApprovalPolicy.Stages,
Approvers = p.ApprovalPolicy.Approvers
.OrderBy(a => a.Stage).ThenBy(a => a.RoleOrIdentity, StringComparer.Ordinal)
.Select(a => new PackagedApprover(a.RoleOrIdentity, a.Stage, a.Quorum))
.ToList(),
RequiresCoreVerdict = p.RequiresCoreVerdict,
Active = p.Active,
EvaluationCriteria = p.EvaluationCriteria
.OrderBy(c => c.Id, StringComparer.Ordinal)
.Select(c => new PackagedCriterion(
c.Id, c.Label, c.ArtifactType, c.FieldPath, c.Operator,
c.Expected.ToList(), c.Mandatory, c.Severity))
.ToList(),
};

private static PackagedArtifactFieldSchema ToPackaged(ArtifactFieldSchemaAggregate s) => new()
{
ArtifactType = s.ArtifactType,
CustomFields = s.CustomFields
.OrderBy(f => f.Name, StringComparer.Ordinal)
.Select(f => new PackagedCustomField(
f.Name, f.Label, f.DataType, f.EligibleValues.ToList(), f.Required))
.ToList(),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
namespace Tracker.Domain.Governance.GovernancePackage;

/// <summary>
/// GT-532 — un PAQUETE DE GOBERNANZA: el conjunto de decisiones de gobierno de un tenant, con
/// nombre y versión, portable como documento.
///
/// <para>El problema que resuelve, en las palabras de la ficha: «la gobernanza no se puede
/// empaquetar por cliente». Hoy un tenant la configura pieza a pieza —una `GatePolicy` por fase,
/// un `ArtifactFieldSchema` por tipo de artefacto— y no hay forma de tomar esa configuración
/// entera, versionarla, moverla a otro tenant o revisarla como una unidad. Eso convierte «damos de
/// alta un cliente con nuestro estándar de gobierno» en un trabajo manual que nadie puede auditar.</para>
///
/// <para><b>ES UN DOCUMENTO, NO UN AGREGADO PERSISTIDO, y la decisión es deliberada.</b> Un paquete
/// no tiene ciclo de vida propio: se exporta desde un tenant y se aplica sobre otro. Guardarlo en
/// una tabla añadiría una tercera copia de unos datos que ya viven en `GatePolicy` y
/// `ArtifactFieldSchema` —y este repositorio ha pasado la semana arreglando exactamente eso: dos
/// copias de un catálogo que divergen sin que nada lo note. Sin tabla no hay migración, no hay
/// deriva posible y el paquete es portable por construcción.</para>
///
/// <para><b>NO LLEVA `TenantId` DENTRO, y esto no es un olvido.</b> Un paquete que arrastrara el
/// tenant de origen al destino sería precisamente la fuga de aislamiento que el producto prohíbe.
/// La procedencia se conserva como texto informativo (<see cref="ExportedFrom"/>), nunca como una
/// clave que alguien pueda usar para escribir en otro sitio. El destino de una aplicación es
/// SIEMPRE el tenant de la sesión que la ejecuta.</para>
///
/// <para><b>Lo que este tipo NO hace: traer contenido.</b> El producto entrega el MECANISMO de
/// empaquetar; no publica paquetes propios con nuestras opiniones sobre qué debe exigir una
/// compuerta. Según `T-056` la validación de contenido es configuración del tenant y no código del
/// motor, así que un «paquete ISO 27001» de fábrica sería exactamente la capa que ese ADR rechaza.
/// El repositorio no contiene ni debe contener paquetes de ejemplo aplicables.</para>
/// </summary>
public sealed record GovernancePackage
{
/// <summary>Nombre legible del paquete. Identidad para una persona, nunca para una máquina.</summary>
public required string Name { get; init; }

/// <summary>
/// Versión del paquete, decidida por quien lo exporta. No se valida como semver a propósito:
/// imponer un esquema de versionado a la configuración de un cliente es exactamente el tipo de
/// opinión que `T-056` deja fuera del motor.
/// </summary>
public required string Version { get; init; }

public string Description { get; init; } = string.Empty;

/// <summary>
/// De dónde salió, como TEXTO informativo. Nunca un identificador utilizable: ver la nota sobre
/// aislamiento en la documentación del tipo.
/// </summary>
public string ExportedFrom { get; init; } = string.Empty;

public DateTime ExportedAtUtc { get; init; }

public IReadOnlyList<PackagedGatePolicy> GatePolicies { get; init; } = new List<PackagedGatePolicy>();

public IReadOnlyList<PackagedArtifactFieldSchema> ArtifactFieldSchemas { get; init; } =
new List<PackagedArtifactFieldSchema>();

/// <summary>
/// Un paquete vacío no es un paquete. Aplicarlo no haría nada y el informe diría «0 cambios»,
/// que es indistinguible de un error de exportación — la misma confusión entre «no había nada»
/// y «no se leyó nada» que este repositorio persigue en sus guardas.
/// </summary>
public bool IsEmpty => GatePolicies.Count == 0 && ArtifactFieldSchemas.Count == 0;

public IReadOnlyList<string> Validate()
{
var errors = new List<string>();

if (string.IsNullOrWhiteSpace(Name)) errors.Add("GovernancePackage.NameRequired");
if (string.IsNullOrWhiteSpace(Version)) errors.Add("GovernancePackage.VersionRequired");
if (IsEmpty) errors.Add("GovernancePackage.Empty");

// Dos políticas para la misma fase harían que el resultado dependiera del orden de
// aplicación, que es una forma silenciosa de no ser determinista.
var fases = GatePolicies.Select(p => p.Phase).ToList();
foreach (var dup in fases.GroupBy(f => f).Where(g => g.Count() > 1).Select(g => g.Key))
{
errors.Add($"GovernancePackage.DuplicatePhase:{dup}");
}

var tipos = ArtifactFieldSchemas.Select(s => s.ArtifactType).ToList();
foreach (var dup in tipos.GroupBy(t => t).Where(g => g.Count() > 1).Select(g => g.Key))
{
errors.Add($"GovernancePackage.DuplicateArtifactType:{dup}");
}

foreach (var p in GatePolicies.Where(p => string.IsNullOrWhiteSpace(p.Phase)))
{
errors.Add("GovernancePackage.PolicyPhaseRequired");
}

foreach (var s in ArtifactFieldSchemas.Where(s => string.IsNullOrWhiteSpace(s.ArtifactType)))
{
errors.Add("GovernancePackage.SchemaArtifactTypeRequired");
}

return errors;
}
}

/// <summary>Una política de compuerta dentro de un paquete, sin tenant y sin identidad de fila.</summary>
public sealed record PackagedGatePolicy
{
public required string Phase { get; init; }
public required string Mode { get; init; }
public IReadOnlyList<PackagedEvidence> RequiredEvidence { get; init; } = new List<PackagedEvidence>();
public string ApprovalStrategy { get; init; } = string.Empty;
public string ApprovalStages { get; init; } = string.Empty;
public IReadOnlyList<PackagedApprover> Approvers { get; init; } = new List<PackagedApprover>();
public bool RequiresCoreVerdict { get; init; }
public bool Active { get; init; } = true;
public IReadOnlyList<PackagedCriterion> EvaluationCriteria { get; init; } = new List<PackagedCriterion>();
}

public sealed record PackagedEvidence(string Type, bool Mandatory);

public sealed record PackagedApprover(string RoleOrIdentity, int Stage, int Quorum);

public sealed record PackagedCriterion(
string Id,
string Label,
string ArtifactType,
string FieldPath,
string Operator,
IReadOnlyList<string> Expected,
bool Mandatory,
string Severity);

public sealed record PackagedArtifactFieldSchema
{
public required string ArtifactType { get; init; }
public IReadOnlyList<PackagedCustomField> CustomFields { get; init; } = new List<PackagedCustomField>();
}

public sealed record PackagedCustomField(
string Name,
string Label,
string DataType,
IReadOnlyList<string> EligibleValues,
bool Required);
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ public static WebApplication MapTrackerApiSurface(this WebApplication app)
api.MapGatePolicyEndpoints();
api.MapGateSubmissionEndpoints();
api.MapArtifactFieldSchemaEndpoints();
api.MapGovernancePackageEndpoints();
api.MapEvidenceRecordEndpoints();
api.MapAuditEntryEndpoints();
api.MapArchitectureReferenceEndpoints();
Expand Down
Loading
Loading