Validate runtime receipt linkage

This commit is contained in:
Meta Vens 2026-06-17 11:22:29 +01:00
commit f58a0cbc1b
8 changed files with 467 additions and 14 deletions

View file

@ -15,6 +15,9 @@ public sealed class AgentDispatchRunService(
Guid? startedByUserAccountId,
CancellationToken cancellationToken)
{
ValidateStartRequest(request);
await EnsureLinkedTransportRequestAsync(request, cancellationToken);
var now = timeProvider.GetUtcNow();
var run = new AgentDispatchRun
{
@ -126,6 +129,76 @@ public sealed class AgentDispatchRunService(
return AgentDispatchRunResult.From(run);
}
private async Task EnsureLinkedTransportRequestAsync(
AgentDispatchRunStartRequest request,
CancellationToken cancellationToken)
{
var linkedReceipt = await dbContext.AgentTransportReceipts
.AsNoTracking()
.Where(x => x.RequestId == request.RequestId)
.OrderByDescending(x => x.OccurredAtUtc)
.FirstOrDefaultAsync(cancellationToken);
if (linkedReceipt is null)
{
throw new AgentDispatchRunException(
$"Dispatch runs require an existing request-lane receipt for request {request.RequestId}.");
}
EnsureSameIdentity(
"dispatch run target repo",
request.TargetRepoName,
"request-lane target repo",
linkedReceipt.TargetRepoName);
EnsureSameIdentity(
"dispatch run target repository",
request.TargetRepositoryFullName,
"request-lane target repository",
linkedReceipt.TargetRepositoryFullName);
EnsureSameIdentity(
"dispatch run target agent",
request.TargetAgentIdentity,
"request-lane target agent",
linkedReceipt.TargetAgentIdentity);
}
private static void ValidateStartRequest(AgentDispatchRunStartRequest request)
{
if (string.IsNullOrWhiteSpace(request.RequestId))
{
throw new AgentDispatchRunException("Dispatch runs require a request id.");
}
if (string.IsNullOrWhiteSpace(request.TargetRepoName))
{
throw new AgentDispatchRunException("Dispatch runs require a target repo name.");
}
if (string.IsNullOrWhiteSpace(request.LaunchMode))
{
throw new AgentDispatchRunException("Dispatch runs require a launch mode.");
}
}
private static void EnsureSameIdentity(
string currentLabel,
string currentValue,
string linkedLabel,
string linkedValue)
{
var normalizedCurrent = NormalizeText(currentValue);
var normalizedLinked = NormalizeText(linkedValue);
if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked))
{
return;
}
if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase))
{
throw new AgentDispatchRunException($"{currentLabel} does not match the linked {linkedLabel}.");
}
}
private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty;
}
@ -194,3 +267,5 @@ public sealed record AgentDispatchRunResult(
run.UpdatedAtUtc,
run.CompletedAtUtc);
}
public sealed class AgentDispatchRunException(string message) : InvalidOperationException(message);

View file

