diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..1bb3c83
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,7 @@
+
+
+
+ true
+
+
+
diff --git a/Dockerfile b/Dockerfile
index 4f607e6..45072e5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,14 +1,15 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
-COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/
-RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj
+COPY Directory.Build.props .
+COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/packages.lock.json src/Bifrost.Web/
+RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj --locked-mode
COPY . .
RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \
--configuration Release \
--output /app/publish \
- /p:UseAppHost=false
+ /p:UseAppHost=false /p:RestoreLockedMode=true
FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime
WORKDIR /app
diff --git a/docs/bridge.md b/docs/bridge.md
index 683d7d2..d971d0c 100644
--- a/docs/bridge.md
+++ b/docs/bridge.md
@@ -196,15 +196,7 @@ The command is a dry-run shape probe for public surfaces that are real enough to
Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona.
-Persona replies cross back through two explicit CultMesh surfaces. Epiphany is
-the sole writer of `epiphany.persona_discord_delivery_request.v0`; Bifrost
-reads that surface, authenticates the dedicated Persona-mouth signer, journals
-external execution before touching Discord, and is the sole writer of
-`bifrost.persona_discord_delivery_receipt.v0`. Epiphany reads receipts but
-never receives the Discord token or write access to Bifrost private state.
-`unknown` is terminal for automatic transport retry: an external post may have
-happened, so restart recovery must not manufacture a duplicate. Failed and
-unknown outcomes remain Persona pressure for explicit resolution.
+Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds//control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`. The Discord posting actuator, its permit and its RUDP listener are removed.
Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority.
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..a7f59a4
--- /dev/null
+++ b/docs/oidc-issuer.md
@@ -0,0 +1,113 @@
+# 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. |
+| `Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri` | Optional. MAS's back-channel logout URL, `https:///upstream/backchannel-logout/`; absolute http(s) or startup fails. Unset: no logout is sent and no authorization is revoked. |
+
+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.
+
+## Back-channel logout
+
+When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the
+subjects holding valid authorizations of the MAS client; for each subject whose membership is not `Active` (the same
+reading the token and userinfo endpoints use; a deleted account or a subject that is not an account id counts as not
+`Active`) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations,
+which also invalidates their tokens. Membership is never written by this path: any way of leaving `Active` (admin
+action, sync, a database edit, a future suspend) is covered because the work is derived from membership state.
+
+The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it,
+`aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the
+`http://schemas.openid.net/event/backchannel-logout` event. It carries no `nonce` and no `sid`; MAS finds the sessions
+by `sub`. It is sent as `application/x-www-form-urlencoded` field `logout_token` with a 10 second timeout, and the
+response body is never logged.
+
+Failure handling is per subject, in memory: a 5xx, a timeout or a network fault backs off exponentially from 30 seconds
+to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configuration fault, logged by status only,
+and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing
+else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions.
+
+Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions.
+
+MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an
+id_token (`iss`, `aud`, `alg`, JWKS signature), `exp` and `iat` are required, `sub` or `sid` is required, `nonce` must be
+absent. Owed on the MAS side (gamecult-chat): the provider's `backchannel_logout_uri` and
+`on_backchannel_logout: logout_all`. Re-check against the MAS version pinned there.
+
+## 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/docs/research/2026-10-29-estate-tally-game-theory/README.md b/docs/research/2026-10-29-estate-tally-game-theory/README.md
new file mode 100644
index 0000000..f67a9a5
--- /dev/null
+++ b/docs/research/2026-10-29-estate-tally-game-theory/README.md
@@ -0,0 +1,330 @@
+# Eyes: estate internal tally, game-theory probes (2026-10-29)
+
+Fork: Fork Desk question 39, `eureka-body:question:estate-internal-tally` (InForce; options proportional | winner-take-all; Self's recommended: proportional). Context ruling `eureka-body:ruling:governance-estate-blocks`. Session self-2026-10-29-eureka. Facts and measured results only, no recommendation.
+Operator additions folded in (her words relayed by the Self): within-bloc vote-power curves ("Patrons vote power scales with donation total, but it's logarithmic... Member vote should scale with contribution... via a lower exponent"); present case ("I'm the only active member"); renormalization ruling ("normalize it by who actually voted").
+
+## Code, outputs, model
+Dir `sims/` (numpy 2.5.4, Python 3.13, `python -I
+