Skip to content
Open
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
56 changes: 56 additions & 0 deletions robosoft/robots/audit-trail.robot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ export default {
check('action is faithful', entry.action === 'probe:performed', { detail: `action=${entry.action}` });
check('the actor is attributed', Boolean(entry.actorId), { detail: `actorId=${entry.actorId}` });
check('the record is timestamped', Boolean(entry.timestampUtc), { detail: `at=${entry.timestampUtc}` });
// GT-603: attribution is not only WHO but WHAT KIND. A human action must say so — a ledger
// that types everything the same way cannot answer "did a person decide this?".
check('a human action is TYPED as human', entry.actorType === 'human', {
detail: `actorType=${entry.actorType}`,
});
check('a human action carries no agent identity', !entry.agentId, {
detail: `agentId=${entry.agentId ?? 'null'}`,
});
recordedActor = entry.actorId;
}

Expand All @@ -77,6 +85,54 @@ export default {
});
}

// ── Agent attribution (GT-603) ──────────────────────────────────────────
// The differentiating claim of the product is that the ledger tells a human
// apart from an agent. Until GT-603 the ONLY path an agent acted through
// (/assistant/converse) persisted nothing at all, so the claim was false in
// code. `audit_entries` is append-only by DB trigger, so every row written
// before the discriminator existed is permanently unattributable — which is
// why this is asserted end to end and not only in unit tests.
step('An agent turn lands in the SAME ledger, typed as an AGENT');
{
const conversation = `robosoft-agent-${RUN}`;
const turn = await api.post('/assistant/converse', {
message: 'RoboSoft attribution probe: read-only turn.',
conversationId: conversation,
});
check('POST /assistant/converse responds 2xx', turn.ok, {
detail: turn.error ? turn.error : `status=${turn.status}`,
});

const list = (await need('GET /audit-entries', api.get('/audit-entries'))).body;
const agentEntry = (Array.isArray(list) ? list : []).find(
(e) => e?.entityType === 'AgentTurn' && e?.sessionId === conversation);

check('the agent turn is in the trail', Boolean(agentEntry), {
detail: agentEntry ? `id=${agentEntry.id}` : 'missing — the agent path wrote nothing',
});
check('it is attributed to an AGENT, not to a human', agentEntry?.actorType === 'agent', {
detail: `actorType=${agentEntry?.actorType}`,
});
check('the acting agent is identified', Boolean(agentEntry?.agentId), {
detail: `agentId=${agentEntry?.agentId ?? 'null'}`,
});
// Both halves survive: WHO acted (the agent) and ON WHOSE BEHALF (the human principal).
check('the human on whose behalf it acted is preserved', Boolean(agentEntry?.actorId), {
detail: `actorId=${agentEntry?.actorId}`,
});
check('the turn declares the scope it used', Boolean(agentEntry?.changes?.scope), {
detail: `scope=${agentEntry?.changes?.scope} action=${agentEntry?.action}`,
});
// The ledger is append-only: a prompt written into it could never be withdrawn.
check('the prompt itself is NOT stored', agentEntry?.changes?.prompt === undefined, {
detail: `keys=${Object.keys(agentEntry?.changes ?? {}).join(',')}`,
});
check('human and agent entries are distinguishable in the SAME trail',
agentEntry?.actorType === 'agent' && recordedActor !== undefined, {
detail: `agent=${agentEntry?.actorType} · human entry actor=${recordedActor}`,
});
}

// ── Tenant scope ────────────────────────────────────────────────────────
step('The audit trail is tenant-scoped — tenant C does not see it');
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
using Tracker.Domain.Audit.AuditEntry;

namespace Tracker.Application.Audit.AuditEntry.Commands.CreateAuditEntry;