@ -1,6 +1,7 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.Bridge;
@ -14,6 +15,9 @@ public sealed class AgentTransportReceiptService(
Guid? actorUserAccountId,
CancellationToken cancellationToken)
{
ValidateRequest(request);
await EnsureConsistentIdentityAsync(request, cancellationToken);
var receipt = new AgentTransportReceipt
{
RequestId = NormalizeText(request.RequestId),
@ -42,6 +46,103 @@ public sealed class AgentTransportReceiptService(
return AgentTransportReceiptResult.From(receipt);
}
private async Task EnsureConsistentIdentityAsync(
AgentTransportReceiptRequest request,
CancellationToken cancellationToken)
{
var priorReceipt = await dbContext.AgentTransportReceipts
.AsNoTracking()
.Where(x => x.RequestId == request.RequestId)
.OrderByDescending(x => x.OccurredAtUtc)
.FirstOrDefaultAsync(cancellationToken);
if (priorReceipt is not null)
{
EnsureSameIdentity(
"request-lane target repo",
request.TargetRepoName,
"prior request-lane target repo",
priorReceipt.TargetRepoName);
EnsureSameIdentity(
"request-lane target repository",
request.TargetRepositoryFullName,
"prior request-lane target repository",
priorReceipt.TargetRepositoryFullName);
EnsureSameIdentity(
"request-lane target agent",
request.TargetAgentIdentity,
"prior request-lane target agent",
priorReceipt.TargetAgentIdentity);
}
var linkedRun = await dbContext.AgentDispatchRuns
.AsNoTracking()
.Where(x => x.RequestId == request.RequestId)
.OrderByDescending(x => x.StartedAtUtc)
.FirstOrDefaultAsync(cancellationToken);
if (linkedRun is not null)
{
EnsureSameIdentity(
"request-lane target repo",
request.TargetRepoName,
"dispatch-run target repo",
linkedRun.TargetRepoName);
EnsureSameIdentity(
"request-lane target repository",
request.TargetRepositoryFullName,
"dispatch-run target repository",
linkedRun.TargetRepositoryFullName);
EnsureSameIdentity(
"request-lane target agent",
request.TargetAgentIdentity,
"dispatch-run target agent",
linkedRun.TargetAgentIdentity);
}
}
private static void ValidateRequest(AgentTransportReceiptRequest request)
{
if (string.IsNullOrWhiteSpace(request.RequestId))
{
throw new AgentTransportReceiptException("Request-lane receipts require a request id.");
}
if (string.IsNullOrWhiteSpace(request.Title))
{
throw new AgentTransportReceiptException("Request-lane receipts require a title.");
}
if (string.IsNullOrWhiteSpace(request.TargetRepoName))
{
throw new AgentTransportReceiptException("Request-lane receipts require a target repo name.");
}
if (string.IsNullOrWhiteSpace(request.ActorName))
{
throw new AgentTransportReceiptException("Request-lane receipts require an actor name.");
}
}
private static void EnsureSameIdentity(
string currentLabel,
string currentValue,
string linkedLabel,
string linkedValue)
{
var normalizedCurrent = NormalizeText(currentValue);
var normalizedLinked = NormalizeText(linkedValue);
if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked))
{
return;
}
if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase))
{
throw new AgentTransportReceiptException($"{currentLabel} does not match the linked {linkedLabel}.");
}
}
private static string BuildAuditDetail(AgentTransportReceipt receipt)
{
var actorName = string.IsNullOrWhiteSpace(receipt.ActorName) ? "unknown actor" : receipt.ActorName;
@ -89,3 +190,5 @@ public sealed record AgentTransportReceiptResult(
receipt.Note,
receipt.OccurredAtUtc);
}
public sealed class AgentTransportReceiptException(string message) : InvalidOperationException(message);

View file

@ -1,6 +1,7 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.Bridge;
@ -14,6 +15,9 @@ public sealed class GovernanceActivityReceiptService(
Guid? actorUserAccountId,
CancellationToken cancellationToken)
{
ValidateRequest(request);
await EnsureLinkedDispatchRequestAsync(request, cancellationToken);
var receipt = new GovernanceActivityReceipt
{
TopicId = NormalizeText(request.TopicId),
@ -43,6 +47,86 @@ public sealed class GovernanceActivityReceiptService(
return GovernanceActivityReceiptResult.From(receipt);
}
private async Task EnsureLinkedDispatchRequestAsync(
GovernanceActivityReceiptRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.DispatchRequestId))
{
return;
}
var linkedReceipt = await dbContext.AgentTransportReceipts
.AsNoTracking()
.Where(x => x.RequestId == request.DispatchRequestId)
.OrderByDescending(x => x.OccurredAtUtc)
.FirstOrDefaultAsync(cancellationToken);
if (linkedReceipt is null)
{
throw new GovernanceActivityReceiptException(
$"Governance receipt references unknown dispatch request {request.DispatchRequestId}.");
}
EnsureSameIdentity(
"governance jurisdiction repo",
request.JurisdictionRepoName,
"dispatch request repo",
linkedReceipt.TargetRepoName);
EnsureSameIdentity(
"governance jurisdiction agent",
request.JurisdictionAgentIdentity,
"dispatch request agent",
linkedReceipt.TargetAgentIdentity);
}
private static void ValidateRequest(GovernanceActivityReceiptRequest request)
{
if (string.IsNullOrWhiteSpace(request.TopicId))
{
throw new GovernanceActivityReceiptException("Governance receipts require a topic id.");
}
if (string.IsNullOrWhiteSpace(request.Title))
{
throw new GovernanceActivityReceiptException("Governance receipts require a title.");
}
if (string.IsNullOrWhiteSpace(request.JurisdictionRepoName))
{
throw new GovernanceActivityReceiptException("Governance receipts require a jurisdiction repo name.");
}
if (string.IsNullOrWhiteSpace(request.ActorKind))
{
throw new GovernanceActivityReceiptException("Governance receipts require an actor kind.");
}
if (string.IsNullOrWhiteSpace(request.ActorName))
{
throw new GovernanceActivityReceiptException("Governance receipts require an actor name.");
}
}
private static void EnsureSameIdentity(
string currentLabel,
string currentValue,
string linkedLabel,
string linkedValue)
{
var normalizedCurrent = NormalizeText(currentValue);
var normalizedLinked = NormalizeText(linkedValue);
if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked))
{
return;
}
if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase))
{
throw new GovernanceActivityReceiptException($"{currentLabel} does not match the linked {linkedLabel}.");
}
}
private static string ToAuditSuffix(GovernanceActivityReceiptKind activityKind) => activityKind switch
{
GovernanceActivityReceiptKind.TopicOpened => "topic-opened",
@ -102,3 +186,5 @@ public sealed record GovernanceActivityReceiptResult(
receipt.Note,
receipt.OccurredAtUtc);
}
public sealed class GovernanceActivityReceiptException(string message) : InvalidOperationException(message);

