Receipt governance activity in Bifrost
This commit is contained in:
parent
118a3efc74
commit
cab9d22f78
15 changed files with 2600 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
2035
src/Bifrost.Web/Data/Migrations/20260617041417_AddGovernanceActivityReceipts.Designer.cs
generated
Normal file
2035
src/Bifrost.Web/Data/Migrations/20260617041417_AddGovernanceActivityReceipts.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue