diff --git a/README.md b/README.md index 36974ba..dceafa3 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core - health and readiness endpoints plus startup config validation - GitHub App webhook ingestion for issue, pull request, and review sync - Heimdall-signed external patron support intake for Patreon and PayPal support facts +- Eve Motion Verse surface and command endpoint for governance participation, with Razor motion forms demoted to a transitional browser lowering - local bridge tooling for agent-owned GitHub draft PRs and Discord posts - local bridge tooling for Reddit self-posts in `r/GameCultOrg`, including Persona flair labels through the Bifrost Reddit app - CultCache-backed governance topic threads for feature requests, discussion comments, Persona approvals, and dispatch promotion @@ -78,6 +79,7 @@ For the full staged roadmap, including contribution points, revenue share, patro - dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true` - print the Eve provider advertisement with `node tools/provider-advertisement.mjs print` - export the Eve provider advertisement witness with `node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc` +- inspect the interactive Motion Verse surface at `/eve/governance/surface` while signed in as an active member - container smoke test with `docker compose -f compose.local.yaml up --build` - container health checks live at `http://127.0.0.1:5080/healthz` and `http://127.0.0.1:5080/readyz` diff --git a/docs/full-implementation-strategy.md b/docs/full-implementation-strategy.md index fa298cd..0f9cf42 100644 --- a/docs/full-implementation-strategy.md +++ b/docs/full-implementation-strategy.md @@ -18,6 +18,7 @@ It is currently a strong first application slice, not an internet-facing alpha. - invite and approval gate with explicit member roles - member profile editing plus patron or contributor tier snapshots - project, work item, motion, member, and ledger UI +- first Eve Motion Verse product surface and command endpoint, with Razor motion forms routed through the same governance service - patron support event recording with derived patron tier snapshot refresh - work logs, work reviews, and completion flow - GitHub App webhook ingestion for issue, pull request, and review sync diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index b4749ea..087c44a 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -128,9 +128,17 @@ Operator surfaces are the canonical interface compositions for: - Discord mirror health, including fail-closed unmirrored governance writes; - Reddit bridge readiness for `r/GameCultOrg` organizing posts and Persona flair. -The current Razor Pages app lowers product surfaces into browser UI. Existing -health/readiness endpoints lower operator state into HTTP probes. Both should -be fed by the same typed service state that backs Eve surfaces. +The Motion Verse has begun the migration from Razor-owned behavior to Eve-owned +presentation. `/eve/governance/surface` emits a `gamecult.eve.surface.v1` +composition for motions and voting. `/eve/governance/commands` accepts +`motion.create`, `motion.vote`, and `motion.close` commands, then routes those +commands through the same Bifrost motion governance service used by the Razor +forms. + +The current Razor Pages app is now a transitional browser lowering for motions, +not the behavior owner. Existing health/readiness endpoints lower operator +state into HTTP probes. Both should be fed by the same typed service state that +backs Eve surfaces. ## CultMesh Address Shape @@ -198,7 +206,8 @@ Bifrost. 5. Publish operator surfaces first: readiness, witness freshness, bridge queue, failed crossings, and migration drift. 6. Publish product surfaces for account, project, work, motion, patron, and - contributor views as `gamecult.eve.surface.v1`. + contributor views as `gamecult.eve.surface.v1`. Motion governance has its + first product surface and command endpoint. 7. Lower the existing Razor Pages app from the Eve/product surface contract where practical, leaving direct Razor composition only as transitional UI. 8. Route Discord and Reddit native interactions through Bifrost commands that diff --git a/src/Bifrost.Web/Features/Motions/MotionEveSurfaceService.cs b/src/Bifrost.Web/Features/Motions/MotionEveSurfaceService.cs new file mode 100644 index 0000000..1ba39cf --- /dev/null +++ b/src/Bifrost.Web/Features/Motions/MotionEveSurfaceService.cs @@ -0,0 +1,207 @@ +using Bifrost.Web.Domain; + +namespace Bifrost.Web.Features.Motions; + +public sealed class MotionEveSurfaceService +{ + public EveSurfaceDocument BuildSurface(MotionGovernanceState state) + { + var motionNodes = state.Motions.Count > 0 + ? state.Motions.Select(BuildMotionCard).ToArray() + : [TextNode("motion-empty", "No motions yet.")]; + + return new EveSurfaceDocument( + "gamecult.eve.surface.v1", + "bifrost-motion-verse", + "Bifrost Motion Verse", + new EveNode( + "bifrost-motion-root", + "workspace", + new Dictionary + { + ["title"] = "Bifrost Governance", + ["subtitle"] = "Motions, weighted votes, thresholds, and receipts", + ["actor"] = state.ActorName, + ["effectiveVotingWeight"] = state.EffectiveVotingWeight, + ["canonicalCommandTarget"] = "/eve/governance/commands" + }, + [ + new EveNode( + "motion-create", + "form", + new Dictionary + { + ["title"] = "Open Motion", + ["command"] = "motion.create", + ["target"] = "/eve/governance/commands", + ["method"] = "POST" + }, + [ + SelectNode("scope", "Scope", EnumNames()), + SelectNode("projectId", "Project", state.Projects.Select(x => new EveOption(x.Id.ToString(), x.Name)).Prepend(new EveOption("", "No project scope"))), + SelectNode("category", "Category", EnumNames()), + InputNode("title", "Title", "text"), + InputNode("summary", "Summary", "multiline"), + InputNode("closesAtUtc", "Close at UTC", "datetime") + ]), + new EveNode( + "vote-ledger", + "panel", + new Dictionary + { + ["title"] = "Vote Ledger", + ["effectiveVotingWeight"] = state.EffectiveVotingWeight + }, + state.CategoryPolicies + .Select(x => TextNode($"threshold-{Slug(x.Label)}", $"{x.Label}: {x.Threshold:P0} threshold")) + .ToArray()), + new EveNode( + "motions", + "collection", + new Dictionary { ["title"] = "Motions" }, + motionNodes) + ])); + } + + private static EveNode BuildMotionCard(MotionListItem motion) + { + var commandNodes = new List(); + foreach (var choice in Enum.GetValues()) + { + commandNodes.Add(CommandNode( + $"vote-{motion.Id}-{choice}", + $"Vote {choice}", + "motion.vote", + new Dictionary + { + ["motionId"] = motion.Id, + ["choice"] = choice.ToString(), + ["comment"] = string.Empty + }, + motion.CurrentUserVote == choice ? "selected" : "default")); + } + + if (motion.StatusLabel == "Expired") + { + commandNodes.Add(CommandNode( + $"close-{motion.Id}", + "Close motion", + "motion.close", + new Dictionary { ["motionId"] = motion.Id }, + "secondary")); + } + + return new EveNode( + $"motion-{motion.Id}", + "card", + new Dictionary + { + ["title"] = motion.Title, + ["summary"] = motion.Summary, + ["scope"] = motion.Scope, + ["category"] = motion.Category, + ["status"] = motion.StatusLabel, + ["tone"] = motion.StatusTone, + ["project"] = motion.ProjectName, + ["threshold"] = motion.Threshold, + ["closesAtUtc"] = motion.ClosesAtUtc, + ["resolutionNote"] = motion.ResolutionNote + }, + [ + new EveNode( + $"motion-votes-{motion.Id}", + "metrics", + new Dictionary + { + ["for"] = motion.VotesFor, + ["against"] = motion.VotesAgainst, + ["abstain"] = motion.VotesAbstain, + ["currentUserVote"] = motion.CurrentUserVote?.ToString() ?? string.Empty + }), + new EveNode( + $"motion-commands-{motion.Id}", + "commands", + new Dictionary { ["target"] = "/eve/governance/commands" }, + commandNodes) + ]); + } + + private static EveNode CommandNode( + string id, + string label, + string command, + IReadOnlyDictionary payload, + string tone) => + new( + id, + "command", + new Dictionary + { + ["label"] = label, + ["command"] = command, + ["target"] = "/eve/governance/commands", + ["method"] = "POST", + ["payload"] = payload, + ["tone"] = tone + }); + + private static EveNode InputNode(string id, string label, string inputKind) => + new( + $"input-{id}", + "input", + new Dictionary + { + ["name"] = id, + ["label"] = label, + ["inputKind"] = inputKind + }); + + private static EveNode SelectNode(string id, string label, IEnumerable options) => + SelectNode(id, label, options.Select(x => new EveOption(x, x))); + + private static EveNode SelectNode(string id, string label, IEnumerable options) => + new( + $"input-{id}", + "select", + new Dictionary + { + ["name"] = id, + ["label"] = label, + ["options"] = options.ToArray() + }); + + private static EveNode TextNode(string id, string text) => + new(id, "text", new Dictionary { ["text"] = text }); + + private static IReadOnlyList EnumNames() where TEnum : struct, Enum => + Enum.GetNames(); + + private static string Slug(string value) => + value.ToLowerInvariant().Replace(" ", "-", StringComparison.Ordinal); +} + +public sealed record EveSurfaceDocument( + string Schema, + string Id, + string Title, + EveNode Root); + +public sealed record EveNode( + string Id, + string Kind, + IReadOnlyDictionary Props, + IReadOnlyList? Children = null); + +public sealed record EveOption(string Value, string Label); + +public sealed record MotionEveCommandRequest( + string Command, + Guid? MotionId, + MotionScope? Scope, + Guid? ProjectId, + MotionCategory? Category, + string? Title, + string? Summary, + DateTimeOffset? ClosesAtUtc, + VoteChoice? Choice, + string? Comment); diff --git a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs new file mode 100644 index 0000000..8c10dcf --- /dev/null +++ b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs @@ -0,0 +1,258 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.Motions; + +public sealed class MotionGovernanceService( + BifrostDbContext dbContext, + AuditTrailService auditTrailService, + TimeProvider timeProvider) +{ + public async Task CreateMotionAsync( + CurrentBifrostActor actor, + CreateMotionCommand command, + CancellationToken cancellationToken) + { + var actorUser = actor.UserAccount ?? throw new UnauthorizedAccessException("A linked Bifrost actor is required."); + var now = timeProvider.GetUtcNow(); + var motion = new Motion + { + ProjectId = command.Scope == MotionScope.Project ? command.ProjectId : null, + CreatedByUserAccountId = actorUser.Id, + Scope = command.Scope, + Category = command.Category, + Title = command.Title.Trim(), + Summary = command.Summary.Trim(), + ApprovalThreshold = MotionCategoryPolicy.GetThreshold(command.Category), + OpensAtUtc = now, + ClosesAtUtc = command.ClosesAtUtc.ToUniversalTime(), + CreatedAtUtc = now + }; + + dbContext.Motions.Add(motion); + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + actorUser.Id, + nameof(Motion), + motion.Id, + "motion.created", + $"Opened motion {motion.Title}.", + cancellationToken); + + return motion; + } + + public async Task CastVoteAsync( + CurrentBifrostActor actor, + Guid motionId, + VoteChoice choice, + string? comment, + CancellationToken cancellationToken) + { + var actorUser = actor.UserAccount ?? throw new UnauthorizedAccessException("A linked Bifrost actor is required."); + var motion = await dbContext.Motions + .Include(x => x.Votes) + .SingleAsync(x => x.Id == motionId, cancellationToken); + + if (motion.Status != MotionStatus.Open) + { + return; + } + + var vote = motion.Votes.SingleOrDefault(x => x.UserAccountId == actorUser.Id); + if (vote is null) + { + dbContext.Votes.Add(new Vote + { + MotionId = motionId, + UserAccountId = actorUser.Id, + Choice = choice, + Weight = actor.EffectiveVotingWeight, + Comment = comment?.Trim() ?? string.Empty, + CastAtUtc = timeProvider.GetUtcNow() + }); + } + else + { + vote.Choice = choice; + vote.Weight = actor.EffectiveVotingWeight; + vote.Comment = comment?.Trim() ?? string.Empty; + vote.CastAtUtc = timeProvider.GetUtcNow(); + } + + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + actorUser.Id, + nameof(Motion), + motion.Id, + "motion.voted", + $"{actor.DisplayName} voted {choice} on {motion.Title}.", + cancellationToken); + } + + public async Task CloseMotionAsync( + CurrentBifrostActor actor, + Guid motionId, + CancellationToken cancellationToken) + { + var actorUser = actor.UserAccount ?? throw new UnauthorizedAccessException("A linked Bifrost actor is required."); + var motion = await dbContext.Motions + .Include(x => x.Votes) + .SingleAsync(x => x.Id == motionId, cancellationToken); + + if (!actor.CanModerateMotions && motion.CreatedByUserAccountId != actorUser.Id) + { + throw new UnauthorizedAccessException("Only the motion creator or a moderator can close this motion."); + } + + var votesFor = motion.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight); + var votesAgainst = motion.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight); + var decisiveVotes = votesFor + votesAgainst; + + motion.Status = decisiveVotes > 0 && votesFor / decisiveVotes >= motion.ApprovalThreshold + ? MotionStatus.Passed + : decisiveVotes > 0 + ? MotionStatus.Failed + : MotionStatus.Closed; + motion.ResolvedAtUtc = timeProvider.GetUtcNow(); + motion.ResolutionNote = decisiveVotes > 0 + ? $"Closed with {votesFor:0.##} for and {votesAgainst:0.##} against at a {motion.ApprovalThreshold:P0} threshold." + : "Closed without decisive votes."; + + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + actorUser.Id, + nameof(Motion), + motion.Id, + "motion.closed", + $"Closed motion {motion.Title} with status {motion.Status}.", + cancellationToken); + } + + public async Task GetStateAsync( + CurrentBifrostActor actor, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var motions = await dbContext.Motions + .AsNoTracking() + .Include(x => x.Project) + .Include(x => x.Votes) + .OrderBy(x => x.ClosesAtUtc) + .ToListAsync(cancellationToken); + + var projects = await dbContext.Projects + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new MotionProjectOption(x.Id, x.Name)) + .ToListAsync(cancellationToken); + + var motionItems = motions.Select(x => new MotionListItem( + x.Id, + x.Title, + x.Summary, + x.Project?.Name ?? "Management-wide", + x.Scope.ToString(), + MotionCategoryPolicy.GetLabel(x.Category), + x.ApprovalThreshold, + ResolveStatusLabel(x, now), + ResolveStatusTone(x, now), + x.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight), + x.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight), + x.Votes.Where(v => v.Choice == VoteChoice.Abstain).Sum(v => v.Weight), + x.Votes.Where(v => v.UserAccountId == actor.UserAccount?.Id).Select(v => (VoteChoice?)v.Choice).FirstOrDefault(), + x.ClosesAtUtc, + x.ResolutionNote)).ToList(); + + return new MotionGovernanceState( + actor.DisplayName, + actor.EffectiveVotingWeight, + actor.CanModerateMotions, + projects, + motionItems, + MotionCategoryPolicyRows.All); + } + + public static string ResolveStatusLabel(Motion motion, DateTimeOffset now) + { + if (motion.Status != MotionStatus.Open) + { + return motion.Status.ToString(); + } + + if (motion.ClosesAtUtc >= now) + { + return "Open"; + } + + var votesFor = motion.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight); + var votesAgainst = motion.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight); + var decisiveVotes = votesFor + votesAgainst; + if (decisiveVotes <= 0) + { + return "Expired"; + } + + return votesFor / decisiveVotes >= motion.ApprovalThreshold ? "Passed" : "Failed"; + } + + public static string ResolveStatusTone(Motion motion, DateTimeOffset now) => + ResolveStatusLabel(motion, now) switch + { + "Passed" => "success", + "Failed" => "danger", + "Expired" => "warning", + _ => string.Empty + }; +} + +public sealed record CreateMotionCommand( + MotionScope Scope, + Guid? ProjectId, + MotionCategory Category, + string Title, + string Summary, + DateTimeOffset ClosesAtUtc); + +public sealed record MotionGovernanceState( + string ActorName, + decimal EffectiveVotingWeight, + bool CanModerateMotions, + IReadOnlyList Projects, + IReadOnlyList Motions, + IReadOnlyList CategoryPolicies); + +public sealed record MotionProjectOption(Guid Id, string Name); + +public sealed record MotionCategoryPolicyRow(string Label, decimal Threshold); + +public static class MotionCategoryPolicyRows +{ + public static IReadOnlyList All { get; } = + [ + new("Bugs", 0.15m), + new("Cosmetics", 0.30m), + new("Balance changes", 0.40m), + new("Features and new content", 0.50m), + new("Fundamental design changes", 0.66m) + ]; +} + +public sealed record MotionListItem( + Guid Id, + string Title, + string Summary, + string ProjectName, + string Scope, + string Category, + decimal Threshold, + string StatusLabel, + string StatusTone, + decimal VotesFor, + decimal VotesAgainst, + decimal VotesAbstain, + VoteChoice? CurrentUserVote, + DateTimeOffset ClosesAtUtc, + string ResolutionNote); diff --git a/src/Bifrost.Web/Pages/App/Motions/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Motions/Index.cshtml.cs index 89433e9..3d42d85 100644 --- a/src/Bifrost.Web/Pages/App/Motions/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Motions/Index.cshtml.cs @@ -1,20 +1,16 @@ using System.ComponentModel.DataAnnotations; -using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Motions; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.EntityFrameworkCore; namespace Bifrost.Web.Pages.App.Motions; public sealed class IndexModel( - BifrostDbContext dbContext, ICurrentBifrostActorAccessor actorAccessor, - AuditTrailService auditTrailService, - TimeProvider timeProvider) : PageModel + MotionGovernanceService motionGovernanceService) : PageModel { [BindProperty] public CreateMotionInput Input { get; set; } = new() @@ -36,12 +32,6 @@ public sealed class IndexModel( public async Task OnPostCreateAsync(CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); - var actorUser = Actor.UserAccount; - - if (actorUser is null) - { - return Forbid(); - } if (!ModelState.IsValid) { @@ -49,29 +39,15 @@ public sealed class IndexModel( return Page(); } - var now = timeProvider.GetUtcNow(); - var motion = new Motion - { - ProjectId = Input.Scope == MotionScope.Project ? Input.ProjectId : null, - CreatedByUserAccountId = actorUser.Id, - Scope = Input.Scope, - Category = Input.Category, - Title = Input.Title.Trim(), - Summary = Input.Summary.Trim(), - ApprovalThreshold = MotionCategoryPolicy.GetThreshold(Input.Category), - OpensAtUtc = now, - ClosesAtUtc = Input.ClosesAtUtc.ToUniversalTime(), - CreatedAtUtc = now - }; - - dbContext.Motions.Add(motion); - await dbContext.SaveChangesAsync(cancellationToken); - await auditTrailService.RecordAsync( - actorUser.Id, - nameof(Motion), - motion.Id, - "motion.created", - $"Opened motion {motion.Title}.", + await motionGovernanceService.CreateMotionAsync( + Actor, + new CreateMotionCommand( + Input.Scope, + Input.ProjectId, + Input.Category, + Input.Title, + Input.Summary, + Input.ClosesAtUtc), cancellationToken); return RedirectToPage(); @@ -80,166 +56,27 @@ public sealed class IndexModel( public async Task OnPostVoteAsync(Guid motionId, VoteChoice choice, string comment, CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); - var actorUser = Actor.UserAccount; - - if (actorUser is null) - { - return Forbid(); - } - - var motion = await dbContext.Motions - .Include(x => x.Votes) - .SingleAsync(x => x.Id == motionId, cancellationToken); - - if (motion.Status != MotionStatus.Open) - { - return RedirectToPage(); - } - - var vote = motion.Votes.SingleOrDefault(x => x.UserAccountId == actorUser.Id); - if (vote is null) - { - dbContext.Votes.Add(new Vote - { - MotionId = motionId, - UserAccountId = actorUser.Id, - Choice = choice, - Weight = Actor.EffectiveVotingWeight, - Comment = comment?.Trim() ?? string.Empty, - CastAtUtc = timeProvider.GetUtcNow() - }); - } - else - { - vote.Choice = choice; - vote.Weight = Actor.EffectiveVotingWeight; - vote.Comment = comment?.Trim() ?? string.Empty; - vote.CastAtUtc = timeProvider.GetUtcNow(); - } - - await dbContext.SaveChangesAsync(cancellationToken); - await auditTrailService.RecordAsync( - actorUser.Id, - nameof(Motion), - motion.Id, - "motion.voted", - $"{Actor.DisplayName} voted {choice} on {motion.Title}.", - cancellationToken); - + await motionGovernanceService.CastVoteAsync(Actor, motionId, choice, comment, cancellationToken); return RedirectToPage(); } public async Task OnPostCloseAsync(Guid motionId, CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); - var actorUser = Actor.UserAccount; - if (actorUser is null) - { - return Forbid(); - } - - var motion = await dbContext.Motions - .Include(x => x.Votes) - .SingleAsync(x => x.Id == motionId, cancellationToken); - - if (!Actor.CanModerateMotions && motion.CreatedByUserAccountId != actorUser.Id) - { - return Forbid(); - } - - var votesFor = motion.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight); - var votesAgainst = motion.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight); - var decisiveVotes = votesFor + votesAgainst; - - motion.Status = decisiveVotes > 0 && votesFor / decisiveVotes >= motion.ApprovalThreshold - ? MotionStatus.Passed - : decisiveVotes > 0 - ? MotionStatus.Failed - : MotionStatus.Closed; - motion.ResolvedAtUtc = timeProvider.GetUtcNow(); - motion.ResolutionNote = decisiveVotes > 0 - ? $"Closed with {votesFor:0.##} for and {votesAgainst:0.##} against at a {motion.ApprovalThreshold:P0} threshold." - : "Closed without decisive votes."; - - await dbContext.SaveChangesAsync(cancellationToken); - await auditTrailService.RecordAsync( - actorUser.Id, - nameof(Motion), - motion.Id, - "motion.closed", - $"Closed motion {motion.Title} with status {motion.Status}.", - cancellationToken); - + await motionGovernanceService.CloseMotionAsync(Actor, motionId, cancellationToken); return RedirectToPage(); } private async Task LoadAsync(CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); - - ProjectOptions = await dbContext.Projects - .AsNoTracking() - .OrderBy(x => x.Name) + var state = await motionGovernanceService.GetStateAsync(Actor, cancellationToken); + ProjectOptions = state.Projects .Select(x => new SelectListItem(x.Name, x.Id.ToString())) - .ToListAsync(cancellationToken); - - var motions = await dbContext.Motions - .AsNoTracking() - .Include(x => x.Project) - .Include(x => x.Votes) - .OrderBy(x => x.ClosesAtUtc) - .ToListAsync(cancellationToken); - - Motions = motions.Select(x => new MotionListItem( - x.Id, - x.Title, - x.Summary, - x.Project?.Name ?? "Management-wide", - x.Scope.ToString(), - MotionCategoryPolicy.GetLabel(x.Category), - x.ApprovalThreshold, - ResolveStatusLabel(x, timeProvider.GetUtcNow()), - ResolveStatusTone(x, timeProvider.GetUtcNow()), - x.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight), - x.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight), - x.Votes.Where(v => v.Choice == VoteChoice.Abstain).Sum(v => v.Weight), - x.Votes.Where(v => v.UserAccountId == Actor.UserAccount?.Id).Select(v => (VoteChoice?)v.Choice).FirstOrDefault(), - x.ClosesAtUtc, - x.ResolutionNote)).ToList(); + .ToList(); + Motions = state.Motions; } - private static string ResolveStatusLabel(Motion motion, DateTimeOffset now) - { - if (motion.Status != MotionStatus.Open) - { - return motion.Status.ToString(); - } - - if (motion.ClosesAtUtc >= now) - { - return "Open"; - } - - var votesFor = motion.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight); - var votesAgainst = motion.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight); - var decisiveVotes = votesFor + votesAgainst; - if (decisiveVotes <= 0) - { - return "Expired"; - } - - return votesFor / decisiveVotes >= motion.ApprovalThreshold ? "Passed" : "Failed"; - } - - private static string ResolveStatusTone(Motion motion, DateTimeOffset now) => - ResolveStatusLabel(motion, now) switch - { - "Passed" => "success", - "Failed" => "danger", - "Expired" => "warning", - _ => string.Empty - }; - public sealed class CreateMotionInput { [Display(Name = "Scope")] @@ -261,20 +98,4 @@ public sealed class IndexModel( public DateTimeOffset ClosesAtUtc { get; set; } } - public sealed record MotionListItem( - Guid Id, - string Title, - string Summary, - string ProjectName, - string Scope, - string Category, - decimal Threshold, - string StatusLabel, - string StatusTone, - decimal VotesFor, - decimal VotesAgainst, - decimal VotesAbstain, - VoteChoice? CurrentUserVote, - DateTimeOffset ClosesAtUtc, - string ResolutionNote); } diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index f04d84a..e0a50d7 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -1,11 +1,13 @@ using System.Net.Http.Headers; using System.Security.Claims; using System.Text.Json; +using System.Text.Json.Serialization; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Features.GitHub; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Motions; using Bifrost.Web.Features.Patronage; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authentication; @@ -44,6 +46,10 @@ builder.Services.AddRazorPages(options => options.Conventions.AuthorizePage("/Membership/Status"); options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName); }); +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); builder.Services.AddDbContext(options => { @@ -69,6 +75,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -156,6 +164,7 @@ builder.Services.AddAuthorization(options => }); var app = builder.Build(); +var eveJsonSerializerOptions = CreateEveJsonSerializerOptions(); app.Services.GetRequiredService().Validate(); await using (var scope = app.Services.CreateAsyncScope()) @@ -308,6 +317,94 @@ app.MapPost("/heimdall/patron-support/events", async ( }; }).AllowAnonymous(); +var eveGovernance = app.MapGroup("/eve/governance") + .RequireAuthorization(ActiveMemberRequirement.PolicyName); + +eveGovernance.MapGet("/surface", async ( + ICurrentBifrostActorAccessor actorAccessor, + MotionGovernanceService motionGovernanceService, + MotionEveSurfaceService motionEveSurfaceService, + CancellationToken cancellationToken) => +{ + var actor = await actorAccessor.GetAsync(cancellationToken); + var state = await motionGovernanceService.GetStateAsync(actor, cancellationToken); + var surface = motionEveSurfaceService.BuildSurface(state); + return Results.Text( + JsonSerializer.Serialize(surface, eveJsonSerializerOptions), + "application/json"); +}); + +eveGovernance.MapPost("/commands", async ( + MotionEveCommandRequest command, + ICurrentBifrostActorAccessor actorAccessor, + MotionGovernanceService motionGovernanceService, + MotionEveSurfaceService motionEveSurfaceService, + CancellationToken cancellationToken) => +{ + var actor = await actorAccessor.GetAsync(cancellationToken); + + try + { + switch (command.Command) + { + case "motion.create": + if (command.Scope is null || + command.Category is null || + command.ClosesAtUtc is null || + string.IsNullOrWhiteSpace(command.Title) || + string.IsNullOrWhiteSpace(command.Summary)) + { + return Results.BadRequest("motion.create requires scope, category, title, summary, and closesAtUtc."); + } + + await motionGovernanceService.CreateMotionAsync( + actor, + new CreateMotionCommand( + command.Scope.Value, + command.ProjectId, + command.Category.Value, + command.Title, + command.Summary, + command.ClosesAtUtc.Value), + cancellationToken); + break; + case "motion.vote": + if (command.MotionId is null || command.Choice is null) + { + return Results.BadRequest("motion.vote requires motionId and choice."); + } + + await motionGovernanceService.CastVoteAsync( + actor, + command.MotionId.Value, + command.Choice.Value, + command.Comment, + cancellationToken); + break; + case "motion.close": + if (command.MotionId is null) + { + return Results.BadRequest("motion.close requires motionId."); + } + + await motionGovernanceService.CloseMotionAsync(actor, command.MotionId.Value, cancellationToken); + break; + default: + return Results.BadRequest($"Unknown Eve governance command '{command.Command}'."); + } + } + catch (UnauthorizedAccessException) + { + return Results.Forbid(); + } + + var state = await motionGovernanceService.GetStateAsync(actor, cancellationToken); + var surface = motionEveSurfaceService.BuildSurface(state); + return Results.Text( + JsonSerializer.Serialize(surface, eveJsonSerializerOptions), + "application/json"); +}); + app.MapGet("/auth/heimdall/wait", async ( HttpContext httpContext, HeimdallAuthService heimdallAuthService, @@ -346,4 +443,11 @@ app.MapRazorPages(); app.Run(); +static JsonSerializerOptions CreateEveJsonSerializerOptions() +{ + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; +} + public partial class Program; diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index 3984610..d1839a6 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -227,6 +227,73 @@ public sealed class PlatformEndpointsTests : IClassFixture(); + var actor = dbContext.UserAccounts.Single(x => x.NormalizedGitHubLogin == "TEST-ADMIN"); + var motion = new Motion + { + CreatedByUserAccountId = actor.Id, + Scope = MotionScope.Management, + Category = MotionCategory.Features, + Title = "Govern through Eve", + Summary = "The Motion Verse should command the canonical motion path.", + ApprovalThreshold = 0.50m, + OpensAtUtc = DateTimeOffset.UtcNow, + ClosesAtUtc = DateTimeOffset.UtcNow.AddDays(7), + CreatedAtUtc = DateTimeOffset.UtcNow + }; + dbContext.Motions.Add(motion); + await dbContext.SaveChangesAsync(); + motionId = motion.Id; + } + + var payload = JsonSerializer.Serialize(new + { + command = "motion.vote", + motionId, + choice = "For", + comment = "Cast from Eve." + }); + using var request = new HttpRequestMessage(HttpMethod.Post, "/eve/governance/commands"); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var surfaceJson = await response.Content.ReadAsStringAsync(); + Assert.Contains("bifrost-motion-verse", surfaceJson); + + using var verificationScope = _factory.Services.CreateScope(); + var verificationContext = verificationScope.ServiceProvider.GetRequiredService(); + var vote = verificationContext.Votes.Single(x => x.MotionId == motionId); + Assert.Equal(VoteChoice.For, vote.Choice); + Assert.Equal("Cast from Eve.", vote.Comment); + Assert.Contains(verificationContext.AuditEvents, x => x.Action == "motion.voted"); + } + private static string ComputeSignature(string secret, string payload) { using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index 28f5a6a..4c70c76 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -188,6 +188,8 @@ function buildAdvertisement(options) { endpoint("operator-tui", `${locatedService}/eve/tui`, "gamecult.eve.surface.v1", ["tui", "nightwing-tui"]), endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]), endpoint("operator-commands", `${locatedService}/commands`, "bifrost.bridge_action.v0", ["command"]), + endpoint("motion-surface", "https://bifrost.gamecult.org/eve/governance/surface", "gamecult.eve.surface.v1", ["product", "governance", "motion"]), + endpoint("motion-commands", "https://bifrost.gamecult.org/eve/governance/commands", "bifrost.motion_command.v0", ["command", "governance", "motion"]), ], routes: [ route("cultcache-witness", ".bifrost/provider-advertisement.cc", "local-cultcache", true), @@ -217,8 +219,9 @@ function buildAdvertisement(options) { schema("bifrost.governance.topic_comment.v0", "bifrost.governance.topic-comment", "existing CultCache governance comment witness"), schema("bifrost.agent-transport.update-request.v0", "bifrost.agent-transport.update-request", "existing CultCache/CultNet agent intake witness"), schema("bifrost.work_item.v0", "bifrost.work-item", "planned Postgres-to-CultCache work item witness"), - schema("bifrost.motion.v0", "bifrost.motion", "planned app-native motion witness"), - schema("bifrost.vote.v0", "bifrost.vote", "planned motion vote witness"), + schema("bifrost.motion.v0", "bifrost.motion", "app-native motion witness"), + schema("bifrost.vote.v0", "bifrost.vote", "motion vote witness"), + schema("bifrost.motion_command.v0", "bifrost.motion-command", "Eve Motion Verse command envelope for create, vote, and close"), schema("bifrost.ledger_entry.v0", "bifrost.ledger-entry", "planned contributor/patron ledger witness"), schema("bifrost.bridge_action.v0", "bifrost.bridge-action", "planned governed crossing command witness"), schema("bifrost.bridge_receipt.v0", "bifrost.bridge-receipt", "planned governed crossing result witness"), @@ -268,7 +271,7 @@ function buildAdvertisement(options) { "blockers", "completion artifacts", ]), - surface("bifrost.motion", "Motion Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", ".bifrost/eve-surfaces.cc", "motion/eve", [ + surface("bifrost.motion", "Motion Verse", "gamecult.bifrost.surface.product", "gamecult.eve.surface.v1", "/eve/governance/surface", "motion/eve", [ "motions", "topic threads", "votes", @@ -597,7 +600,7 @@ function buildInterfaceBinding(surface, stats) { route("cultcache-witness", ".bifrost/provider-advertisement.cc", "local-cultcache", true), route("websocket-bridge", "ws://192.168.1.66:8797/eve/deck", "compatibility-eve-deck", true), ], - capabilities: ["operator-stats", "bridge-health", "governance-counts", "agent-transport-counts"], + capabilities: ["operator-stats", "bridge-health", "governance-counts", "agent-transport-counts", "motion-surface", "motion-commands"], usesCultMesh: true, status: stats.summary.status, transport: "CultMesh Eve interface binding.",