View file

@ -490,8 +490,15 @@ dispatchRuns.MapPost("/start", async (
return Results.Unauthorized();
}
var result = await agentDispatchRunService.StartAsync(command, null, cancellationToken);
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
try
{
var result = await agentDispatchRunService.StartAsync(command, null, cancellationToken);
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
}
catch (AgentDispatchRunException error)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
}
});
dispatchRuns.MapPost("/{id:guid}/complete", async (
@ -507,10 +514,17 @@ dispatchRuns.MapPost("/{id:guid}/complete", async (
return Results.Unauthorized();
}
var result = await agentDispatchRunService.CompleteAsync(id, command, null, cancellationToken);
return result is null
? Results.NotFound()
: JsonResult(result, appJsonSerializerOptions);
try
{
var result = await agentDispatchRunService.CompleteAsync(id, command, null, cancellationToken);
return result is null
? Results.NotFound()
: JsonResult(result, appJsonSerializerOptions);
}
catch (AgentDispatchRunException error)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
}
});
dispatchRuns.MapPost("/{id:guid}/fail", async (
@ -526,10 +540,17 @@ dispatchRuns.MapPost("/{id:guid}/fail", async (
return Results.Unauthorized();
}
var result = await agentDispatchRunService.FailAsync(id, command, null, cancellationToken);
return result is null
? Results.NotFound()
: JsonResult(result, appJsonSerializerOptions);
try
{
var result = await agentDispatchRunService.FailAsync(id, command, null, cancellationToken);
return result is null
? Results.NotFound()
: JsonResult(result, appJsonSerializerOptions);
}
catch (AgentDispatchRunException error)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
}
});
var transportReceipts = app.MapGroup("/transport/receipts");
@ -546,8 +567,15 @@ transportReceipts.MapPost("", async (
return Results.Unauthorized();
}
var result = await agentTransportReceiptService.RecordAsync(command, null, cancellationToken);
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
try
{
var result = await agentTransportReceiptService.RecordAsync(command, null, cancellationToken);
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
}
catch (AgentTransportReceiptException error)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
}
});
var governanceReceipts = app.MapGroup("/governance/receipts");
@ -564,8 +592,15 @@ governanceReceipts.MapPost("", async (
return Results.Unauthorized();
}
var result = await governanceActivityReceiptService.RecordAsync(command, null, cancellationToken);
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
try
{
var result = await governanceActivityReceiptService.RecordAsync(command, null, cancellationToken);
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
}
catch (GovernanceActivityReceiptException error)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
}
});
var eveGovernance = app.MapGroup("/eve/governance")