diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs
new file mode 100644
index 00000000..da24850f
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs
@@ -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;
+
+/// What an application did, item by item. Never a bare «ok».
+public sealed record GovernancePackageApplyReport
+{
+ public string PackageName { get; init; } = string.Empty;
+ public string PackageVersion { get; init; } = string.Empty;
+ public IReadOnlyList AppliedGatePolicies { get; init; } = new List();
+ public IReadOnlyList AppliedArtifactSchemas { get; init; } = new List();
+ public IReadOnlyList Failures { get; init; } = new List();
+ public bool FullyApplied => Failures.Count == 0;
+}
+
+///
+/// GT-532 — aplica un paquete de gobernanza SOBRE EL TENANT DE LA SESIÓN.
+///
+/// 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.
+///
+/// Es idempotente porque reutiliza los upsert que ya existen, 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.
+///
+/// No se detiene en el primer fallo. 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.
+///
+public sealed record ApplyGovernancePackageCommand(
+ Guid TenantId,
+ Guid ActorId,
+ Package Package) : ICommand;
+
+internal sealed class ApplyGovernancePackageCommandHandler
+ : ICommandHandler
+{
+ private readonly IMediator _mediator;
+
+ public ApplyGovernancePackageCommandHandler(IMediator mediator) => _mediator = mediator;
+
+ public async Task> 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.Failure(string.Join("; ", errores));
+ }
+
+ var policies = new List();
+ var schemas = new List();
+ var failures = new List();
+
+ 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.Success(new GovernancePackageApplyReport
+ {
+ PackageName = request.Package.Name,
+ PackageVersion = request.Package.Version,
+ AppliedGatePolicies = policies,
+ AppliedArtifactSchemas = schemas,
+ Failures = failures,
+ });
+ }
+}
diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs
new file mode 100644
index 00000000..282449eb
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs
@@ -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;
+
+///
+/// GT-532 — exporta la configuración de gobernanza del tenant de la sesión como un paquete
+/// portable.
+///
+/// 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.
+///
+public sealed record ExportGovernancePackageQuery(
+ Guid TenantId,
+ string Name,
+ string Version,
+ string Description,
+ string ExportedFrom) : IQuery;
+
+internal sealed class ExportGovernancePackageQueryHandler
+ : IQueryHandler
+{
+ private readonly IGatePolicyRepository _policies;
+ private readonly IArtifactFieldSchemaRepository _schemas;
+
+ public ExportGovernancePackageQueryHandler(
+ IGatePolicyRepository policies, IArtifactFieldSchemaRepository schemas)
+ {
+ _policies = policies;
+ _schemas = schemas;
+ }
+
+ public async Task 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(),
+ };
+}
diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs b/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs
new file mode 100644
index 00000000..fbf408d9
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs
@@ -0,0 +1,141 @@
+namespace Tracker.Domain.Governance.GovernancePackage;
+
+///
+/// GT-532 — un PAQUETE DE GOBERNANZA: el conjunto de decisiones de gobierno de un tenant, con
+/// nombre y versión, portable como documento.
+///
+/// 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.
+///
+/// ES UN DOCUMENTO, NO UN AGREGADO PERSISTIDO, y la decisión es deliberada. 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.
+///
+/// NO LLEVA `TenantId` DENTRO, y esto no es un olvido. 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 (), 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.
+///
+/// Lo que este tipo NO hace: traer contenido. 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.
+///
+public sealed record GovernancePackage
+{
+ /// Nombre legible del paquete. Identidad para una persona, nunca para una máquina.
+ public required string Name { get; init; }
+
+ ///
+ /// 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.
+ ///
+ public required string Version { get; init; }
+
+ public string Description { get; init; } = string.Empty;
+
+ ///
+ /// De dónde salió, como TEXTO informativo. Nunca un identificador utilizable: ver la nota sobre
+ /// aislamiento en la documentación del tipo.
+ ///
+ public string ExportedFrom { get; init; } = string.Empty;
+
+ public DateTime ExportedAtUtc { get; init; }
+
+ public IReadOnlyList GatePolicies { get; init; } = new List();
+
+ public IReadOnlyList ArtifactFieldSchemas { get; init; } =
+ new List();
+
+ ///
+ /// 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.
+ ///
+ public bool IsEmpty => GatePolicies.Count == 0 && ArtifactFieldSchemas.Count == 0;
+
+ public IReadOnlyList Validate()
+ {
+ var errors = new List();
+
+ 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;
+ }
+}
+
+/// Una política de compuerta dentro de un paquete, sin tenant y sin identidad de fila.
+public sealed record PackagedGatePolicy
+{
+ public required string Phase { get; init; }
+ public required string Mode { get; init; }
+ public IReadOnlyList RequiredEvidence { get; init; } = new List();
+ public string ApprovalStrategy { get; init; } = string.Empty;
+ public string ApprovalStages { get; init; } = string.Empty;
+ public IReadOnlyList Approvers { get; init; } = new List();
+ public bool RequiresCoreVerdict { get; init; }
+ public bool Active { get; init; } = true;
+ public IReadOnlyList EvaluationCriteria { get; init; } = new List();
+}
+
+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 Expected,
+ bool Mandatory,
+ string Severity);
+
+public sealed record PackagedArtifactFieldSchema
+{
+ public required string ArtifactType { get; init; }
+ public IReadOnlyList CustomFields { get; init; } = new List();
+}
+
+public sealed record PackagedCustomField(
+ string Name,
+ string Label,
+ string DataType,
+ IReadOnlyList EligibleValues,
+ bool Required);
diff --git a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs
index 31207e8c..0f4eafcc 100644
--- a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs
+++ b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs
@@ -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();
diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs
new file mode 100644
index 00000000..7ec3cb35
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs
@@ -0,0 +1,167 @@
+using Tracker.Application.Governance.GovernancePackage;
+using Tracker.Domain.Governance.GovernancePackage;
+
+namespace Tracker.Presentation.Endpoints.Governance;
+
+using Package = Tracker.Domain.Governance.GovernancePackage.GovernancePackage;
+
+///
+/// GT-532 — empaquetar la gobernanza de un cliente: exportarla como documento portable y aplicarla
+/// sobre un tenant.
+///
+/// EL TENANT SALE SIEMPRE DE LA SESIÓN, en las dos operaciones. Ni la exportación lo
+/// acepta como parámetro ni la aplicación lo acepta en el cuerpo. Un `tenantId` en cualquiera de
+/// los dos sitios convertiría estos endpoints en una forma de leer o reescribir la gobernanza de
+/// otro cliente, y el propio documento tampoco lleva su tenant de origen dentro, precisamente para
+/// que ni siquiera por accidente pueda usarse como destino.
+///
+/// Lo que el producto NO entrega aquí es contenido. Estos endpoints son el mecanismo;
+/// no existen paquetes de fábrica con nuestra opinión 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.
+///
+public static class GovernancePackageEndpoints
+{
+ public static void MapGovernancePackageEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/governance-packages").WithTags("GateGovernance");
+
+ group.MapGet("/export", async (
+ ITrackerUserContext user,
+ IMediator mediator,
+ string? name,
+ string? version,
+ string? description,
+ CancellationToken ct) =>
+ {
+ var result = await mediator.Send(new ExportGovernancePackageQuery(
+ user.TenantId,
+ string.IsNullOrWhiteSpace(name) ? "governance" : name,
+ string.IsNullOrWhiteSpace(version) ? DateTime.UtcNow.ToString("yyyy.MM.dd") : version,
+ description ?? string.Empty,
+ // Procedencia como TEXTO. Nunca el identificador: ver la nota del tipo de dominio.
+ "tenant de la sesion"), ct);
+
+ return Results.Ok(result);
+ })
+ .RequireTrackerPermission(TrackerPermissions.GatePolicyRead)
+ .WithName("ExportGovernancePackage");
+
+ group.MapPost("/apply", async (
+ GovernancePackageRequest request,
+ ITrackerUserContext user,
+ IMediator mediator,
+ CancellationToken ct) =>
+ {
+ var package = new Package
+ {
+ Name = request.Name ?? string.Empty,
+ Version = request.Version ?? string.Empty,
+ Description = request.Description ?? string.Empty,
+ ExportedFrom = request.ExportedFrom ?? string.Empty,
+ ExportedAtUtc = request.ExportedAtUtc ?? default,
+ GatePolicies = (request.GatePolicies ?? new List())
+ .Select(p => new PackagedGatePolicy
+ {
+ Phase = p.Phase ?? string.Empty,
+ Mode = p.Mode ?? string.Empty,
+ RequiredEvidence = (p.RequiredEvidence ?? new List())
+ .Select(e => new PackagedEvidence(e.Type ?? string.Empty, e.Mandatory)).ToList(),
+ ApprovalStrategy = p.ApprovalStrategy ?? string.Empty,
+ ApprovalStages = p.ApprovalStages ?? string.Empty,
+ Approvers = (p.Approvers ?? new List())
+ .Select(a => new PackagedApprover(a.RoleOrIdentity ?? string.Empty, a.Stage, a.Quorum)).ToList(),
+ RequiresCoreVerdict = p.RequiresCoreVerdict,
+ Active = p.Active,
+ EvaluationCriteria = (p.EvaluationCriteria ?? new List())
+ .Select(c => new PackagedCriterion(
+ c.Id ?? string.Empty, c.Label ?? string.Empty, c.ArtifactType ?? string.Empty,
+ c.FieldPath ?? string.Empty, c.Operator ?? string.Empty,
+ c.Expected ?? new List(), c.Mandatory, c.Severity ?? string.Empty)).ToList(),
+ }).ToList(),
+ ArtifactFieldSchemas = (request.ArtifactFieldSchemas ?? new List())
+ .Select(s => new PackagedArtifactFieldSchema
+ {
+ ArtifactType = s.ArtifactType ?? string.Empty,
+ CustomFields = (s.CustomFields ?? new List())
+ .Select(f => new PackagedCustomField(
+ f.Name ?? string.Empty, f.Label ?? string.Empty, f.DataType ?? "string",
+ f.EligibleValues ?? new List(), f.Required)).ToList(),
+ }).ToList(),
+ };
+
+ var result = await mediator.Send(
+ new ApplyGovernancePackageCommand(user.TenantId, user.ActorId, package), ct);
+ return result.ToOk();
+ })
+ .RequireTrackerPermission(TrackerPermissions.GatePolicyWrite)
+ .WithName("ApplyGovernancePackage");
+ }
+}
+
+///
+/// Cuerpo de una aplicación. Deliberadamente SIN `tenantId`: el destino es el tenant de la sesión,
+/// y aceptarlo aquí sería una forma de escribir en otro cliente.
+///
+public sealed class GovernancePackageRequest
+{
+ public string? Name { get; init; }
+ public string? Version { get; init; }
+ public string? Description { get; init; }
+ public string? ExportedFrom { get; init; }
+ public DateTime? ExportedAtUtc { get; init; }
+ public List? GatePolicies { get; init; }
+ public List? ArtifactFieldSchemas { get; init; }
+}
+
+public sealed class PackagedGatePolicyRequest
+{
+ public string? Phase { get; init; }
+ public string? Mode { get; init; }
+ public List? RequiredEvidence { get; init; }
+ public string? ApprovalStrategy { get; init; }
+ public string? ApprovalStages { get; init; }
+ public List? Approvers { get; init; }
+ public bool RequiresCoreVerdict { get; init; }
+ public bool Active { get; init; } = true;
+ public List? EvaluationCriteria { get; init; }
+}
+
+public sealed class PackagedEvidenceRequest
+{
+ public string? Type { get; init; }
+ public bool Mandatory { get; init; }
+}
+
+public sealed class PackagedApproverRequest
+{
+ public string? RoleOrIdentity { get; init; }
+ public int Stage { get; init; }
+ public int Quorum { get; init; }
+}
+
+public sealed class PackagedCriterionRequest
+{
+ public string? Id { get; init; }
+ public string? Label { get; init; }
+ public string? ArtifactType { get; init; }
+ public string? FieldPath { get; init; }
+ public string? Operator { get; init; }
+ public List? Expected { get; init; }
+ public bool Mandatory { get; init; }
+ public string? Severity { get; init; }
+}
+
+public sealed class PackagedArtifactFieldSchemaRequest
+{
+ public string? ArtifactType { get; init; }
+ public List? CustomFields { get; init; }
+}
+
+public sealed class PackagedCustomFieldRequest
+{
+ public string? Name { get; init; }
+ public string? Label { get; init; }
+ public string? DataType { get; init; }
+ public List? EligibleValues { get; init; }
+ public bool Required { get; init; }
+}
diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs
new file mode 100644
index 00000000..1dee939f
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs
@@ -0,0 +1,123 @@
+using Tracker.Domain.Governance.GovernancePackage;
+
+namespace Tracker.Tests.Domain.Governance;
+
+///
+/// GT-532 — el documento de paquete de gobernanza.
+///
+/// Lo que se prueba aquí no es que los campos se copien, sino las tres decisiones que hacen
+/// que un paquete sea seguro de mover entre clientes: que un paquete vacío se rechace, que no
+/// pueda contener dos respuestas para la misma fase, y que NO lleve dentro el tenant de origen.
+///
+public class GovernancePackageTests
+{
+ private static GovernancePackage Paquete(
+ IReadOnlyList? politicas = null,
+ IReadOnlyList? esquemas = null,
+ string nombre = "Estándar corporativo",
+ string version = "2026.1") => new()
+ {
+ Name = nombre,
+ Version = version,
+ GatePolicies = politicas ?? new List
+ {
+ new() { Phase = "discovery", Mode = "advisory" },
+ },
+ ArtifactFieldSchemas = esquemas ?? new List(),
+ };
+
+ [Fact]
+ public void UnPaqueteCompletoEsValido()
+ {
+ Paquete().Validate().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void UnPaqueteVacioSeRechaza()
+ {
+ // Aplicar un paquete vacío no haría nada y el informe diría «0 cambios», que es
+ // indistinguible de un fallo de exportación. La misma confusión entre «no había nada» y
+ // «no se leyó nada» que las guardas de este repositorio persiguen.
+ var vacio = Paquete(politicas: new List());
+ vacio.IsEmpty.Should().BeTrue();
+ vacio.Validate().Should().Contain("GovernancePackage.Empty");
+ }
+
+ [Fact]
+ public void ExigeNombreYVersion()
+ {
+ Paquete(nombre: " ").Validate().Should().Contain("GovernancePackage.NameRequired");
+ Paquete(version: "").Validate().Should().Contain("GovernancePackage.VersionRequired");
+ }
+
+ [Fact]
+ public void NoAdmiteDosPoliticasParaLaMismaFase()
+ {
+ // Con dos, el resultado dependería del orden de aplicación: una forma silenciosa de no
+ // ser determinista, y de que dos tenants con el «mismo» paquete acaben distintos.
+ var dos = Paquete(politicas: new List
+ {
+ new() { Phase = "qa", Mode = "advisory" },
+ new() { Phase = "qa", Mode = "blocking" },
+ });
+ dos.Validate().Should().Contain("GovernancePackage.DuplicatePhase:qa");
+ }
+
+ [Fact]
+ public void NoAdmiteDosEsquemasParaElMismoTipoDeArtefacto()
+ {
+ var dos = Paquete(esquemas: new List
+ {
+ new() { ArtifactType = "prd" },
+ new() { ArtifactType = "prd" },
+ });
+ dos.Validate().Should().Contain("GovernancePackage.DuplicateArtifactType:prd");
+ }
+
+ [Fact]
+ public void RechazaUnaPoliticaSinFaseYUnEsquemaSinTipo()
+ {
+ Paquete(politicas: new List { new() { Phase = " ", Mode = "advisory" } })
+ .Validate().Should().Contain("GovernancePackage.PolicyPhaseRequired");
+
+ Paquete(esquemas: new List { new() { ArtifactType = " " } })
+ .Validate().Should().Contain("GovernancePackage.SchemaArtifactTypeRequired");
+ }
+
+ [Fact]
+ public void ElDocumentoNoExponeNingunIdentificadorDeTenant()
+ {
+ // La prueba que de verdad importa. Un paquete que arrastrara el tenant de origen al
+ // destino sería la fuga de aislamiento que el producto prohíbe, y es un error que se
+ // cometería anadiendo un campo «por comodidad» meses despues. Se comprueba por REFLEXION
+ // sobre el tipo, no sobre una instancia, para que anadir el campo rompa esta prueba.
+ var propiedades = typeof(GovernancePackage).GetProperties()
+ .Select(p => p.Name)
+ .ToList();
+
+ propiedades.Should().NotContain("TenantId");
+ propiedades.Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase))
+ .Should().BeEquivalentTo(new List(),
+ "la procedencia viaja como texto en `ExportedFrom`, nunca como una clave que "
+ + "alguien pueda usar para escribir en otro tenant");
+
+ foreach (var tipo in new[]
+ {
+ typeof(PackagedGatePolicy), typeof(PackagedArtifactFieldSchema),
+ typeof(PackagedCriterion), typeof(PackagedCustomField),
+ })
+ {
+ tipo.GetProperties().Select(p => p.Name)
+ .Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase))
+ .Should().BeEmpty($"{tipo.Name} tampoco puede llevar tenant dentro de un paquete");
+ }
+ }
+
+ [Fact]
+ public void LaProcedenciaEsTextoInformativoYSobrevive()
+ {
+ var p = Paquete() with { ExportedFrom = "acme (produccion)", ExportedAtUtc = new DateTime(2026, 8, 2) };
+ p.Validate().Should().BeEmpty();
+ p.ExportedFrom.Should().Be("acme (produccion)");
+ }
+}
diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs
new file mode 100644
index 00000000..e5c0cf6a
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs
@@ -0,0 +1,105 @@
+using System.Reflection;
+using Tracker.Application.Governance.GovernancePackage;
+using Tracker.Presentation.Endpoints.Governance;
+
+namespace Tracker.Tests.Presentation.Governance;
+
+///
+/// GT-532 — la propiedad que hace seguro mover gobernanza entre clientes: **el destino nunca lo
+/// elige el llamante**.
+///
+/// Un paquete existe para llevarse de un tenant a otro, así que es exactamente el tipo de
+/// función donde un `tenantId` «por comodidad» en el cuerpo o en la query se cuela sin que nadie
+/// lo note, y donde el efecto de colarlo es reescribir la gobernanza de otro cliente. Estas
+/// pruebas miran los TIPOS, no una llamada concreta: añadir el campo rompe la prueba aunque nadie
+/// escriba una petición que lo use.
+///
+public class GovernancePackageIsolationTests
+{
+ [Fact]
+ public void ElCuerpoDeUnaAplicacionNoAceptaTenant()
+ {
+ var propiedades = typeof(GovernancePackageRequest).GetProperties().Select(p => p.Name).ToList();
+
+ propiedades.Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase))
+ .Should().BeEmpty(
+ "el destino es el tenant de la SESION; aceptarlo en el cuerpo convertiria este "
+ + "endpoint en una forma de reescribir la gobernanza de otro cliente");
+ }
+
+ [Fact]
+ public void NingunTipoDePeticionAnidadoAceptaTenant()
+ {
+ var tipos = new[]
+ {
+ typeof(PackagedGatePolicyRequest), typeof(PackagedEvidenceRequest),
+ typeof(PackagedApproverRequest), typeof(PackagedCriterionRequest),
+ typeof(PackagedArtifactFieldSchemaRequest), typeof(PackagedCustomFieldRequest),
+ };
+
+ foreach (var t in tipos)
+ {
+ t.GetProperties().Select(p => p.Name)
+ .Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase))
+ .Should().BeEmpty($"{t.Name} viaja dentro del paquete y tampoco puede nombrar un tenant");
+ }
+ }
+
+ [Fact]
+ public void ElComandoDeAplicacionRecibeElTenantComoParametroPropioYNoDesdeElPaquete()
+ {
+ // El tenant es un parametro del COMANDO, que la capa de presentacion rellena desde la
+ // sesion. Si algun dia alguien lo moviera dentro del documento, el paquete pasaria a
+ // llevar su propio destino y esta separacion desapareceria en silencio.
+ var ctor = typeof(ApplyGovernancePackageCommand).GetConstructors().Single();
+ var parametros = ctor.GetParameters().Select(p => p.Name).ToList();
+
+ parametros.Should().Contain("TenantId");
+ parametros.Should().Contain("Package");
+ }
+
+ [Fact]
+ public void LaExportacionTampocoAceptaUnTenantElegidoPorElLlamante()
+ {
+ // El handler recibe un TenantId, pero el ENDPOINT lo toma de `ITrackerUserContext`. Lo que
+ // se comprueba aqui es que la query no expone ningun otro camino: un segundo parametro de
+ // tenant seria un modo de leer la configuracion de otro cliente.
+ var ctor = typeof(ExportGovernancePackageQuery).GetConstructors().Single();
+ ctor.GetParameters().Select(p => p.Name)
+ .Count(n => n!.Contains("Tenant", StringComparison.OrdinalIgnoreCase))
+ .Should().Be(1);
+ }
+
+ [Fact]
+ public void ElRepositorioNoTraeNingunPaqueteDeFabrica()
+ {
+ // T-056: el producto entrega el MECANISMO de empaquetar, no contenido con nuestra opinion
+ // sobre que debe exigir una compuerta. Un paquete de ejemplo APLICABLE en el arbol seria
+ // esa capa entrando por la puerta de atras, asi que se comprueba que no exista.
+ var raiz = RepoRoot();
+ var sospechosos = Directory
+ .GetFiles(raiz, "*.json", SearchOption.AllDirectories)
+ .Where(f => !f.Contains("/node_modules/") && !f.Contains("/obj/") && !f.Contains("/bin/"))
+ .Where(f =>
+ {
+ var nombre = Path.GetFileName(f).ToLowerInvariant();
+ return nombre.Contains("governance-package") || nombre.Contains("governance_package");
+ })
+ .ToList();
+
+ sospechosos.Should().BeEmpty(
+ "el repositorio no publica paquetes de gobernanza; segun T-056 el contenido lo "
+ + "configura el tenant y no lo cablea el motor");
+ }
+
+ private static string RepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git")))
+ {
+ dir = dir.Parent;
+ }
+ dir.Should().NotBeNull();
+ return dir!.FullName;
+ }
+}