/// <summary>
/// GT-603 — <see cref="Actor"/> lo fija el ADAPTADOR que recibe la peticion, nunca el cuerpo: si
/// un cliente pudiera declararse «agente» —o «humano»— la atribucion del expediente valdria lo que
/// valga la palabra de quien escribe en el.
/// </summary>
public sealed record CreateAuditEntryCommand(
Guid TenantId,
string EntityType,
string EntityId,
string Action,
Guid ActorId,
AuditActor Actor,
Dictionary<string, object>? Changes = null,
string? CorrelationId = null
) : ICommand<Guid>;
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public async Task<Result<Guid>> Handle(
request.EntityId,
request.Action,
request.ActorId,
request.Actor,
request.Changes,
request.CorrelationId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ public sealed class AuditEntryDto
public string EntityId { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public Guid ActorId { get; init; }

/// <summary>GT-603 — human / agent / system, o `unknown` si es anterior al discriminador.</summary>
public string ActorType { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? ModelId { get; init; }
public string? SessionId { get; init; }

public DateTime TimestampUtc { get; init; }
public Dictionary<string, object> Changes { get; init; } = new();
public string? CorrelationId { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public GetAuditEntryQueryHandler(Tracker.Domain.Audit.AuditEntry.IAuditEntryRepo
EntityId = entry.EntityId,
Action = entry.Action,
ActorId = entry.ActorId,
ActorType = entry.ActorType,
AgentId = entry.AgentId,
ModelId = entry.ModelId,
SessionId = entry.SessionId,
TimestampUtc = entry.TimestampUtc,
Changes = entry.Changes,
CorrelationId = entry.CorrelationId
Expand Down
11 changes: 11 additions & 0 deletions src/apps/tracker-api/Tracker.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ public static IServiceCollection AddApplication(this IServiceCollection services
services.AddSingleton<IHandshakeAuthenticator, HandshakeAuthenticator>();
services.AddSingleton<IBatchFileParser, BatchFileParser>();

// EAG-16 / GT-603 — el ledger de turnos agenticos. Estaba escrito y probado, y no estaba
// REGISTRADO: `IAgentExecutionPort` no aparecia en ningun contenedor, asi que ninguna
// peticion podia alcanzarlo y ningun turno se escribia. Va aqui, junto al resto de servicios
// de aplicacion, porque ambos tipos son `internal` a este ensamblado; el ejecutor concreto
// que hay al otro lado (`IAgentRuntimeExecutor`) lo ata el host, que es quien conoce el
// runtime — separar las dos cosas es lo que T-010 exige.
services.AddScoped<Integration.AgentExecution.IAgentTurnAuditor,
Integration.AgentExecution.AgentTurnAuditor>();
services.AddScoped<Integration.AgentExecution.IAgentExecutionPort,
Integration.AgentExecution.AgentExecutionService>();

// Repository context builder: assembles the inline satellite evaluation context.
// The provider source-reader resolver is registered by the host (Presentation),
// which owns the HttpClient-backed GitHub reader.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ public async Task<Result<EvaluateGateResult>> Handle(
initiative.Id.ToString(),
"initiative.discovery-gate.passed",
Guid.Empty,
// GT-603 — el veredicto lo produce el motor, no una persona: `Guid.Empty` ya lo decia
// sin poder afirmarlo. Ahora lo afirma.
AuditActor.System(),
changes,
correlationId: initiative.Code);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ private async Task WriteAuditAsync(
initiative.Id.ToString(),
action,
Guid.Empty,
// GT-603 — una aprobacion la decide una persona. Su identidad viaja hoy en
// `approverIdentity` dentro de `changes`; sera un Guid cuando EAG-01 cierre con UMS.
AuditActor.Human(),
changes,
correlationId: initiative.Code);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public async Task WriteAsync(
submission.Id.ToString(),
action,
actorId,
// GT-603 — `actorId` viene del contexto de usuario autenticado del endpoint.
AuditActor.Human(),
changes,
correlationId: submission.Id.ToString());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public async Task WriteAsync(
initiative.Id.ToString(),
action,
actorId,
// GT-603 — el intake lo mueve una persona a traves de la API; el canal de maquina
// (PPM) tiene su propio escritor. Si eso deja de ser cierto, este es el sitio a tocar.
AuditActor.Human(),
changes,
correlationId: initiative.ExternalId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ public async Task<Result<AgentTurnResult>> ExecuteAsync(
{
ArgumentNullException.ThrowIfNull(request);

// 0) GT-603 — sin identidad de agente el turno no es ATRIBUIBLE, y se rechaza antes que
// nada: incluso el asiento de rechazo necesita decir quien lo intento. El expediente es
// append-only, asi que una fila mal atribuida no se corrige nunca; no escribirla y
// negar el turno es la unica salida que no deja evidencia falsa.
if (string.IsNullOrWhiteSpace(request.AgentId))
{
return Result<AgentTurnResult>.Failure("AgentExecution.UnattributableTurn");
}

// 1) El scope tiene que existir. Un scope inventado no es «uno que no reconocemos y por
// tanto inofensivo»: es una peticion que no sabemos evaluar, y no saber evaluarla es
// razon suficiente para rechazarla.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ Task<Result<Guid>> RecordAsync(
AgentTurnRequest request, string outcome, string? failureReason, CancellationToken ct = default);
}

internal sealed class AgentTurnAuditor(IAuditEntryRepository auditRepository) : IAgentTurnAuditor
internal sealed class AgentTurnAuditor(
IAuditEntryRepository auditRepository,
IUnitOfWork unitOfWork) : IAgentTurnAuditor
{
/// <summary>Tipo de entidad con el que los turnos agénticos se distinguen en el expediente.</summary>
public const string EntityType = "AgentTurn";
Expand Down Expand Up @@ -47,12 +49,16 @@ public async Task<Result<Guid>> RecordAsync(
// El PROMPT NO se guarda, sólo su longitud. Puede arrastrar datos del cliente, y el
// expediente es append-only: lo que entre ahi no se puede retirar despues. Guardar por si
// acaso algo que no se puede borrar es como se construye una fuga permanente.
//
// GT-603 — la ENTIDAD del asiento es la sesion: es lo que permite reconstruir un hilo
// completo («todos los turnos de esta conversacion») en vez de asientos sueltos.
var entry = AuditEntry.Create(
request.TenantId,
EntityType,
request.CorrelationId ?? Guid.NewGuid().ToString(),
request.SessionId ?? request.CorrelationId ?? Guid.NewGuid().ToString(),
$"agent:{request.Scope}",
request.ActorId,
AuditActor.Agent(request.AgentId, request.ModelId, request.SessionId),
cambios,
request.CorrelationId);

Expand All @@ -61,7 +67,22 @@ public async Task<Result<Guid>> RecordAsync(
return Result<Guid>.Failure(entry.Error);
}

await auditRepository.AddAsync(entry.Value!, ct);
// GT-603 — se CONFIRMA aqui. `AddAsync` sobre un repositorio EF no escribe nada por si
// solo: sin este `Save`, el servicio comprobaba que el asiento «se pudo registrar» contra
// un cambio que aun vivia en memoria y que nadie confirmaba despues — la garantia de
// «si no se puede registrar, no se ejecuta» era, literalmente, no ejecutar nada.
try
{
await auditRepository.AddAsync(entry.Value!, ct);
await unitOfWork.SaveEntitiesAsync(ct);
}
catch (Exception ex)
{
// Un fallo al escribir el expediente NO se propaga como excepcion: es una respuesta
// legitima del auditor, y el servicio la traduce en «este turno no ocurre».
return Result<Guid>.Failure($"AgentTurnAuditor.WriteFailed: {ex.Message}");
}

return Result<Guid>.Success(entry.Value!.Id);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,37 @@ public interface IAgentExecutionPort

/// <summary>
/// Un turno: quién, en nombre de qué tenant, con qué scope y sobre qué.
///
/// <para>GT-603 — <see cref="AgentId"/> es obligatorio y va junto al <see cref="ActorId"/> humano,
/// no en su lugar: un turno tiene SIEMPRE dos partes —la persona en cuyo nombre se actúa y el
/// ejecutor que actúa— y colapsarlas es exactamente lo que hacía que el expediente no pudiera
/// distinguir un acto humano de uno agéntico.</para>
/// </summary>
public sealed record AgentTurnRequest(
Guid TenantId,
Guid ActorId,
string AgentId,
string Scope,
string Prompt,
IReadOnlyList<string> GrantedScopes,
string? CorrelationId = null);
string? ModelId = null,
string? SessionId = null,
string? CorrelationId = null,
AgentTurnFraming? Framing = null);

/// <summary>
/// Encuadre del turno: dónde ocurre y con qué parámetros. Es opaco para la gobernanza —ni los
/// scopes ni la auditoría lo leen— y existe para que atar el puerto delante del runtime no obligue
/// a perder por el camino el contexto que la superficie conversacional ya sabía transmitir.
/// </summary>
public sealed record AgentTurnFraming(
string? ProductId = null,
string? InitiativeId = null,
string? Phase = null,
string? Gate = null,
Guid? TenantId = null,
IReadOnlyDictionary<string, object?>? Parameters = null,
bool? DryRun = null);

/// <summary>
/// Resultado del turno más el identificador del asiento que lo registra. Devolver el asiento no es
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ internal sealed class GateDecisionPublisher(
var asiento = Tracker.Domain.Audit.AuditEntry.AuditEntry.Create(
tenantId, "GateDecision", gateDecisionId.ToString(),
"publication:skipped", Guid.Empty,
// GT-603 — no publicar lo decide la plataforma al no poder resolver el componente.
Tracker.Domain.Audit.AuditEntry.AuditActor.System(),
new Dictionary<string, object>
{
["reason"] = "ComponentNotResolvable",
Expand Down
93 changes: 93 additions & 0 deletions src/apps/tracker-api/Tracker.Domain/Audit/AuditEntry/AuditActor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
namespace Tracker.Domain.Audit.AuditEntry;

/// <summary>
/// GT-603 — <b>quién escribió el asiento: una persona, un agente o la plataforma.</b>
///
/// <para><b>Por qué esto no podía esperar.</b> <c>audit_entries</c> es append-only por trigger de
/// base de datos (`COH-014`): una fila escrita sin discriminador no se puede tipar después, porque
/// el <c>UPDATE</c> que lo haría está prohibido. Cada asiento escrito antes de que esta columna
/// existiera queda inatribuible PARA SIEMPRE. Es la única deuda del expediente que no se paga más
/// cara con el tiempo: simplemente deja de poder pagarse.</para>
///
/// <para><b><c>Unknown</c> no es un tipo de actor: es la ausencia de uno.</b> Existe únicamente
/// para representar lo que ya estaba en la tabla cuando llegó la migración, y por eso
/// <see cref="Writable"/> lo excluye — ningún camino de escritura puede producirlo. Decir «no
/// consta» sobre una fila antigua es cierto; decir «lo hizo una persona» sería inventar evidencia
/// en el registro que precisamente se presenta como evidencia.</para>
/// </summary>
public static class AuditActorType
{
/// <summary>Una persona identificada actuó.</summary>
public const string Human = "human";

/// <summary>Un ejecutor no humano actuó dentro de un scope concedido (EAG-16).</summary>
public const string Agent = "agent";

/// <summary>La plataforma actuó sin que nadie se lo pidiera: reintentos, drenajes, veredictos.</summary>
public const string System = "system";

/// <summary>
/// Escrito ANTES de que el discriminador existiera. Reservado a la migración; ninguna ruta de
/// escritura lo admite.
/// </summary>
public const string Unknown = "unknown";

/// <summary>Lo que una escritura nueva puede declarar. <see cref="Unknown"/> no está, a propósito.</summary>
public static readonly string[] Writable = [Human, Agent, System];

public static bool IsWritable(string? actorType) =>
actorType is not null && Writable.Contains(actorType, StringComparer.Ordinal);
}

/// <summary>
/// La atribución completa de un asiento. Para un agente incluye su identidad, el modelo que lo
/// respalda y la sesión en la que actuó: sin esos tres, «lo hizo un agente» es una etiqueta que no
/// permite ir a preguntar a nadie.
/// </summary>
public sealed record AuditActor
{
private AuditActor(string type, string? agentId, string? modelId, string? sessionId)
{
Type = type;
AgentId = agentId;
ModelId = modelId;
SessionId = sessionId;
}

public string Type { get; }

/// <summary>Identidad del ejecutor no humano. Sólo para <see cref="AuditActorType.Agent"/>.</summary>
public string? AgentId { get; }

/// <summary>Modelo que respaldó el turno, cuando se conoce.</summary>
public string? ModelId { get; }

/// <summary>Sesión/conversación que agrupa los turnos de un mismo hilo.</summary>
public string? SessionId { get; }

public static AuditActor Human() => new(AuditActorType.Human, null, null, null);

public static AuditActor System() => new(AuditActorType.System, null, null, null);

/// <summary>
/// Un turno agéntico. El <paramref name="agentId"/> es obligatorio: un agente sin identidad no
/// es atribuible, y un asiento no atribuible no es evidencia — vale lo mismo que no escribirlo.
/// </summary>
public static AuditActor Agent(string agentId, string? modelId = null, string? sessionId = null)
{
if (string.IsNullOrWhiteSpace(agentId))
{
throw new ArgumentException(
"un turno agentico sin identidad de agente no es atribuible", nameof(agentId));
}

return new AuditActor(AuditActorType.Agent, agentId, modelId, sessionId);
}

/// <summary>
/// Rehidratación desde el almacén. Es la ÚNICA vía por la que <see cref="AuditActorType.Unknown"/>
/// entra en el modelo: se lee de filas anteriores a la migración, nunca se escribe.
/// </summary>
public static AuditActor FromStorage(string type, string? agentId, string? modelId, string? sessionId)
=> new(type, agentId, modelId, sessionId);
}
Loading
Loading