From cab9d22f78f3eeb6701e3a3b174c4386fbd03672 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 17 Jun 2026 05:21:29 +0100 Subject: [PATCH] Receipt governance activity in Bifrost --- README.md | 1 + docs/bridge.md | 7 + src/Bifrost.Web/Data/BifrostDbContext.cs | 22 + ..._AddGovernanceActivityReceipts.Designer.cs | 2035 +++++++++++++++++ ...617041417_AddGovernanceActivityReceipts.cs | 71 + .../BifrostDbContextModelSnapshot.cs | 85 + src/Bifrost.Web/Domain/BifrostModels.cs | 41 + .../GovernanceActivityReceiptService.cs | 104 + .../Shared/DashboardSnapshotService.cs | 39 +- src/Bifrost.Web/Pages/App/Index.cshtml | 40 + src/Bifrost.Web/Pages/App/Index.cshtml.cs | 2 +- src/Bifrost.Web/Program.cs | 21 + tests/Bifrost.Web.Tests/MemberConsoleTests.cs | 17 + .../PlatformEndpointsTests.cs | 49 + tools/governance-threads.mjs | 69 + 15 files changed, 2600 insertions(+), 3 deletions(-) create mode 100644 src/Bifrost.Web/Data/Migrations/20260617041417_AddGovernanceActivityReceipts.Designer.cs create mode 100644 src/Bifrost.Web/Data/Migrations/20260617041417_AddGovernanceActivityReceipts.cs create mode 100644 src/Bifrost.Web/Features/Bridge/GovernanceActivityReceiptService.cs diff --git a/README.md b/README.md index c91ad2e..4472b09 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/bridge.md b/docs/bridge.md index 857efe8..452dfad 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -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. diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 45adc26..8ddec75 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -59,6 +59,8 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet AgentTransportReceipts => Set(); + public DbSet GovernanceActivityReceipts => Set(); + public DbSet AuditEvents => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -475,6 +477,26 @@ public sealed class BifrostDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.SetNull); }); + modelBuilder.Entity(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(); + 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(entity => { entity.Property(x => x.EntityType).HasMaxLength(120); diff --git a/src/Bifrost.Web/Data/Migrations/20260617041417_AddGovernanceActivityReceipts.Designer.cs b/src/Bifrost.Web/Data/Migrations/20260617041417_AddGovernanceActivityReceipts.Designer.cs new file mode 100644 index 0000000..b718839 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260617041417_AddGovernanceActivityReceipts.Designer.cs @@ -0,0 +1,2035 @@ +// +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + [DbContext(typeof(BifrostDbContext))] + [Migration("20260617041417_AddGovernanceActivityReceipts")] + partial class AddGovernanceActivityReceipts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .IsRequired() + .HasColumnType("text"); + + b.Property("LaunchMode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("LogPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ResultPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ThreadId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TurnId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerProcessId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RequestId"); + + b.HasIndex("StartedByUserAccountId"); + + b.HasIndex("Status"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "Status"); + + b.ToTable("AgentDispatchRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentTransportReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("OccurredAtUtc"); + + b.HasIndex("RequestId"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "ActivityKind"); + + b.ToTable("AgentTransportReceipts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("AuthorityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalReceiptId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("FailureReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceiptUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetLocator") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TargetSurface") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("MotionId"); + + b.HasIndex("Status"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("ActorKind", "ActorName"); + + b.HasIndex("SourceKind", "SourceId"); + + b.HasIndex("TargetSurface", "ActionKind", "Status"); + + b.ToTable("BridgeActions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RanAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Rate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.HasKey("Id"); + + b.ToTable("DecayRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IssueNumber") + .HasColumnType("integer"); + + b.Property("IssueUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId") + .IsUnique(); + + b.HasIndex("RepositoryFullName", "IssueNumber") + .IsUnique(); + + b.ToTable("GitHubIssueLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsMerged") + .HasColumnType("boolean"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ReviewDecision") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("RepositoryFullName", "PullRequestNumber") + .IsUnique(); + + b.ToTable("GitHubPullRequestLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("CommentId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DispatchRequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("JurisdictionAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("JurisdictionRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("EffectiveAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasColumnType("text"); + + b.Property("NominalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Points") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("LedgerEntries"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Availability") + .IsRequired() + .HasColumnType("text"); + + b.Property("Headline") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortfolioUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Skills") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("MemberProfiles"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CanManageLedger") + .HasColumnType("boolean"); + + b.Property("CanManageProjects") + .HasColumnType("boolean"); + + b.Property("CanModerateMotions") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPlatformAdmin") + .HasColumnType("boolean"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("Memberships"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AcceptedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedByUserAccountId"); + + b.HasIndex("IssuedByUserAccountId"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique(); + + b.ToTable("MembershipInvitations"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalThreshold") + .HasPrecision(6, 3) + .HasColumnType("numeric(6,3)"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosesAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("OpensAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ResolutionNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.ToTable("Motions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("ExternalSupportId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("IsCurrentRecurringSupport") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProviderEventId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderPayerId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Provider", "ProviderEventId") + .IsUnique() + .HasFilter("\"ProviderEventId\" <> ''"); + + b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); + + b.ToTable("PatronSupportEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.ToTable("PayoutProposalBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDecaying") + .HasColumnType("boolean"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("PointTransactions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GitHubRepository") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("OwnerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GitHubRepository"); + + b.HasIndex("OwnerUserAccountId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("RevenueEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("RevenueEventId") + .HasColumnType("uuid"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("RevenueEventId"); + + b.ToTable("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Basis") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RevenueShareBatchId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RevenueShareBatchId"); + + b.HasIndex("UserAccountId"); + + b.ToTable("RevenueShareLines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("MembershipId", "Role") + .IsUnique(); + + b.ToTable("RoleAssignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("MembershipId", "Kind", "IsCurrent"); + + b.ToTable("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("HeimdallAccountId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubUserId") + .IsUnique() + .HasFilter("\"GitHubUserId\" IS NOT NULL"); + + b.HasIndex("HeimdallAccountId") + .IsUnique() + .HasFilter("\"HeimdallAccountId\" <> ''"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique() + .HasFilter("\"NormalizedGitHubLogin\" <> ''"); + + b.ToTable("UserAccounts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId", "UserAccountId") + .IsUnique(); + + b.ToTable("VolunteerClaims"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Choice") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("MotionId", "UserAccountId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedToUserAccountId") + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContributionPoints") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedHours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExternalSourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RequestedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("SkillLevel") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RequestedByUserAccountId"); + + b.ToTable("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Hours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkLogs"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReviewerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewerUserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount") + .WithMany("AgentDispatchRuns") + .HasForeignKey("StartedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("StartedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentTransportReceipt", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AgentTransportReceipts") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Assignments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("Assignments") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AuditEvents") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("BridgeActions") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany() + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany() + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + + b.Navigation("Motion"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithOne("GitHubIssueLink") + .HasForeignKey("Bifrost.Web.Domain.GitHubIssueLink", "WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("GitHubPullRequestLinks") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("LedgerEntries") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("LedgerEntries") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("LedgerEntries") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("MemberProfile") + .HasForeignKey("Bifrost.Web.Domain.MemberProfile", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("Membership") + .HasForeignKey("Bifrost.Web.Domain.Membership", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AcceptedByUserAccount") + .WithMany() + .HasForeignKey("AcceptedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "IssuedByUserAccount") + .WithMany() + .HasForeignKey("IssuedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AcceptedByUserAccount"); + + b.Navigation("IssuedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany("CreatedMotions") + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("Motions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PatronSupportEvents") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PointTransactions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PointTransactions") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("PointTransactions") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "OwnerUserAccount") + .WithMany("OwnedProjects") + .HasForeignKey("OwnerUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OwnerUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.RevenueEvent", "RevenueEvent") + .WithMany("RevenueShareBatches") + .HasForeignKey("RevenueEventId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("RevenueEvent"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueShareLines") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.RevenueShareBatch", "RevenueShareBatch") + .WithMany("Lines") + .HasForeignKey("RevenueShareBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("RevenueShareBatch"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("RoleAssignments") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("TierSnapshots") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("VolunteerClaims") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("VolunteerClaims") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany("Votes") + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Votes") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Motion"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedToUserAccount") + .WithMany("AssignedWorkItems") + .HasForeignKey("AssignedToUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("WorkItems") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "RequestedByUserAccount") + .WithMany("RequestedWorkItems") + .HasForeignKey("RequestedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedToUserAccount"); + + b.Navigation("Project"); + + b.Navigation("RequestedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewedByUserAccount") + .WithMany() + .HasForeignKey("ReviewedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("WorkLogs") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkLogs") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewerUserAccount") + .WithMany("WorkReviews") + .HasForeignKey("ReviewerUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkReviews") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewerUserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Navigation("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Navigation("LedgerEntries"); + + b.Navigation("Motions"); + + b.Navigation("PointTransactions"); + + b.Navigation("RevenueEvents"); + + b.Navigation("RevenueShareLines"); + + b.Navigation("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Navigation("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Navigation("AgentDispatchRuns"); + + b.Navigation("AgentTransportReceipts"); + + b.Navigation("AssignedWorkItems"); + + b.Navigation("Assignments"); + + b.Navigation("AuditEvents"); + + b.Navigation("BridgeActions"); + + b.Navigation("CreatedMotions"); + + b.Navigation("GovernanceActivityReceipts"); + + b.Navigation("LedgerEntries"); + + b.Navigation("MemberProfile"); + + b.Navigation("Membership"); + + b.Navigation("OwnedProjects"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RequestedWorkItems"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("Votes"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Navigation("Assignments"); + + b.Navigation("GitHubIssueLink"); + + b.Navigation("GitHubPullRequestLinks"); + + b.Navigation("LedgerEntries"); + + b.Navigation("PointTransactions"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/20260617041417_AddGovernanceActivityReceipts.cs b/src/Bifrost.Web/Data/Migrations/20260617041417_AddGovernanceActivityReceipts.cs new file mode 100644 index 0000000..bf87f85 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260617041417_AddGovernanceActivityReceipts.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class AddGovernanceActivityReceipts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "GovernanceActivityReceipts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TopicId = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + CommentId = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + DispatchRequestId = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + Title = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + JurisdictionRepoName = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + JurisdictionAgentIdentity = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + ActivityKind = table.Column(type: "text", nullable: false), + ActorKind = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + ActorName = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + Note = table.Column(type: "text", nullable: false), + ActorUserAccountId = table.Column(type: "uuid", nullable: true), + OccurredAtUtc = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GovernanceActivityReceipts"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 702d3e3..fbf2804 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -494,6 +494,79 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("GitHubPullRequestLinks"); }); + modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("CommentId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DispatchRequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("JurisdictionAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("JurisdictionRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("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("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"); diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 35684a8..5942474 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -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 AgentTransportReceipts { get; set; } = []; + public ICollection GovernanceActivityReceipts { get; set; } = []; + public ICollection 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(); diff --git a/src/Bifrost.Web/Features/Bridge/GovernanceActivityReceiptService.cs b/src/Bifrost.Web/Features/Bridge/GovernanceActivityReceiptService.cs new file mode 100644 index 0000000..ac8a568 --- /dev/null +++ b/src/Bifrost.Web/Features/Bridge/GovernanceActivityReceiptService.cs @@ -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 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); +} diff --git a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs index ef6e505..9f07cf9 100644 --- a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs +++ b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs @@ -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 RecentActivity, IReadOnlyList RecentBridgeActions, IReadOnlyList RecentDispatchRuns, - IReadOnlyList RecentTransportReceipts); + IReadOnlyList RecentTransportReceipts, + IReadOnlyList 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); diff --git a/src/Bifrost.Web/Pages/App/Index.cshtml b/src/Bifrost.Web/Pages/App/Index.cshtml index b943639..889675a 100644 --- a/src/Bifrost.Web/Pages/App/Index.cshtml +++ b/src/Bifrost.Web/Pages/App/Index.cshtml @@ -269,6 +269,46 @@ } +
+
+
+

Governance receipts

+

Hosted receipts for topic opens, comments, approvals, and promotions.

+
+
+ + @if (Model.Snapshot.RecentGovernanceReceipts.Count == 0) + { +
No governance receipts have been recorded yet.
+ } + else + { +
+ @foreach (var receipt in Model.Snapshot.RecentGovernanceReceipts) + { +
+ @receipt.JurisdictionRepoName@if (!string.IsNullOrWhiteSpace(receipt.JurisdictionAgentIdentity)) { / @receipt.JurisdictionAgentIdentity} + @receipt.Title +
+ @receipt.ActivityKind + @receipt.ActorName +
+
Topic @receipt.TopicId
+ @if (!string.IsNullOrWhiteSpace(receipt.DispatchRequestId)) + { +
Dispatch @receipt.DispatchRequestId
+ } + @if (!string.IsNullOrWhiteSpace(receipt.Note)) + { +
@receipt.Note
+ } +
@receipt.OccurredAtUtc.ToString("dd MMM yyyy HH:mm") UTC
+
+ } +
+ } +
+
diff --git a/src/Bifrost.Web/Pages/App/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Index.cshtml.cs index c43f414..ed0db5c 100644 --- a/src/Bifrost.Web/Pages/App/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Index.cshtml.cs @@ -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) { diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 5a700fd..6c5a980 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -82,6 +82,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -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, + 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); diff --git a/tests/Bifrost.Web.Tests/MemberConsoleTests.cs b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs index d1ccf18..a3addd0 100644 --- a/tests/Bifrost.Web.Tests/MemberConsoleTests.cs +++ b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs @@ -110,6 +110,21 @@ public sealed class MemberConsoleTests : IClassFixture 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(); + 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(); + 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 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`); }