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
Expand Up @@ -26,6 +26,10 @@ private RuntimeApproval(RuntimeApprovalProps props) : base(props) { }
public string? CorrelationId => Props.CorrelationId;
public string? RequestedBy => Props.RequestedBy;
public string? ExecutionMode => Props.ExecutionMode;

/// <summary>GT-590 — el objeto bajo decisión, cuando el runtime lo declaró. Nulo si no.</summary>
public RuntimeApprovalSubject? Subject => Props.Subject;

public string Status => Props.Status;
public string? Approver => Props.Approver;
public string? Reason => Props.Reason;
Expand All @@ -43,7 +47,8 @@ public static Result<RuntimeApproval> Submit(
string? initiativeId = null,
string? correlationId = null,
string? requestedBy = null,
string? executionMode = null)
string? executionMode = null,
RuntimeApprovalSubject? subject = null)
{
if (tenantId == Guid.Empty) return Result<RuntimeApproval>.Failure("RuntimeApproval.TenantRequired");
if (string.IsNullOrWhiteSpace(skillId)) return Result<RuntimeApproval>.Failure("RuntimeApproval.SkillRequired");
Expand All @@ -63,6 +68,7 @@ public static Result<RuntimeApproval> Submit(
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId,
RequestedBy = requestedBy,
ExecutionMode = executionMode,
Subject = subject,
Status = RuntimeApprovalStatus.Pending,
RequestedAtUtc = now,
ExpiresAtUtc = now.Add(ttl),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ public sealed record RuntimeApprovalProps : IProps

public string? ExecutionMode { get; init; }

/// <summary>
/// GT-590 — QUÉ se decide, cuando `skillId` + `intent` no lo dicen. Nulo en toda aprobación
/// abierta por un runtime anterior a GT-590, y nulo sigue siendo válido: el sujeto es opcional
/// en el contrato del Core y hacerlo obligatorio aquí rompería a los runtimes desplegados.
/// </summary>
public RuntimeApprovalSubject? Subject { get; init; }

public string Status { get; init; } = RuntimeApprovalStatus.Pending;

/// <summary>Humano que resolvio. Un `approved` sin aprobador deja el ledger sin responsable.</summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using System.Text.Json;

namespace Tracker.Domain.Governance.RuntimeApproval;

/// <summary>
/// GT-590 — QUÉ se está decidiendo, cuando el id de la capacidad no lo dice.
///
/// Hasta aqui una <see cref="RuntimeApproval"/> llevaba solo `skillId` + `intent`, asi que TODA
/// decision tomada a traves de una misma capacidad se veia igual: igual para el humano que la
/// concede e igual en el ledger que la registra. Eso basta para «¿puede ejecutarse esta
/// capacidad?» y NO basta para «¿es ESTA la correspondencia correcta?» —una decision de gobierno
/// sobre un objeto concreto, que es lo que el Core empezo a enrutar por esta compuerta.
///
/// El contrato lo fija el Core en `approval.port.ts` (`ApprovalSubject`) y aqui se ESPEJA, no se
/// reinterpreta:
/// · <c>Kind</c> — familia del sujeto, con espacio de nombres de la capacidad que la introduce.
/// · <c>Ref</c> — referencia estable al objeto concreto, unica dentro de su <c>Kind</c>.
/// · <c>Summary</c> — una linea sobre la que un humano puede decidir sin abrir nada mas.
/// · <c>Confidence</c> — confianza de la propuesta que se ratifica, si vino de un proponente
/// probabilistico. Existe para DECIRLE al humano que esta ratificando una CONJETURA.
/// · <c>PayloadJson</c> — opaco. Se guarda y se devuelve verbatim; el Tracker NUNCA lo
/// interpreta, igual que el Core no lo interpreta.
///
/// Todo el sujeto es OPCIONAL: las versiones del runtime que ya estan desplegadas no lo envian y
/// deben seguir funcionando exactamente igual. Pero un sujeto PRESENTE y a medias se rechaza en
/// vez de recortarse: si llega un <c>kind</c> sin <c>summary</c>, el humano decidiria a ciegas
/// creyendo que ve el objeto, que es peor que no ver nada. Ausente es ausente; roto es un 400.
/// </summary>
public sealed record RuntimeApprovalSubject
{
public const int MaxKindLength = 100;
public const int MaxRefLength = 500;
public const int MaxSummaryLength = 1000;

/// <summary>
/// El payload viaja a una columna jsonb de una fila de auditoria, no a un almacen de objetos.
/// El tope existe para que un adjunto desmedido no convierta el ledger en un blobstore.
/// </summary>
public const int MaxPayloadLength = 16 * 1024;

private RuntimeApprovalSubject(
string kind, string reference, string summary, double? confidence, string? payloadJson)
{
Kind = kind;
Ref = reference;
Summary = summary;
Confidence = confidence;
PayloadJson = payloadJson;
}

/// <summary>Familia del sujeto, p. ej. <c>c4-binding</c>. Indexable.</summary>
public string Kind { get; }

/// <summary>Referencia estable al objeto bajo decision, unica dentro de <see cref="Kind"/>.</summary>
public string Ref { get; }

/// <summary>La linea que el humano lee para decidir.</summary>
public string Summary { get; }

/// <summary>Confianza en [0,1] cuando la propuesta es probabilistica; nula si no lo es.</summary>
public double? Confidence { get; }

/// <summary>JSON opaco tal cual llego. Nunca se interpreta aqui.</summary>
public string? PayloadJson { get; }

/// <summary>
/// Valida y construye. <paramref name="payloadJson"/> se exige OBJETO JSON porque el contrato
/// del Core lo declara <c>Record&lt;string, unknown&gt;</c>; admitir un escalar o un array
/// dejaria pasar formas que ninguna superficie de lectura sabe mostrar.
/// </summary>
public static Result<RuntimeApprovalSubject> Create(
string? kind,
string? reference,
string? summary,
double? confidence = null,
string? payloadJson = null)
{
if (string.IsNullOrWhiteSpace(kind))
return Result<RuntimeApprovalSubject>.Failure("RuntimeApproval.Subject.KindRequired");
if (kind.Length > MaxKindLength)
return Result<RuntimeApprovalSubject>.Failure("RuntimeApproval.Subject.KindTooLong");

if (string.IsNullOrWhiteSpace(reference))
return Result<RuntimeApprovalSubject>.Failure("RuntimeApproval.Subject.RefRequired");
if (reference.Length > MaxRefLength)
return Result<RuntimeApprovalSubject>.Failure("RuntimeApproval.Subject.RefTooLong");

// Sin resumen el sujeto es un identificador opaco y el humano vuelve a decidir a ciegas,
// que es exactamente lo que este campo existe para evitar.
if (string.IsNullOrWhiteSpace(summary))
return Result<RuntimeApprovalSubject>.Failure("RuntimeApproval.Subject.SummaryRequired");
if (summary.Length > MaxSummaryLength)
return Result<RuntimeApprovalSubject>.Failure("RuntimeApproval.Subject.SummaryTooLong");

// Fuera de [0,1] no es «poco fiable»: es que quien la emitio no esta hablando de una
// probabilidad, y mostrarla como tal enganaria a quien decide.
if (confidence is not null &&
(double.IsNaN(confidence.Value) || confidence.Value < 0 || confidence.Value > 1))
{
return Result<RuntimeApprovalSubject>.Failure("RuntimeApproval.Subject.ConfidenceOutOfRange");
}

string? payload = null;
if (!string.IsNullOrWhiteSpace(payloadJson))
{
if (payloadJson.Length > MaxPayloadLength)
return Result<RuntimeApprovalSubject>.Failure("RuntimeApproval.Subject.PayloadTooLarge");

// Se valida AQUI y no en la base: un jsonb invalido reventaria en `SaveChanges` como
// un 500 opaco, cuando lo cierto es que el cuerpo de la peticion venia mal.
try
{
using var parsed = JsonDocument.Parse(payloadJson);
if (parsed.RootElement.ValueKind != JsonValueKind.Object)
return Result<RuntimeApprovalSubject>.Failure("RuntimeApproval.Subject.PayloadNotAnObject");
}
catch (JsonException)
{
return Result<RuntimeApprovalSubject>.Failure("RuntimeApproval.Subject.PayloadNotJson");
}

payload = payloadJson;
}

return Result<RuntimeApprovalSubject>.Success(
new RuntimeApprovalSubject(kind.Trim(), reference.Trim(), summary.Trim(), confidence, payload));
}

/// <summary>
/// Rehidrata desde persistencia sin revalidar: la fila ya paso por <see cref="Create"/>.
/// Devuelve <c>null</c> cuando la fila no lleva sujeto —el caso de toda aprobacion abierta por
/// un runtime anterior a GT-590, que debe releerse igual que siempre.
/// </summary>
public static RuntimeApprovalSubject? Rehydrate(
string? kind, string? reference, string? summary, double? confidence, string? payloadJson)
{
// La ausencia se decide por `kind`: es el unico campo que ninguna fila con sujeto puede
// tener vacio. Una fila a medias se lee como SIN sujeto en vez de fabricar uno inventado.
if (string.IsNullOrWhiteSpace(kind) ||
string.IsNullOrWhiteSpace(reference) ||
string.IsNullOrWhiteSpace(summary))
{
return null;
}

return new RuntimeApprovalSubject(kind, reference, summary, confidence, payloadJson);
}
}
Loading
Loading