diff --git a/docs/context.md b/docs/context.md
index fdcef97..542ff8d 100644
--- a/docs/context.md
+++ b/docs/context.md
@@ -18,8 +18,10 @@ The product center is the Labor Platform loop: eligible participants express pol
- source of deployment and infrastructure conventions
- documents Yggdrasil, nginx, systemd, and local-Postgres patterns
- `E:\Projects\Heimdall`
- - owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation
- - Bifrost consumes Heimdall identity/capability facts instead of becoming the credential vault
+ - authenticates: owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation
+ - does not issue GameCult identity: its account id (`HeimdallAccountId`) is an authenticator handle, never who a person is on GameCult
+ - Bifrost consumes Heimdall authentication/capability facts instead of becoming the credential vault
+ - remaining scar: Heimdall still mints its own account id in code (`src/app.ts` `createAccount`); chat does not need that to move, so it has not
- `E:\Projects\VoidBot`
- observes Discord, packages consensus, and runs repo Persona heartbeats
- should call Bifrost for governed GitHub/Discord/Reddit/intake crossings instead of becoming hidden transport authority
@@ -55,7 +57,7 @@ After bootstrap, the next session should start implementing the first vertical s
- member alpha first
- invite-only onboarding
-- GitHub as the only auth provider in v1
+- Bifrost is the GameCult identity issuer: `UserAccount.Id` is the subject, and Bifrost is the OIDC provider for chat (`docs/oidc-issuer.md`); GitHub, native, and Heimdall-linked sign-in only authenticate into it
- GitHub issues and internal tasks both supported
- app-native governance and voting
- priority and reward pressure should be transparent and auditable
diff --git a/docs/jurisdiction-map.md b/docs/jurisdiction-map.md
index c0a2754..a251b00 100644
--- a/docs/jurisdiction-map.md
+++ b/docs/jurisdiction-map.md
@@ -103,6 +103,8 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut
- signed identity and capability claims
- grants, consent, revocation, and capability evaluation
+Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity.
+
Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed.
### Patron Collection Crossing
@@ -153,7 +155,7 @@ Repo Personas may generate bridge requests. They do not own the bridge itself.
| Concern | Owner | Reason |
| --- | --- | --- |
-| Who is this actor? | Heimdall | Identity and account custody must stay with the auth authority. |
+| Who is this actor? | Bifrost (identity) with Heimdall (authentication) | Bifrost issues the identity; account custody and credential proof stay with the auth authority. |
| Is this actor allowed to request this crossing? | Heimdall plus Bifrost | Heimdall issues grants; Bifrost applies bridge-domain policy. |
| What work is being requested? | Bifrost | The request shape, priority, provenance, and lifecycle are governance concerns. |
| How does interest become reward pressure? | Bifrost | Priority, bounty growth, vote weight, and reward allocation are Labor Platform concerns. |
@@ -205,6 +207,7 @@ This is not the final hosted control plane. It is the current bridge actuator an
- Do not add Bifrost-scoped Discord transport or dispatch receipts to arbitrary repo Persona scripts. Call Bifrost.
- Do not add Bifrost-scoped Reddit posting or Persona flair handling to arbitrary repo Persona scripts. Call Bifrost.
- Do not put OAuth credentials or account custody into Bifrost helper scripts as the long-term plan. Use Heimdall claims.
+- Do not hand Heimdall ids, GitHub ids, or display names out as the GameCult subject. The OIDC `sub` is `UserAccount.Id`, and the localpart is `NormalizedBifrostIdentity`, minted once.
- Do not make CultCache documents decide policy. They store packets; Bifrost gives those packets meaning.
- Do not let a repo Persona turn a proposal into endless Aquarium pressure once it has enough shape. Route it to Bifrost as a reviewable artifact.
- Do not expose bridge debug noise to public channels. Public receipts should say what happened, which Bifrost topic or work item owns it, where it landed, and what comes next.
diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md
new file mode 100644
index 0000000..5698c25
--- /dev/null
+++ b/docs/oidc-issuer.md
@@ -0,0 +1,85 @@
+# Bifrost OIDC issuer
+
+Bifrost is the GameCult identity issuer. It runs an OpenID Connect provider on its public host so that Matrix
+Authentication Service (MAS) can sign chat users in with their Bifrost account. Heimdall authenticates people into
+Bifrost; it does not issue the identity chat sees.
+
+The provider is OpenIddict (`OpenIddict.AspNetCore` and `OpenIddict.EntityFrameworkCore`, 7.7.1). Bifrost owns two
+decisions at the authorize endpoint: who the chat user is, and whether they may get a session.
+
+## Endpoints
+
+The issuer is `Host:PublicBaseUrl`, normalized by .NET into an absolute URI. For a bare host that adds a trailing
+slash, so for `https://bifrost.gamecult.org` the issuer string is `https://bifrost.gamecult.org/`. Configure MAS with
+that exact string.
+
+| Endpoint | Path |
+| --- | --- |
+| Discovery | `/.well-known/openid-configuration` |
+| JWKS | `/.well-known/jwks` |
+| Authorization | `/connect/authorize` |
+| Token | `/connect/token` |
+| Userinfo | `/connect/userinfo` |
+
+Only the authorization code flow exists, and PKCE (S256) is required. There is no implicit, hybrid, password,
+client-credentials or device flow, no refresh token, and no dynamic client registration. The token endpoint takes
+`client_secret_basic` only. Scopes are `openid` and `profile`.
+
+## The one client
+
+Exactly one client is registered, from configuration, and seeded or updated at startup; every other application
+row (with its authorizations and tokens) is deleted at startup. Any other `client_id`, an unregistered `redirect_uri`, or a request without PKCE is refused by the
+provider before any sign-in redirect.
+
+## Configuration
+
+The section is `Bifrost:Oidc`. Absent, the provider surface is not mapped at all and discovery answers 404. Present,
+it must be complete: an unreadable or wrong-shaped file stops startup with a message that names the setting and never
+echoes its value.
+
+| Setting | Meaning |
+| --- | --- |
+| `Bifrost:Oidc:SigningKeyPath` | PEM file with an EC P-256 private key. Tokens and id_tokens are signed ES256; JWKS publishes exactly that key. A non-P-256 or public-only key stops startup. |
+| `Bifrost:Oidc:EncryptionKeyPath` | File holding 32 random bytes as base64 (`openssl rand -base64 32`). Encrypts the tokens OpenIddict issues, the access token and the parked authorize request among them. |
+| `Bifrost:Oidc:Clients:Mas:ClientId` | The client id MAS presents. |
+| `Bifrost:Oidc:Clients:Mas:ClientSecretPath` | File holding the client secret. |
+| `Bifrost:Oidc:Clients:Mas:RedirectUri` | MAS's callback URL, registered exactly. |
+
+There are no development or ephemeral keys in the host. Key files are provisioned like the other
+`[workload.secret_files]` paths.
+
+## Claims
+
+| Claim | Value |
+| --- | --- |
+| `sub` | `UserAccount.Id`, formatted `D` |
+| `preferred_username` | `NormalizedBifrostIdentity` (minted once; there is no rename path) |
+| `name` | `DisplayName`, else `BifrostIdentity` |
+
+No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost.
+
+## Who may sign in
+
+The authorize endpoint reads the cookie session and the same actor facts the `/App` policy reads.
+
+- No session: challenge to `/auth/sign-in`, which resumes the original request after sign-in. OpenIddict parks the
+ full request (its request token, in the OpenIddict token table, expiring after ten minutes, spent when used) and
+ the return path carries only a short `request_uri`, so `LocalReturnPath.MaxLength` stays 256.
+- Signed in, membership not `Active`: redirect to the client's `redirect_uri` with `error=access_denied` and
+ `error_description=The account is not an active GameCult member.`
+- Signed in, `Active`, but `NormalizedBifrostIdentity` empty: `error=access_denied` with
+ `error_description=The account has no Bifrost identity.`
+- Signed in and `Active`: a code is issued.
+- Token endpoint: membership is read again; an account no longer `Active` gets `invalid_grant` and no tokens.
+
+## MAS configuration
+
+Configure MAS's upstream provider with the issuer above, the client id and secret, the token endpoint auth method
+`client_secret_basic`, the id_token signing algorithm `ES256`, scope `openid profile`, and a localpart claim import
+from `preferred_username`. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an
+account.
+
+## Remaining scar
+
+Heimdall still mints its own account id in code (`src/app.ts` `createAccount`). That id is an authenticator handle
+and chat does not need it to move.
diff --git a/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj
index 128d84e..7cd14b3 100644
--- a/src/Bifrost.Web/Bifrost.Web.csproj
+++ b/src/Bifrost.Web/Bifrost.Web.csproj
@@ -17,6 +17,8 @@
+
+
diff --git a/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs
new file mode 100644
index 0000000..1a14457
--- /dev/null
+++ b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs
@@ -0,0 +1,41 @@
+namespace Bifrost.Web.Configuration;
+
+///
+/// The OIDC provider surface (section Bifrost:Oidc). An absent section means the provider is not mapped at
+/// all; a present section is complete or startup fails. Keys and the client secret are named by file path, never inlined.
+///
+public sealed class OidcIssuerOptions
+{
+ public const string SectionName = "Bifrost:Oidc";
+
+ /// PEM file holding the EC P-256 private key that signs tokens (ES256).
+ public string SigningKeyPath { get; init; } = string.Empty;
+
+ /// File holding the 32-byte symmetric token-encryption key as base64.
+ public string EncryptionKeyPath { get; init; } = string.Empty;
+
+ public OidcClientsOptions Clients { get; init; } = new();
+
+ /// Null when the section is absent.
+ public static OidcIssuerOptions? FromConfiguration(IConfiguration configuration)
+ {
+ var section = configuration.GetSection(SectionName);
+ return section.Exists() ? section.Get() ?? new OidcIssuerOptions() : null;
+ }
+}
+
+public sealed class OidcClientsOptions
+{
+ /// Matrix Authentication Service, the one client of this provider.
+ public OidcClientOptions Mas { get; init; } = new();
+}
+
+public sealed class OidcClientOptions
+{
+ public string ClientId { get; init; } = string.Empty;
+
+ /// File holding the client secret.
+ public string ClientSecretPath { get; init; } = string.Empty;
+
+ public string RedirectUri { get; init; } = string.Empty;
+}
diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs
index 9863cfd..5596594 100644
--- a/src/Bifrost.Web/Data/BifrostDbContext.cs
+++ b/src/Bifrost.Web/Data/BifrostDbContext.cs
@@ -5,6 +5,10 @@ namespace Bifrost.Web.Data;
public sealed class BifrostDbContext(DbContextOptions options) : DbContext(options)
{
+ // The OpenIddict tables are part of this model for every way a context is built: the host, the concepts
+ // command, tests and the design-time factory that generates migrations.
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseOpenIddict();
+
public DbSet UserAccounts => Set();
public DbSet MemberProfiles => Set();
diff --git a/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs b/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs
new file mode 100644
index 0000000..154c5ae
--- /dev/null
+++ b/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs
@@ -0,0 +1,2441 @@
+//
+using System;
+using Bifrost.Web.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Bifrost.Web.Data.Migrations
+{
+ [DbContext(typeof(BifrostDbContext))]
+ [Migration("20261022163159_AddOpenIddict")]
+ partial class AddOpenIddict
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.30")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CompletedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Error")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("LaunchMode")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)");
+
+ b.Property("LogPath")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Note")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RequestId")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("ResultPath")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("StartedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("StartedByUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TargetAgentIdentity")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("TargetRepoName")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("TargetRepositoryFullName")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("ThreadId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("TurnId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WorkerProcessId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RequestId");
+
+ b.HasIndex("StartedByUserAccountId");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("TargetRepoName", "TargetAgentIdentity", "Status");
+
+ b.ToTable("AgentDispatchRuns");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.AgentTransportReceipt", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ActivityKind")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ActorName")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("ActorUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("Note")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RequestId")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)");
+
+ b.Property("TargetAgentIdentity")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("TargetRepoName")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("TargetRepositoryFullName")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActorUserAccountId");
+
+ b.HasIndex("OccurredAtUtc");
+
+ b.HasIndex("RequestId");
+
+ b.HasIndex("TargetRepoName", "TargetAgentIdentity", "ActivityKind");
+
+ b.ToTable("AgentTransportReceipts");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AssignedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AssignedByUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("Note")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("WorkItemId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AssignedByUserAccountId");
+
+ b.HasIndex("UserAccountId");
+
+ b.HasIndex("WorkItemId");
+
+ b.ToTable("Assignments");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("ActorUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("Detail")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("EntityId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActorUserAccountId");
+
+ b.ToTable("AuditEvents");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ActionKind")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ActorKind")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ActorName")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("ActorUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("AuthorityReference")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("BifrostIdentity")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("CompletedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EpiphanyAgentIdentity")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("EpiphanyLaneId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("EpiphanyRunId")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("ExternalReceiptId")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("FailureReason")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("HeimdallCapabilityReference")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("MotionId")
+ .HasColumnType("uuid");
+
+ b.Property("PolicyDecision")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ReceiptPayload")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ReceiptUrl")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("RequestedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SourceId")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("SourceKind")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("StartedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Summary")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TargetLocator")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("TargetRepositoryFullName")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("TargetSurface")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TargetSurfaceName")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WorkItemId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActorUserAccountId");
+
+ b.HasIndex("BifrostIdentity");
+
+ b.HasIndex("HeimdallCapabilityReference");
+
+ b.HasIndex("MotionId");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("WorkItemId");
+
+ b.HasIndex("ActorKind", "ActorName");
+
+ b.HasIndex("SourceKind", "SourceId");
+
+ b.HasIndex("TargetSurface", "ActionKind", "Status");
+
+ b.HasIndex("TargetSurface", "TargetSurfaceName", "Status");
+
+ b.ToTable("BridgeActions");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CastAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NetworkTag")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Stance")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("SubjectId")
+ .HasColumnType("uuid");
+
+ b.Property("UserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("VoterClass")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("VoterKey")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserAccountId");
+
+ b.HasIndex("NetworkTag", "CastAtUtc");
+
+ b.HasIndex("SubjectId", "VoterKey")
+ .IsUnique();
+
+ b.ToTable("ConceptAppraisals");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.ConceptSubject", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Collection")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)");
+
+ b.Property("RegisteredAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RetiredAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Collection", "Key")
+ .IsUnique();
+
+ b.ToTable("ConceptSubjects");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RanAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Rate")
+ .HasPrecision(8, 4)
+ .HasColumnType("numeric(8,4)");
+
+ b.HasKey("Id");
+
+ b.ToTable("DecayRuns");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("IssueNumber")
+ .HasColumnType("integer");
+
+ b.Property("IssueUrl")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("LastSynchronizedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RepositoryFullName")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("State")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TitleSnapshot")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("WorkItemId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("WorkItemId")
+ .IsUnique();
+
+ b.HasIndex("RepositoryFullName", "IssueNumber")
+ .IsUnique();
+
+ b.ToTable("GitHubIssueLinks");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("IsMerged")
+ .HasColumnType("boolean");
+
+ b.Property("LastSynchronizedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PullRequestNumber")
+ .HasColumnType("integer");
+
+ b.Property("PullRequestUrl")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("RepositoryFullName")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("ReviewDecision")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("State")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TitleSnapshot")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("WorkItemId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("WorkItemId");
+
+ b.HasIndex("RepositoryFullName", "PullRequestNumber")
+ .IsUnique();
+
+ b.ToTable("GitHubPullRequestLinks");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ActivityKind")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ActorKind")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)");
+
+ b.Property("ActorName")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("ActorUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("CommentId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("DispatchRequestId")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("JurisdictionAgentIdentity")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("JurisdictionRepoName")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("Note")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("TopicId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActorUserAccountId");
+
+ b.HasIndex("OccurredAtUtc");
+
+ b.HasIndex("TopicId");
+
+ b.HasIndex("JurisdictionRepoName", "JurisdictionAgentIdentity", "ActivityKind");
+
+ b.ToTable("GovernanceActivityReceipts");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedByUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("EffectiveAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EntryType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("NominalAmount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("Note")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Points")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("ProjectId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("WorkItemId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedByUserAccountId");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("UserAccountId");
+
+ b.HasIndex("WorkItemId");
+
+ b.ToTable("LedgerEntries");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Availability")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Headline")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("Nickname")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PortfolioUrl")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Skills")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserAccountId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserAccountId")
+ .IsUnique();
+
+ b.ToTable("MemberProfiles");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.Membership", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ApprovedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CanManageLedger")
+ .HasColumnType("boolean");
+
+ b.Property("CanManageProjects")
+ .HasColumnType("boolean");
+
+ b.Property("CanModerateMotions")
+ .HasColumnType("boolean");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsPlatformAdmin")
+ .HasColumnType("boolean");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserAccountId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserAccountId")
+ .IsUnique();
+
+ b.ToTable("Memberships");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AcceptedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AcceptedByUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("GitHubLogin")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("IssuedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IssuedByUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("NormalizedGitHubLogin")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RevokedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AcceptedByUserAccountId");
+
+ b.HasIndex("IssuedByUserAccountId");
+
+ b.HasIndex("NormalizedGitHubLogin")
+ .IsUnique();
+
+ b.ToTable("MembershipInvitations");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.Motion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ApprovalThreshold")
+ .HasPrecision(6, 3)
+ .HasColumnType("numeric(6,3)");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ClosesAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedByUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("OpensAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ProjectId")
+ .HasColumnType("uuid");
+
+ b.Property("ResolutionNote")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ResolvedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Scope")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Summary")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedByUserAccountId");
+
+ b.HasIndex("ProjectId");
+
+ b.ToTable("Motions");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("CurrencyCode")
+ .IsRequired()
+ .HasMaxLength(12)
+ .HasColumnType("character varying(12)");
+
+ b.Property("ExternalSupportId")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("IsCurrentRecurringSupport")
+ .HasColumnType("boolean");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProjectId")
+ .HasColumnType("uuid");
+
+ b.Property("Provider")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProviderEventId")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("ProviderPayerId")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("ProviderSubscriptionId")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("RecordedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SupportedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserAccountId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("Provider", "ProviderEventId")
+ .IsUnique()
+ .HasFilter("\"ProviderEventId\" <> ''");
+
+ b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport");
+
+ b.ToTable("PatronSupportEvents");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedByUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Summary")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedByUserAccountId");
+
+ b.ToTable("PayoutProposalBatches");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsDecaying")
+ .HasColumnType("boolean");
+
+ b.Property("Note")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProjectId")
+ .HasColumnType("uuid");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("WorkItemId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("UserAccountId");
+
+ b.HasIndex("WorkItemId");
+
+ b.ToTable("PointTransactions");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.Project", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GitHubRepository")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(180)
+ .HasColumnType("character varying(180)");
+
+ b.Property("OwnerUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Summary")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GitHubRepository");
+
+ b.HasIndex("OwnerUserAccountId");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.ToTable("Projects");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("CurrencyCode")
+ .IsRequired()
+ .HasMaxLength(12)
+ .HasColumnType("character varying(12)");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProjectId")
+ .HasColumnType("uuid");
+
+ b.Property("ReceivedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.ToTable("RevenueEvents");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedByUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("RevenueEventId")
+ .HasColumnType("uuid");
+
+ b.Property("Summary")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedByUserAccountId");
+
+ b.HasIndex("RevenueEventId");
+
+ b.ToTable("RevenueShareBatches");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("Basis")
+ .IsRequired()
+ .HasMaxLength(240)
+ .HasColumnType("character varying(240)");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProjectId")
+ .HasColumnType("uuid");
+
+ b.Property("RevenueShareBatchId")
+ .HasColumnType("uuid");
+
+ b.Property("UserAccountId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("RevenueShareBatchId");
+
+ b.HasIndex("UserAccountId");
+
+ b.ToTable("RevenueShareLines");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AssignedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AssignedByUserAccountId")
+ .HasColumnType("uuid");
+
+ b.Property("MembershipId")
+ .HasColumnType("uuid");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AssignedByUserAccountId");
+
+ b.HasIndex("MembershipId", "Role")
+ .IsUnique();
+
+ b.ToTable("RoleAssignments");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CapturedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EffectiveFromUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsCurrent")
+ .HasColumnType("boolean");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("MembershipId")
+ .HasColumnType("uuid");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Weight")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MembershipId", "Kind", "IsCurrent");
+
+ b.ToTable("TierSnapshots");
+ });
+
+ modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b =>
+ {
+ b.Property