Receipt governance activity in Bifrost

This commit is contained in:
Meta Vens 2026-06-17 05:21:29 +01:00
commit cab9d22f78
15 changed files with 2600 additions and 3 deletions

View file

@ -75,6 +75,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
- set `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` when `tools/bifrost-bridge.mjs` should round-trip every action through the app ledger before mutating GitHub, Discord, or Reddit
- the same bridge URL/token pair lets `tools/dispatch-agent-requests.mjs` record `/dispatch/runs/*` receipts for launched Codex work, so Bifrost keeps a live run trail even when a turn fails
- `tools/agent-transport.mjs` also uses that bridge configuration to record `/transport/receipts` entries for queue, claim, release, and close events on the request lane
- `tools/governance-threads.mjs` uses that same bridge configuration to record `/governance/receipts` entries for topic opens, comments, approvals, and promotions
- GitHub bridge mutations now fail closed without those values; `--allow-ungated-github true` is reserved for explicit operator recovery
- set `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optionally `BIFROST_REDDIT_CLIENT_SECRET` before posting Reddit organizing threads
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production

View file

@ -71,6 +71,12 @@ The hosted app also exposes a request-lane receipt ledger for transport lifecycl
This covers the activity that happens before or around a visible Codex turn: queueing a request, claiming it, releasing it, and closing it. The `.cc` packet is still the transport document. The hosted receipt row is the operator-facing witness that the activity happened.
The hosted app also exposes a governance activity receipt ledger:
- `POST /governance/receipts`
This covers the other side of the swarm's work: topic opens, comments, approvals, and promotions to dispatch. The topic and comment documents are still the governance substrate. The hosted receipt row is the Bifrost-side witness that an agent or operator performed the governance action.
Current gate behavior:
- active Bifrost members may request bridge actions from an authenticated app session
@ -88,6 +94,7 @@ Current CLI wiring:
- the dispatcher now preloads `BIFROST_BRIDGE_SOURCE_KIND`, `BIFROST_BRIDGE_SOURCE_ID`, and `BIFROST_BRIDGE_AUTHORITY_REF` into Codex turns launched from Bifrost update requests, so normal bridge use inside a dispatched turn carries request provenance by default
- the dispatcher also uses that same bridge configuration to post dispatch-run start/complete/fail receipts, including the worker pid, thread/turn ids when available, and result/log paths for the local operator trail
- the agent transport CLI uses that same bridge configuration to post request-lane receipts for queue, claim, release, and close events, so Bifrost does not go blind between intake and dispatch
- the governance threads CLI uses that same bridge configuration to post governance receipts for topic opens, comments, approvals, and promotions, so Bifrost does not lose the pre-dispatch reasoning trail
If the app bridge configuration is absent, `tools/bifrost-bridge.mjs` keeps working as a local actuator. When configured, it asks Bifrost for authorization before acting and reports success or failure back to the same bridge action row.

View file

@ -59,6 +59,8 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<AgentTransportReceipt> AgentTransportReceipts => Set<AgentTransportReceipt>();
public DbSet<GovernanceActivityReceipt> GovernanceActivityReceipts => Set<GovernanceActivityReceipt>();
public DbSet<AuditEvent> AuditEvents => Set<AuditEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
@ -475,6 +477,26 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<GovernanceActivityReceipt>(entity =>
{
entity.Property(x => x.TopicId).HasMaxLength(120);
entity.Property(x => x.CommentId).HasMaxLength(120);
entity.Property(x => x.DispatchRequestId).HasMaxLength(240);
entity.Property(x => x.Title).HasMaxLength(240);
entity.Property(x => x.JurisdictionRepoName).HasMaxLength(120);
entity.Property(x => x.JurisdictionAgentIdentity).HasMaxLength(160);
entity.Property(x => x.ActivityKind).HasConversion<string>();
entity.Property(x => x.ActorKind).HasMaxLength(40);
entity.Property(x => x.ActorName).HasMaxLength(160);
entity.HasIndex(x => x.TopicId);
entity.HasIndex(x => x.OccurredAtUtc);
entity.HasIndex(x => new { x.JurisdictionRepoName, x.JurisdictionAgentIdentity, x.ActivityKind });
entity.HasOne(x => x.ActorUserAccount)
.WithMany(x => x.GovernanceActivityReceipts)
.HasForeignKey(x => x.ActorUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<AuditEvent>(entity =>
{
entity.Property(x => x.EntityType).HasMaxLength(120);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
public partial class AddGovernanceActivityReceipts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GovernanceActivityReceipts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TopicId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
CommentId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
DispatchRequestId = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
JurisdictionRepoName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
JurisdictionAgentIdentity = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
ActivityKind = table.Column<string>(type: "text", nullable: false),
ActorKind = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
ActorName = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
Note = table.Column<string>(type: "text", nullable: false),
ActorUserAccountId = table.Column<Guid>(type: "uuid", nullable: true),
OccurredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GovernanceActivityReceipts", x => x.Id);
table.ForeignKey(
name: "FK_GovernanceActivityReceipts_UserAccounts_ActorUserAccountId",
column: x => x.ActorUserAccountId,
principalTable: "UserAccounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_GovernanceActivityReceipts_ActorUserAccountId",
table: "GovernanceActivityReceipts",
column: "ActorUserAccountId");
migrationBuilder.CreateIndex(
name: "IX_GovernanceActivityReceipts_JurisdictionRepoName_Jurisdictio~",
table: "GovernanceActivityReceipts",
columns: new[] { "JurisdictionRepoName", "JurisdictionAgentIdentity", "ActivityKind" });
migrationBuilder.CreateIndex(
name: "IX_GovernanceActivityReceipts_OccurredAtUtc",
table: "GovernanceActivityReceipts",
column: "OccurredAtUtc");
migrationBuilder.CreateIndex(
name: "IX_GovernanceActivityReceipts_TopicId",
table: "GovernanceActivityReceipts",
column: "TopicId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GovernanceActivityReceipts");
}
}
}

View file

@ -494,6 +494,79 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("GitHubPullRequestLinks");
});
modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ActivityKind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ActorKind")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("ActorName")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<Guid?>("ActorUserAccountId")
.HasColumnType("uuid");
b.Property<string>("CommentId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("DispatchRequestId")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("JurisdictionAgentIdentity")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("JurisdictionRepoName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("TopicId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.HasKey("Id");
b.HasIndex("ActorUserAccountId");
b.HasIndex("OccurredAtUtc");
b.HasIndex("TopicId");
b.HasIndex("JurisdictionRepoName", "JurisdictionAgentIdentity", "ActivityKind");
b.ToTable("GovernanceActivityReceipts");
});
modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b =>
{
b.Property<Guid>("Id")
@ -1509,6 +1582,16 @@ namespace Bifrost.Web.Data.Migrations
b.Navigation("WorkItem");
});
modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b =>
{
b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount")
.WithMany("GovernanceActivityReceipts")
.HasForeignKey("ActorUserAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ActorUserAccount");
});
modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b =>
{
b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount")
@ -1900,6 +1983,8 @@ namespace Bifrost.Web.Data.Migrations
b.Navigation("CreatedMotions");
b.Navigation("GovernanceActivityReceipts");
b.Navigation("LedgerEntries");
b.Navigation("MemberProfile");

View file

@ -231,6 +231,14 @@ public enum AgentTransportReceiptKind
Closed
}
public enum GovernanceActivityReceiptKind
{
TopicOpened,
TopicCommented,
TopicApproved,
TopicPromoted
}
public sealed class UserAccount
{
public Guid Id { get; set; } = Guid.NewGuid();
@ -285,6 +293,8 @@ public sealed class UserAccount
public ICollection<AgentTransportReceipt> AgentTransportReceipts { get; set; } = [];
public ICollection<GovernanceActivityReceipt> GovernanceActivityReceipts { get; set; } = [];
public ICollection<AuditEvent> AuditEvents { get; set; } = [];
}
@ -1014,6 +1024,37 @@ public sealed class AgentTransportReceipt
public UserAccount? ActorUserAccount { get; set; }
}
public sealed class GovernanceActivityReceipt
{
public Guid Id { get; set; } = Guid.NewGuid();
public string TopicId { get; set; } = string.Empty;
public string CommentId { get; set; } = string.Empty;
public string DispatchRequestId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string JurisdictionRepoName { get; set; } = string.Empty;
public string JurisdictionAgentIdentity { get; set; } = string.Empty;
public GovernanceActivityReceiptKind ActivityKind { get; set; } = GovernanceActivityReceiptKind.TopicOpened;
public string ActorKind { get; set; } = string.Empty;
public string ActorName { get; set; } = string.Empty;
public string Note { get; set; } = string.Empty;
public Guid? ActorUserAccountId { get; set; }
public DateTimeOffset OccurredAtUtc { get; set; }
public UserAccount? ActorUserAccount { get; set; }
}
public sealed class AuditEvent
{
public Guid Id { get; set; } = Guid.NewGuid();

View file

@ -0,0 +1,104 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
namespace Bifrost.Web.Features.Bridge;
public sealed class GovernanceActivityReceiptService(
BifrostDbContext dbContext,
AuditTrailService auditTrailService,
TimeProvider timeProvider)
{
public async Task<GovernanceActivityReceiptResult> RecordAsync(
GovernanceActivityReceiptRequest request,
Guid? actorUserAccountId,
CancellationToken cancellationToken)
{
var receipt = new GovernanceActivityReceipt
{
TopicId = NormalizeText(request.TopicId),
CommentId = NormalizeText(request.CommentId),
DispatchRequestId = NormalizeText(request.DispatchRequestId),
Title = NormalizeText(request.Title),
JurisdictionRepoName = NormalizeText(request.JurisdictionRepoName),
JurisdictionAgentIdentity = NormalizeText(request.JurisdictionAgentIdentity),
ActivityKind = request.ActivityKind,
ActorKind = NormalizeText(request.ActorKind),
ActorName = NormalizeText(request.ActorName),
Note = NormalizeText(request.Note),
ActorUserAccountId = actorUserAccountId,
OccurredAtUtc = timeProvider.GetUtcNow()
};
dbContext.GovernanceActivityReceipts.Add(receipt);
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
actorUserAccountId,
nameof(GovernanceActivityReceipt),
receipt.Id,
$"governance.{ToAuditSuffix(receipt.ActivityKind)}",
BuildAuditDetail(receipt),
cancellationToken);
return GovernanceActivityReceiptResult.From(receipt);
}
private static string ToAuditSuffix(GovernanceActivityReceiptKind activityKind) => activityKind switch
{
GovernanceActivityReceiptKind.TopicOpened => "topic-opened",
GovernanceActivityReceiptKind.TopicCommented => "topic-commented",
GovernanceActivityReceiptKind.TopicApproved => "topic-approved",
GovernanceActivityReceiptKind.TopicPromoted => "topic-promoted",
_ => "activity",
};
private static string BuildAuditDetail(GovernanceActivityReceipt receipt)
{
var actorName = string.IsNullOrWhiteSpace(receipt.ActorName) ? "unknown actor" : receipt.ActorName;
var topicId = string.IsNullOrWhiteSpace(receipt.TopicId) ? "unknown topic" : receipt.TopicId;
return $"{actorName} recorded {receipt.ActivityKind} for governance topic {topicId}.";
}
private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty;
}
public sealed record GovernanceActivityReceiptRequest(
string TopicId,
string CommentId,
string DispatchRequestId,
string Title,
string JurisdictionRepoName,
string JurisdictionAgentIdentity,
GovernanceActivityReceiptKind ActivityKind,
string ActorKind,
string ActorName,
string Note);
public sealed record GovernanceActivityReceiptResult(
Guid Id,
string TopicId,
string CommentId,
string DispatchRequestId,
string Title,
string JurisdictionRepoName,
string JurisdictionAgentIdentity,
GovernanceActivityReceiptKind ActivityKind,
string ActorKind,
string ActorName,
string Note,
DateTimeOffset OccurredAtUtc)
{
public static GovernanceActivityReceiptResult From(GovernanceActivityReceipt receipt) => new(
receipt.Id,
receipt.TopicId,
receipt.CommentId,
receipt.DispatchRequestId,
receipt.Title,
receipt.JurisdictionRepoName,
receipt.JurisdictionAgentIdentity,
receipt.ActivityKind,
receipt.ActorKind,
receipt.ActorName,
receipt.Note,
receipt.OccurredAtUtc);
}

View file

@ -151,6 +151,25 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
x.OccurredAtUtc))
.ToListAsync(cancellationToken);
var recentGovernanceReceipts = await dbContext.GovernanceActivityReceipts
.AsNoTracking()
.OrderByDescending(x => x.OccurredAtUtc)
.Take(6)
.Select(x => new DashboardGovernanceReceipt(
x.Id,
x.TopicId,
x.CommentId,
x.DispatchRequestId,
x.Title,
x.JurisdictionRepoName,
x.JurisdictionAgentIdentity,
x.ActivityKind.ToString(),
x.ActorKind,
x.ActorName,
x.Note,
x.OccurredAtUtc))
.ToListAsync(cancellationToken);
var payoutPreviewNominal = await dbContext.LedgerEntries
.AsNoTracking()
.Where(x => x.Status == LedgerEntryStatus.Approved)
@ -180,7 +199,8 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
RecentActivity: recentActivity,
RecentBridgeActions: recentBridgeActions,
RecentDispatchRuns: recentDispatchRuns,
RecentTransportReceipts: recentTransportReceipts);
RecentTransportReceipts: recentTransportReceipts,
RecentGovernanceReceipts: recentGovernanceReceipts);
}
}
@ -202,7 +222,8 @@ public sealed record DashboardSnapshot(
IReadOnlyList<DashboardActivity> RecentActivity,
IReadOnlyList<DashboardBridgeAction> RecentBridgeActions,
IReadOnlyList<DashboardDispatchRun> RecentDispatchRuns,
IReadOnlyList<DashboardTransportReceipt> RecentTransportReceipts);
IReadOnlyList<DashboardTransportReceipt> RecentTransportReceipts,
IReadOnlyList<DashboardGovernanceReceipt> RecentGovernanceReceipts);
public sealed record DashboardWorkItem(
Guid WorkItemId,
@ -271,3 +292,17 @@ public sealed record DashboardTransportReceipt(
string ActorName,
string Note,
DateTimeOffset OccurredAtUtc);
public sealed record DashboardGovernanceReceipt(
Guid GovernanceActivityReceiptId,
string TopicId,
string CommentId,
string DispatchRequestId,
string Title,
string JurisdictionRepoName,
string JurisdictionAgentIdentity,
string ActivityKind,
string ActorKind,
string ActorName,
string Note,
DateTimeOffset OccurredAtUtc);

View file

@ -269,6 +269,46 @@
}
</article>
<article class="panel">
<div class="panel-header">
<div>
<h2>Governance receipts</h2>
<p class="muted">Hosted receipts for topic opens, comments, approvals, and promotions.</p>
</div>
</div>
@if (Model.Snapshot.RecentGovernanceReceipts.Count == 0)
{
<div class="empty-state">No governance receipts have been recorded yet.</div>
}
else
{
<div class="list-grid">
@foreach (var receipt in Model.Snapshot.RecentGovernanceReceipts)
{
<div class="status-banner">
<strong>@receipt.JurisdictionRepoName@if (!string.IsNullOrWhiteSpace(receipt.JurisdictionAgentIdentity)) {<text> / @receipt.JurisdictionAgentIdentity</text>}</strong>
<span class="muted">@receipt.Title</span>
<div class="inline-actions">
<span class="pill">@receipt.ActivityKind</span>
<span class="pill">@receipt.ActorName</span>
</div>
<div class="muted">Topic @receipt.TopicId</div>
@if (!string.IsNullOrWhiteSpace(receipt.DispatchRequestId))
{
<div class="muted">Dispatch @receipt.DispatchRequestId</div>
}
@if (!string.IsNullOrWhiteSpace(receipt.Note))
{
<div class="muted">@receipt.Note</div>
}
<div class="muted">@receipt.OccurredAtUtc.ToString("dd MMM yyyy HH:mm") UTC</div>
</div>
}
</div>
}
</article>
<article class="panel">
<div class="panel-header">
<div>

View file

@ -11,7 +11,7 @@ public sealed class IndexModel(
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public DashboardSnapshot Snapshot { get; private set; } =
new(0, 0, 0, 0, 0, 0, 0, 0, 0m, [], [], [], [], [], [], [], [], []);
new(0, 0, 0, 0, 0, 0, 0, 0, 0m, [], [], [], [], [], [], [], [], [], []);
public async Task OnGetAsync(CancellationToken cancellationToken)
{

View file

@ -82,6 +82,7 @@ builder.Services.AddScoped<DashboardSnapshotService>();
builder.Services.AddScoped<BridgeActionService>();
builder.Services.AddScoped<AgentDispatchRunService>();
builder.Services.AddScoped<AgentTransportReceiptService>();
builder.Services.AddScoped<GovernanceActivityReceiptService>();
builder.Services.AddScoped<MembershipSynchronizationService>();
builder.Services.AddScoped<GitHubWebhookService>();
builder.Services.AddScoped<MotionGovernanceService>();
@ -557,6 +558,26 @@ transportReceipts.MapPost("", async (
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
});
var governanceReceipts = app.MapGroup("/governance/receipts");
governanceReceipts.MapPost("", async (
HttpRequest request,
GovernanceActivityReceiptRequest command,
ICurrentBifrostActorAccessor actorAccessor,
GovernanceActivityReceiptService governanceActivityReceiptService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
var actor = await actorAccessor.GetAsync(cancellationToken);
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value) && !actor.IsActiveMember)
{
return Results.Unauthorized();
}
var result = await governanceActivityReceiptService.RecordAsync(command, actor.UserAccount?.Id, cancellationToken);
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
});
var eveGovernance = app.MapGroup("/eve/governance")
.RequireAuthorization(ActiveMemberRequirement.PolicyName);

View file

@ -110,6 +110,21 @@ public sealed class MemberConsoleTests : IClassFixture<TestWebApplicationFactory
Note = "Request claimed for Bifrost.",
OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-3)
});
dbContext.GovernanceActivityReceipts.Add(new GovernanceActivityReceipt
{
TopicId = "topic_123",
CommentId = "comment_123",
DispatchRequestId = "req_transport_123",
Title = "Queue the GitHub bridge hardening pass",
JurisdictionRepoName = "Bifrost",
JurisdictionAgentIdentity = "nibu",
ActivityKind = GovernanceActivityReceiptKind.TopicPromoted,
ActorKind = "face",
ActorName = "nibu",
Note = "Governance topic promoted to update request req_transport_123.",
ActorUserAccountId = actor.Id,
OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-4)
});
dbContext.SaveChanges();
}
@ -122,6 +137,8 @@ public sealed class MemberConsoleTests : IClassFixture<TestWebApplicationFactory
Assert.Contains("req_dispatch_123", html);
Assert.Contains("Transport receipts", html);
Assert.Contains("req_transport_123", html);
Assert.Contains("Governance receipts", html);
Assert.Contains("topic_123", html);
Assert.Contains("Bridge receipts", html);
Assert.Contains("Draft motion implementation PR", html);
Assert.Contains("nibu", html);

View file

@ -545,6 +545,46 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(AgentTransportReceipt) && x.Action == "agent-transport.claimed");
}
[Fact]
public async Task Local_bridge_token_can_record_governance_receipt()
{
using var client = _factory.CreateClient();
var payload = JsonSerializer.Serialize(new
{
topicId = "topic_123",
commentId = "comment_123",
dispatchRequestId = "req_transport_123",
title = "Queue the GitHub bridge hardening pass",
jurisdictionRepoName = "Bifrost",
jurisdictionAgentIdentity = "nibu",
activityKind = "TopicPromoted",
actorKind = "face",
actorName = "nibu",
note = "Governance topic promoted to update request req_transport_123."
});
using var request = new HttpRequestMessage(HttpMethod.Post, "/governance/receipts");
request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var receipt = await response.Content.ReadFromJsonAsync<GovernanceActivityReceiptHttpResult>();
Assert.NotNull(receipt);
Assert.Equal("topic_123", receipt.TopicId);
Assert.Equal("TopicPromoted", receipt.ActivityKind);
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var savedReceipt = dbContext.GovernanceActivityReceipts.Single(x => x.Id == receipt.Id);
Assert.Equal("TopicPromoted", savedReceipt.ActivityKind.ToString());
Assert.Equal("nibu", savedReceipt.ActorName);
Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(GovernanceActivityReceipt) && x.Action == "governance.topic-promoted");
}
private static string ComputeSignature(string secret, string payload)
{
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
@ -582,4 +622,13 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
public string ActivityKind { get; init; } = string.Empty;
}
private sealed class GovernanceActivityReceiptHttpResult
{
public Guid Id { get; init; }
public string TopicId { get; init; } = string.Empty;
public string ActivityKind { get; init; } = string.Empty;
}
}

View file

@ -175,6 +175,14 @@ async function openTopic(cache, options) {
fallbackContent: topic.summaryMarkdown,
eventLabel: "opened",
});
await recordGovernanceReceiptOrThrow(topic, {
commentId: "",
dispatchRequestId: "",
activityKind: "TopicOpened",
actorKind: "agent",
actorName: topic.createdByActor ?? "system",
note: "Governance topic opened.",
});
} catch (error) {
await cache.delete(topicDefinition, topic.id);
throw error;
@ -207,6 +215,14 @@ async function addComment(cache, options) {
fallbackContent: comment.bodyMarkdown,
eventLabel: comment.stance,
});
await recordGovernanceReceiptOrThrow(topic, {
commentId: comment.id,
dispatchRequestId: "",
activityKind: "TopicCommented",
actorKind: comment.authorKind,
actorName: comment.authorId,
note: `Governance topic ${comment.stance} comment recorded.`,
});
} catch (error) {
await cache.delete(commentDefinition, comment.id);
throw error;
@ -254,6 +270,14 @@ async function approveTopic(cache, options) {
fallbackContent: approvalBody ?? `Approved by ${approvedBy}.`,
eventLabel: "approved",
});
await recordGovernanceReceiptOrThrow(approved, {
commentId: comment?.id ?? "",
dispatchRequestId: "",
activityKind: "TopicApproved",
actorKind: "face",
actorName: approvedBy,
note: "Governance topic approved.",
});
} catch (error) {
await cache.put(topicDefinition, topic.id, topic);
if (comment) {
@ -331,6 +355,14 @@ async function promoteTopic(cache, options) {
fallbackContent: receipt.bodyMarkdown,
eventLabel: "dispatched",
});
await recordGovernanceReceiptOrThrow(dispatched, {
commentId: receipt.id,
dispatchRequestId: request.id,
activityKind: "TopicPromoted",
actorKind: "system",
actorName: topic.approvedByAgent ?? topic.createdByActor ?? "bifrost",
note: `Governance topic promoted to update request ${request.id}.`,
});
} catch (error) {
await cache.put(topicDefinition, topic.id, topic);
await cache.delete(commentDefinition, receipt.id);
@ -459,6 +491,39 @@ function truncateMarkdown(value, maxLength) {
return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
}
async function recordGovernanceReceiptOrThrow(topic, receipt) {
const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN);
if (!baseUrl || !token) {
return;
}
const response = await fetch(new URL("/governance/receipts", ensureTrailingSlash(baseUrl)), {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Bifrost-Bridge-Token": token,
},
body: JSON.stringify({
topicId: topic.id,
commentId: receipt.commentId,
dispatchRequestId: receipt.dispatchRequestId,
title: topic.title,
jurisdictionRepoName: topic.jurisdictionRepoName,
jurisdictionAgentIdentity: topic.jurisdictionAgentIdentity ?? "",
activityKind: receipt.activityKind,
actorKind: receipt.actorKind,
actorName: receipt.actorName,
note: receipt.note,
}),
});
const text = await response.text();
if (response.status !== 202) {
throw new Error(`Bifrost governance receipt failed with ${response.status}: ${text}`);
}
}
function listTopics(cache, options) {
const topics = cache.getAll(topicDefinition)
.filter((topic) => !options.status || topic.status === options.status)
@ -760,6 +825,10 @@ function resolveOptionPath(path) {
return resolve(process.cwd(), path);
}
function ensureTrailingSlash(value) {
return value.endsWith("/") ? value : `${value}/`;
}
function printJson(value) {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}