Start Eve governance surface migration

This commit is contained in:
Meta Vens 2026-06-09 20:27:46 +01:00
commit 7354d482e8
9 changed files with 675 additions and 203 deletions

View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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<string, object?>
{
["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<string, object?>
{
["title"] = "Open Motion",
["command"] = "motion.create",
["target"] = "/eve/governance/commands",
["method"] = "POST"
},
[
SelectNode("scope", "Scope", EnumNames<MotionScope>()),
SelectNode("projectId", "Project", state.Projects.Select(x => new EveOption(x.Id.ToString(), x.Name)).Prepend(new EveOption("", "No project scope"))),
SelectNode("category", "Category", EnumNames<MotionCategory>()),
InputNode("title", "Title", "text"),
InputNode("summary", "Summary", "multiline"),
InputNode("closesAtUtc", "Close at UTC", "datetime")
]),
new EveNode(
"vote-ledger",
"panel",
new Dictionary<string, object?>
{
["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<string, object?> { ["title"] = "Motions" },
motionNodes)
]));
}
private static EveNode BuildMotionCard(MotionListItem motion)
{
var commandNodes = new List<EveNode>();
foreach (var choice in Enum.GetValues<VoteChoice>())
{
commandNodes.Add(CommandNode(
$"vote-{motion.Id}-{choice}",
$"Vote {choice}",
"motion.vote",
new Dictionary<string, object?>
{
["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<string, object?> { ["motionId"] = motion.Id },
"secondary"));
}
return new EveNode(
$"motion-{motion.Id}",
"card",
new Dictionary<string, object?>
{
["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<string, object?>
{
["for"] = motion.VotesFor,
["against"] = motion.VotesAgainst,
["abstain"] = motion.VotesAbstain,
["currentUserVote"] = motion.CurrentUserVote?.ToString() ?? string.Empty
}),
new EveNode(
$"motion-commands-{motion.Id}",
"commands",
new Dictionary<string, object?> { ["target"] = "/eve/governance/commands" },
commandNodes)
]);
}
private static EveNode CommandNode(
string id,
string label,
string command,
IReadOnlyDictionary<string, object?> payload,
string tone) =>
new(
id,
"command",
new Dictionary<string, object?>
{
["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<string, object?>
{
["name"] = id,
["label"] = label,
["inputKind"] = inputKind
});
private static EveNode SelectNode(string id, string label, IEnumerable<string> options) =>
SelectNode(id, label, options.Select(x => new EveOption(x, x)));
private static EveNode SelectNode(string id, string label, IEnumerable<EveOption> options) =>
new(
$"input-{id}",
"select",
new Dictionary<string, object?>
{
["name"] = id,
["label"] = label,
["options"] = options.ToArray()
});
private static EveNode TextNode(string id, string text) =>
new(id, "text", new Dictionary<string, object?> { ["text"] = text });
private static IReadOnlyList<string> EnumNames<TEnum>() where TEnum : struct, Enum =>
Enum.GetNames<TEnum>();
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<string, object?> Props,
IReadOnlyList<EveNode>? 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);

View file

@ -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<Motion> 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<MotionGovernanceState> 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<MotionProjectOption> Projects,
IReadOnlyList<MotionListItem> Motions,
IReadOnlyList<MotionCategoryPolicyRow> CategoryPolicies);
public sealed record MotionProjectOption(Guid Id, string Name);
public sealed record MotionCategoryPolicyRow(string Label, decimal Threshold);
public static class MotionCategoryPolicyRows
{
public static IReadOnlyList<MotionCategoryPolicyRow> 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);

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
}

View file

@ -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<BifrostDbContext>(options =>
{
@ -69,6 +75,8 @@ builder.Services.AddScoped<AuditTrailService>();
builder.Services.AddScoped<DashboardSnapshotService>();
builder.Services.AddScoped<MembershipSynchronizationService>();
builder.Services.AddScoped<GitHubWebhookService>();
builder.Services.AddScoped<MotionGovernanceService>();
builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>();
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
builder.Services.AddScoped<ReadinessService>();
@ -156,6 +164,7 @@ builder.Services.AddAuthorization(options =>
});
var app = builder.Build();
var eveJsonSerializerOptions = CreateEveJsonSerializerOptions();
app.Services.GetRequiredService<StartupConfigurationValidator>().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;

View file

@ -227,6 +227,73 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
Assert.Empty(currentPatronTiers);
}
[Fact]
public async Task Eve_governance_surface_returns_motion_verse()
{
using var client = _factory.CreateClient();
using var response = await client.GetAsync("/eve/governance/surface");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadAsStringAsync();
Assert.Contains("gamecult.eve.surface.v1", json);
Assert.Contains("bifrost-motion-verse", json);
Assert.Contains("motion.create", json);
Assert.Contains("/eve/governance/commands", json);
}
[Fact]
public async Task Eve_governance_vote_command_uses_canonical_motion_commit_path()
{
using var client = _factory.CreateClient();
_ = await client.GetAsync("/App");
Guid motionId;
using (var setupScope = _factory.Services.CreateScope())
{
var dbContext = setupScope.ServiceProvider.GetRequiredService<BifrostDbContext>();
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<BifrostDbContext>();
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));

View file

@ -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.",