Start Eve governance surface migration
This commit is contained in:
parent
634f187453
commit
7354d482e8
9 changed files with 675 additions and 203 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
207
src/Bifrost.Web/Features/Motions/MotionEveSurfaceService.cs
Normal file
207
src/Bifrost.Web/Features/Motions/MotionEveSurfaceService.cs
Normal 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);
|
||||
258
src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs
Normal file
258
src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs
Normal 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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue