From 8abd9129176dda3c2dc29701a5737bf39caa6c22 Mon Sep 17 00:00:00 2001
From: Heimdall
Date: Fri, 6 Nov 2026 21:26:13 +0000
Subject: [PATCH 001/102] docs: guest accounts design (role field, invite,
grants, leave and erase)
Co-Authored-By: Claude Sonnet 5.5
---
docs/guest-accounts.md | 255 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 255 insertions(+)
create mode 100644 docs/guest-accounts.md
diff --git a/docs/guest-accounts.md b/docs/guest-accounts.md
new file mode 100644
index 0000000..3044056
--- /dev/null
+++ b/docs/guest-accounts.md
@@ -0,0 +1,255 @@
+# Guest accounts
+
+Status: **design, nothing implemented.** Written for `eureka-body:follow_up:bifrost-guest-accounts` under
+`eureka-body:ruling:guest-arrival-bifrost` (Meta: guests need no Matrix account of their own; they get a GameCult
+account through Bifrost with a guest role, and move to member, user or patron on the same account seamlessly),
+building on `eureka-body:ruling:bifrost-owns-accounts-invite-only`. I could not `view` either ruling from the
+Heimdall mind, so their text here is the follow-up brief's wording; check it against the rulings. First guest:
+sletz (Faust developer). Aqua decides on `#aquasynth` access, so the first grant list is hers to name.
+
+Read at Bifrost `bf8ff62` (`src/Bifrost.Web/Domain/BifrostModels.cs`, `Features/Membership`, `Features/Heimdall`,
+`Features/Oidc`, `Features/Motions`, `Features/ConceptVotes`, `Pages/App/Members`) and Heimdall `575be9c`
+(`src/app-profiles.ts`, `docs/app-profiles/bifrost.md`).
+
+## The shape in one paragraph
+
+A guest is a `UserAccount` like any other: same `Id` (the OIDC `sub`), same `NormalizedBifrostIdentity` (the Matrix
+localpart, minted once). What is new is one field, `Membership.Role`, with four values: `guest`, `user`, `patron`,
+`member`. Role is the single place that says what an account may do. A role change rewrites that field and writes a
+receipt; it never creates a second account, never re-mints the identity, so chat history, grants and the Matrix
+user stay attached.
+
+## What exists today
+
+- `UserAccount.Id` is the OIDC subject; `NormalizedBifrostIdentity` is `preferred_username` and, through MAS, the
+ Matrix localpart (`docs/oidc-issuer.md`). `HeimdallAccountId` is only an authenticator handle.
+- `Membership.Status` is `Authenticated | PendingApproval | Active | Suspended`. `CurrentBifrostActor.IsActiveMember`
+ (`Status == Active`) is the one test behind `/App`, bridge transport (`BridgeActionService` l.492), motion votes
+ (`EffectiveVotingWeight`, `MotionGovernanceService` l.72), concept votes (`VoterIdentity.For`, member versus
+ account class) and the OIDC authorize, token and userinfo endpoints (`OidcEndpoints` l.50, l.131) and the
+ back-channel logout reconciler.
+- `MemberRole` (`StandardMember`, `Producer`, `Maintainer`, `LedgerReviewer`, `PlatformAdmin`) are capability
+ roles a member holds in addition. They are not the new field.
+- Invites are `MembershipInvitation`, keyed by GitHub login (`MembershipSynchronizationService` l.37,
+ `Members/Index.cshtml.cs` l.34). A guest may have no GitHub login at all.
+- Heimdall sign-in (`HeimdallAuthService.StartAsync`) with `requireMemberAccess = false` already creates an
+ identity-only account at `Authenticated`; with member access it creates `Active`.
+- Nothing exists for room grants, leaving, erasing, or any "estate" in code (estates are rulings and research
+ only, `docs/charter-amendment-proposal.md`).
+
+## Decision: one role field, status stays lifecycle
+
+Two authorities cannot both say "may this account vote". Today `Status == Active` says it. The cut:
+
+- `Membership.Status` keeps its meaning as lifecycle of the account: may it hold a session. Add `Left` and
+ `Erased` (see Leave and erase). `Active` means admitted and in good standing, for any role.
+- `Membership.Role` (new, non-null, enum `AccountRole`) decides rights. `IsActiveMember` becomes
+ `Status == Active && Role == Member`, and is the only place that expression exists. Every current
+ `IsActiveMember` caller keeps working unchanged and a guest fails all of them without a per-site edit.
+- OIDC authorize, token, userinfo and the logout reconciler change their test from "Active member" to
+ "`Status == Active`" (any role), so a guest, user or patron can hold a chat session and a member demoted to
+ `Suspended` still loses it. This is the one behavioural change outside new code, and it is the line to review.
+- Backfill: `Active` becomes `member`; `Authenticated` and `PendingApproval` become `user`; an account with a
+ current `Patron` tier snapshot and not `Active` becomes `patron`. Nobody starts as `guest`.
+
+Naming risk: `MemberRole` already exists for capability roles. If the collision bites, call the new field
+`Class`; the published field below follows whichever name is chosen. I use `Role` because that is the ruling's word.
+
+Alternative considered and not recommended: a boolean `IsGuest` on `Membership` with guests kept at `Active`.
+Smaller to land, but `user` and `patron` stay implicit in status and tier snapshots, so "the role field Personas
+read" would have to be derived in three places. One stored field is one reading.
+
+## Invite shape
+
+New table `GuestInvitation`, separate from `MembershipInvitation` (which is GitHub-login-keyed and leads to
+approval, not to a guest). Fields:
+
+| field | rule |
+|---|---|
+| `Id` | Guid. |
+| `TokenHash` | SHA-256 of a 256-bit random token. The token itself is shown to the issuer once, as a link, and never stored. |
+| `IntendedIdentity` | the localpart the issuer picks (for example `sletz`), validated by `BifrostIdentityService.NormalizeIdentity`, reserved while the invite is open. Minted once on accept; a collision gets the usual `-2` suffix. |
+| `Label` | free note for the issuer ("sletz, Faust developer"). |
+| `RoomGrants` | the rooms to grant on accept (below), as a list of grant requests. |
+| `IssuedByUserAccountId`, `IssuedAtUtc` | issuer must be a platform admin (`CanManageMembership`), as for member invites. |
+| `ExpiresAtUtc` | default seven days. |
+| `AcceptedByUserAccountId`, `AcceptedAtUtc`, `RevokedAtUtc` | one use; revoke before accept ends it. |
+
+The link is a bearer: whoever signs in first with it is the guest. That is the cost of not needing sletz's
+provider handle in advance, and it is why the token is single-use and short-lived and why the accept is shown
+to the issuer. Recommended over binding the invite to a provider handle (which needs a Discord or Google id the
+issuer may not have). The issuer may still restrict `ExpectedProvider`; open fork, see Questions.
+
+## Sign-in via Heimdall
+
+1. Guest opens `/invite/guest/`. Bifrost checks hash, expiry, unused, unrevoked, and stores the invite id in
+ the existing Heimdall attempt (`HeimdallAuthService` protects attempt state with the data protector).
+2. Bifrost starts Heimdall sign-in with `requireMemberAccess = false` (identity only, no entitlement policy).
+ Heimdall authenticates; it does not issue the identity (`docs/jurisdiction-map.md`).
+3. On the callback, in one transaction: resolve `HeimdallAccountId`.
+ - No account: create `UserAccount` with `IntendedIdentity`, `Membership { Status = Active, Role = Guest }`, write
+ the room grants, mark the invite accepted, write the receipt.
+ - Account exists: the invite never lowers an account. A `member`, `patron` or `user` keeps their role; the grants
+ are added and the invite is spent. If the existing account is `Left` or `Erased`, refuse.
+4. Bifrost issues its own cookie as for any account. From there the guest reaches chat through the one OIDC client.
+
+Gap, drift found while reading: Bifrost's `AllowedProviders` is `discord, patreon, twitch`
+(`HeimdallAuthService.AllowedProviders`), but Heimdall's `bifrost` app profile allows `discord, patreon, google`
+(`app-profiles.ts`, `bifrostProfile`). Twitch cannot start in Heimdall and Google is refused by Bifrost. A guest with
+neither Discord, Patreon nor a Google account cannot sign in, and GitHub is not a Heimdall sign-in provider for
+this app. Which provider sletz uses is unknown to me; align the two lists before the first invite.
+
+## Guest rights
+
+A guest may: sign in, hold a Bifrost cookie, hold a chat session through OIDC, see and edit their own profile
+(`MemberProfile`), be in the rooms granted to them, and leave or erase (below). A guest may not:
+
+- vote on motions: `EffectiveVotingWeight` is `0` for any role but `member` (today it is `Max(1, ...)` for
+ `Active`, which would give every guest weight 1; that line must read the role);
+- cast concept-mill appraisals: `VoterIdentity.For` returns no voter for a guest (not even `VoterClass.Account`);
+- appear on any estate roll or in any tally. The charter proposal snapshots each estate's roll at notice (A6);
+ `guest` is on no roll, so a guest cannot be counted, cannot make a quorum, and reclassifying a guest after a
+ snapshot changes nothing for that snapshot;
+- use bridge transport, claim or log work, earn points or ledger credit, or hold tier snapshots (those make them
+ a patron or member by changing role, not by decorating a guest);
+- hold a `MemberRole` or any of the `CanManage*` flags;
+- invite anyone.
+
+This list is enforced by role in one place (`CurrentBifrostActor`), not by a check at each use.
+
+## Per-room grants
+
+Rooms are Matrix rooms in chat, not Bifrost objects. Bifrost owns the decision, so a grant is Bifrost state:
+
+`GuestRoomGrant { Id, UserAccountId, RoomRef, GrantedByUserAccountId, GrantedAtUtc, ExpiresAtUtc?, RevokedAtUtc? }`
+
+`RoomRef` is a room id or alias as chat names it. Aqua's `#aquasynth` is the first one if she says yes.
+
+The grant has to reach Matrix to mean anything. Today Bifrost's only Matrix writer is the Persona mouth, with
+per-Persona access tokens (`docs/bridge.md`), and it holds no admin or invite right.
+
+Gap (largest in this doc): something must perform "invite this Matrix user to that room" on a grant and "remove
+them" on revoke or expiry. Candidates: a Bifrost-owned bot user that is a member with invite power in the granted
+rooms (reuses the Matrix client API, no Synapse admin credential), or the Synapse admin API. I recommend the bot
+user: narrower credential, and the room's power levels remain the real gate. Until it exists, the grant is a
+record, a human invites the guest by hand, and the receipt says `delivered_by: manual`. Rooms for guests must be
+invite-only; a guest who can reach a public room needs no grant, and that is a chat configuration fact I have not
+verified.
+
+Second gap: a guest has a full Matrix account once MAS provisions the localpart, so Matrix itself will let them
+create rooms or DM unless Synapse's room-creation and invite settings (`room_list_publication_rules`,
+`allow_public_rooms_*`, a spam-checker or module) say otherwise. These are global in Synapse, not per user, so a
+guest limit may have to be power levels in every room they are in. I did not find a per-user switch.
+
+## Role change on the same account id, with a receipt
+
+`ChangeRole(accountId, to, actor, reason)` is the only writer of `Membership.Role`. It runs in one transaction:
+
+1. refuse `Erased` and `Left`; refuse a no-op;
+2. write `Role`, and for `member` also set `Status = Active`, `ApprovedAtUtc`, and the default `StandardMember`
+ capability role (the same effects as `OnPostApproveAsync`); for `patron`, nothing beyond the role (tier
+ snapshots come from support events as before);
+3. write `AccountRoleReceipt { Id, UserAccountId, From, To, ChangedAtUtc, ChangedByUserAccountId, Reason }`;
+4. leave `UserAccount.Id`, `NormalizedBifrostIdentity`, `HeimdallAccountId`, grants and profile untouched.
+
+Who may change: a platform admin (`CanManageMembership`). Promoting to `member` is the existing approve path;
+this design routes it through `ChangeRole` so one writer exists. Promoting to `member` also applies whatever the
+charter says about joining an estate (not decided; see Questions). Demotion (member to guest) is allowed and
+receipted; it also removes the `MemberRole`s and capability flags in the same transaction.
+
+The receipt is a Postgres row today, like `AuditEvent`. Bifrost's rule is that durable state has a CultCache `.cc`
+witness (`docs/jurisdiction-map.md`, Design Rules); the witness is owed (Gaps).
+
+Seamless means: the guest's session cookie, OIDC subject, Matrix id, display name and room memberships do not
+change on promotion. A cookie issued before promotion carries identity claims only, never the role, so it needs
+no refresh; the actor is read from the database each request (`CurrentBifrostActorAccessor`).
+
+## Leave and erase
+
+Neither exists in Bifrost today. Two different acts:
+
+**Leave** (guest or any role, self-service): sets `Status = Left`, revokes every grant, and expels the account
+from granted rooms through the same effector as grants. The OIDC reconciler already notices a non-`Active`
+subject within 30 seconds and logs MAS out, and the token and userinfo endpoints refuse. The account row and
+`Id` stay, so a later invite can restore it (a `Left` account re-accepting is a role change from itself, receipted).
+Nothing else about the person is deleted by leaving.
+
+**Erase** (self-service, or admin on request): irreversible. In one transaction: blank `DisplayName`, `AvatarUrl`,
+`MemberProfile`, `GitHubLogin`/`GitHubUserId`, clear `HeimdallAccountId` (the authenticator link), set
+`Status = Erased`, keep `Id` and `NormalizedBifrostIdentity` as a tombstone so the localpart is never reissued to
+someone else (OIDC says it is minted once), keep receipts but with names stripped. The reason to keep a tombstone
+row is that receipts and audit events point at the id.
+
+An erasing guest has no votes, ledger entries or points, because guests cannot create them. A member or patron
+who erases does, and those are accounting records: their erase is a separate question (Questions). Guest erase
+is the case this design covers.
+
+Outside Bifrost, erase is incomplete without two effectors:
+
+- Matrix: Synapse's deactivate-with-erase admin call and MAS deactivation for the user. Federation is disabled in
+ the chat map (`gamecult-ops/docs/gamecult-chat-map.md`: federation paths 404), which helps: messages are
+ not copied to other servers. Other members' clients, and any export or training copy, are not reached by an
+ erase. Whether chat content is training data is `heimdall-body`'s consent rulings (Heimdall PR #6), which already
+ define exit by redaction; a guest's messages follow whatever that doc says, not this one.
+- Heimdall: I found no account-delete or unlink path in Heimdall `src`. Clearing `HeimdallAccountId` on the Bifrost
+ side leaves the Heimdall account and its provider link alive. Heimdall needs an erase call keyed by the account id
+ before "erased" is true end to end.
+
+## What Eureka reads
+
+Personas read the role, not the account. Do not put it in OIDC: the issuer promises "no membership detail leaves
+Bifrost" (`docs/oidc-issuer.md`) and chat does not need it. Publish a typed CultMesh document under the
+`gamecult.bifrost.governance` family (the nearest existing schema namespace, `verse-service-contract.md` l.60), id
+`gamecult.bifrost.account.v1`, key = account id:
+
+| field | type | note |
+|---|---|---|
+| `account_id` | uuid | OIDC `sub`; never changes. |
+| `identity` | string | `NormalizedBifrostIdentity`; the Matrix localpart, so Matrix user id is `@:` (server name per MAS config, not verified here). |
+| `display_name` | string | blank once erased. |
+| `role` | `guest` \| `user` \| `patron` \| `member` | the field Personas read. |
+| `status` | `authenticated` \| `pending_approval` \| `active` \| `suspended` \| `left` \| `erased` | |
+| `role_since` | UTC ms | from the latest `AccountRoleReceipt`. |
+| `rooms` | list of room refs | only for `guest`: the live grants. Others get rooms by estate. |
+
+A Persona deciding how to treat someone looks up `identity` from the Matrix sender and reads `role`. A sender with
+no document is not a Bifrost account (a bridge ghost or an unlinked user) and is `unknown`, never defaulted to
+`guest` or `user`. Bifrost does not decide Persona behavior; it states the role.
+
+No such publisher or witness exists; Bifrost has no `.cc` export for accounts today.
+
+## Cut sequence (not specs)
+
+1. `Membership.Role`, backfill, `IsActiveMember` and the OIDC tests moved to the new reading, vote and concept-vote
+ exclusions, with tests that fail if a guest gets weight or a voter key.
+2. `GuestInvitation`, the accept path, the Heimdall provider list aligned.
+3. `AccountRoleReceipt` and `ChangeRole`, with `OnPostApproveAsync` routed through it.
+4. `GuestRoomGrant` and the Matrix effector.
+5. Leave, then erase (Bifrost side), then the Matrix and Heimdall effectors.
+6. The account document and its `.cc` witness.
+
+## Gaps, plainly
+
+- The Matrix effector for grants, leave and erase does not exist, and no credential for it is provisioned.
+- Synapse cannot cap what a guest does per user; room-creation and invite limits are global (unverified: I did not
+ read the live Synapse config).
+- Heimdall has no erase path. Bifrost and Heimdall disagree on provider lists today.
+- No `.cc` witness or CultMesh publisher for accounts or receipts. Bifrost's account state is Postgres only.
+- Matrix id shape (`@:`) depends on MAS importing `preferred_username` as localpart; the OIDC doc
+ says so, I did not check the deployed MAS.
+- Tie to the answer-collector consent doc (Heimdall PR #6): a guest posting in a room may be posting to a sample
+ collector. That doc keys on `member_id`, and says a sender with no linked member is dropped. Whether a guest has a
+ `member_id`, and so whether their answers are collected at all, is not decided here. Default: not collected.
+- Erase for member and patron roles (ledger and vote records) is unmapped.
+
+## Questions for the deciders
+
+1. **Is `Role` the right name** given `MemberRole`, and is one stored role (this doc) preferred over a boolean
+ `IsGuest`? Recommendation: stored role. Confidence: medium; the boolean is cheaper if user and patron are not
+ yet needed.
+2. **Bearer link or provider-bound invite?** Recommendation: bearer, single use, seven days, optional
+ `ExpectedProvider`. Confidence: medium; this is a trust call about how sletz will be sent the link.
+3. **Which Heimdall provider will sletz sign in with?** Needed before the lists are aligned.
+4. **Do guests get a `member_id` for the answer collector?** Default no.
+5. **Does promotion to member join the Members estate immediately**, or does the charter's notice and snapshot
+ rule apply to the roll? The charter text is a proposal, not in force.
From 2aabaabc53c7d9f853635f0f3470b2ce1e6be1a3 Mon Sep 17 00:00:00 2001
From: Heimdall
Date: Tue, 10 Nov 2026 06:13:02 +0000
Subject: [PATCH 002/102] docs(guest-accounts): fold in Eureka's review:
matrix_id lookup, unknown/status/freshness contract, closed-room grants with
steward grantor, guest DM conflict
Co-Authored-By: Claude Sonnet 5.5
---
docs/guest-accounts.md | 52 ++++++++++++++++++++++++++++++++++--------
1 file changed, 43 insertions(+), 9 deletions(-)
diff --git a/docs/guest-accounts.md b/docs/guest-accounts.md
index 3044056..5471e47 100644
--- a/docs/guest-accounts.md
+++ b/docs/guest-accounts.md
@@ -4,7 +4,8 @@ Status: **design, nothing implemented.** Written for `eureka-body:follow_up:bifr
`eureka-body:ruling:guest-arrival-bifrost` (Meta: guests need no Matrix account of their own; they get a GameCult
account through Bifrost with a guest role, and move to member, user or patron on the same account seamlessly),
building on `eureka-body:ruling:bifrost-owns-accounts-invite-only`. I could not `view` either ruling from the
-Heimdall mind, so their text here is the follow-up brief's wording; check it against the rulings. First guest:
+Heimdall mind, so their text here is the follow-up brief's wording. Eureka's review (PR comment, 2026-11-07) checked
+it against the rulings and found guest-arrival matches; the conflicts it found are folded in below. First guest:
sletz (Faust developer). Aqua decides on `#aquasynth` access, so the first grant list is hers to name.
Read at Bifrost `bf8ff62` (`src/Bifrost.Web/Domain/BifrostModels.cs`, `Features/Membership`, `Features/Heimdall`,
@@ -125,6 +126,12 @@ Rooms are Matrix rooms in chat, not Bifrost objects. Bifrost owns the decision,
`RoomRef` is a room id or alias as chat names it. Aqua's `#aquasynth` is the first one if she says yes.
+**Scope (per `guest-room-moderation-ruled`, `guest-room-authority-ruled`).** Project rooms are public-join and
+hand-moderated by the steward, with Eureka as backstop, so a guest who can reach a public room needs no grant. A
+grant exists only for a room its steward chooses to keep closed, and its grantor is that room's steward, not a
+platform admin (the invite itself is still issued by a platform admin; `GrantedByUserAccountId` records the
+steward). Room entry for public rooms is not Bifrost's decision.
+
The grant has to reach Matrix to mean anything. Today Bifrost's only Matrix writer is the Persona mouth, with
per-Persona access tokens (`docs/bridge.md`), and it holds no admin or invite right.
@@ -132,9 +139,19 @@ Gap (largest in this doc): something must perform "invite this Matrix user to th
them" on revoke or expiry. Candidates: a Bifrost-owned bot user that is a member with invite power in the granted
rooms (reuses the Matrix client API, no Synapse admin credential), or the Synapse admin API. I recommend the bot
user: narrower credential, and the room's power levels remain the real gate. Until it exists, the grant is a
-record, a human invites the guest by hand, and the receipt says `delivered_by: manual`. Rooms for guests must be
-invite-only; a guest who can reach a public room needs no grant, and that is a chat configuration fact I have not
-verified.
+record, a human invites the guest by hand, and the receipt says `delivered_by: manual`. Whether the closed rooms a
+steward keeps are invite-only in Synapse is a chat configuration fact I have not verified.
+
+What Matrix takes from the Bifrost account is exactly this effector: invite or remove on a grant, and the power
+level the effector sets on invite. Role never enters OIDC, so Matrix reads no role from the issuer
+(`bifrost-owns-accounts-invite-only`: grants flow from the account through the effector, not through claims).
+
+**Guest DMs (`guest-dms-ruled`) conflict, open.** The mouth declines invites from other servers and accepts
+same-server DM invites (`tools/persona-matrix-mouth.mjs` `answerInvites`). The ruling's decline-for-now assumed
+federated guests; a Bifrost guest is a local `@sletz:`, so the mouth would accept their DM. Either the mouth
+declines DMs from role `guest` (using the account document below), or the ruling is revisited. That is Eureka's and
+the ruling's owner's call, not this doc's; until it is made, the mouth's behaviour is the ruling's opposite for local
+guests.
Second gap: a guest has a full Matrix account once MAS provisions the localpart, so Matrix itself will let them
create rooms or DM unless Synapse's room-creation and invite settings (`room_list_publication_rules`,
@@ -205,16 +222,31 @@ Bifrost" (`docs/oidc-issuer.md`) and chat does not need it. Publish a typed Cult
| field | type | note |
|---|---|---|
| `account_id` | uuid | OIDC `sub`; never changes. |
-| `identity` | string | `NormalizedBifrostIdentity`; the Matrix localpart, so Matrix user id is `@:` (server name per MAS config, not verified here). |
+| `identity` | string | `NormalizedBifrostIdentity`; the Matrix localpart. |
+| `matrix_id` | string | the full mxid `@:`, server name taken from MAS config at publish time so no reader assumes it. The lookup key for a Persona, which holds the Matrix sender id and not the account uuid. |
| `display_name` | string | blank once erased. |
| `role` | `guest` \| `user` \| `patron` \| `member` | the field Personas read. |
| `status` | `authenticated` \| `pending_approval` \| `active` \| `suspended` \| `left` \| `erased` | |
| `role_since` | UTC ms | from the latest `AccountRoleReceipt`. |
| `rooms` | list of room refs | only for `guest`: the live grants. Others get rooms by estate. |
-A Persona deciding how to treat someone looks up `identity` from the Matrix sender and reads `role`. A sender with
-no document is not a Bifrost account (a bridge ghost or an unlinked user) and is `unknown`, never defaulted to
-`guest` or `user`. Bifrost does not decide Persona behavior; it states the role.
+Contract the document carries, not left to each reader:
+
+- **Lookup.** The document is queryable by `matrix_id`.
+- **Absence is `unknown`.** A sender with no document is not a Bifrost account (a bridge ghost or an unlinked user).
+ Readers must treat it as `unknown`, never default it to `guest`, `user` or `member`. This reverses today's Persona
+ reading (everyone unlisted is `member`), which narrows to the Bifrost `member` role.
+- **Status gates the role.** Only `status = active` yields a usable role; `suspended`, `left`, `erased`,
+ `authenticated` and `pending_approval` yield none.
+- **Not Bifrost accounts.** Operator, the Persona's own id and Minds are typed by the reader's config, which takes
+ precedence over the document. Minds are not Bifrost accounts and must not fall to `unknown`.
+- **Freshness.** A reader loads the document when it builds each turn, never once at start, so a role change
+ applies from the next message; past messages keep the role they were read under. `role_since` lets a trace say
+ which role was read. Publisher bound: a role or status change is published within 30 seconds, the same bound the
+ OIDC reconciler already keeps for a non-`active` subject. No publisher exists, so this is the requirement it is
+ built to, not a measurement.
+
+Bifrost does not decide Persona behavior; it states the role.
No such publisher or witness exists; Bifrost has no `.cc` export for accounts today.
@@ -250,6 +282,8 @@ No such publisher or witness exists; Bifrost has no `.cc` export for accounts to
2. **Bearer link or provider-bound invite?** Recommendation: bearer, single use, seven days, optional
`ExpectedProvider`. Confidence: medium; this is a trust call about how sletz will be sent the link.
3. **Which Heimdall provider will sletz sign in with?** Needed before the lists are aligned.
-4. **Do guests get a `member_id` for the answer collector?** Default no.
+4. **Do guests get a `member_id` for the answer collector?** Default no. Eureka agrees from the chat side: the
+ Persona turn is not the collector, and presence in a room is the room's decision (Heimdall PR #6 owns the
+ collector).
5. **Does promotion to member join the Members estate immediately**, or does the charter's notice and snapshot
rule apply to the roll? The charter text is a proposal, not in force.
From 70a20384351f8418aca856f768c236b03a29c63e Mon Sep 17 00:00:00 2001
From: Heimdall
Date: Wed, 11 Nov 2026 14:12:47 +0000
Subject: [PATCH 003/102] Suspension gaps: bootstrap, demotion, members-page
notices, admin-only reason (#60)
---
docs/oidc-issuer.md | 4 +
docs/registration-gates.md | 2 +-
...111110000_AddMembershipSuspensionReason.cs | 25 +++
.../BifrostDbContextModelSnapshot.cs | 3 +
src/Bifrost.Web/Domain/BifrostModels.cs | 3 +
.../Membership/MembershipSuspensionService.cs | 8 +-
.../MembershipSynchronizationService.cs | 31 +--
.../Features/Oidc/OidcClientSeeder.cs | 9 +-
.../Shared/ApplicationBootstrapper.cs | 51 ++++-
.../Shared/DashboardSnapshotService.cs | 2 +-
.../Pages/App/Members/Index.cshtml | 9 +
.../Pages/App/Members/Index.cshtml.cs | 47 +++-
.../Bifrost.Web.Tests/BootstrapAdminTests.cs | 176 +++++++++++++++
.../MembersPageNoticeTests.cs | 202 ++++++++++++++++++
.../MembershipSuspensionTests.cs | 25 ++-
.../MigrationDiscoveryTests.cs | 3 +-
.../OidcLogoutReachWarningTests.cs | 50 +++++
.../Support/OidcLogoutKit.cs | 20 +-
.../SuspensionReasonExposureTests.cs | 134 ++++++++++++
19 files changed, 763 insertions(+), 41 deletions(-)
create mode 100644 src/Bifrost.Web/Data/Migrations/20261111110000_AddMembershipSuspensionReason.cs
create mode 100644 tests/Bifrost.Web.Tests/BootstrapAdminTests.cs
create mode 100644 tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs
create mode 100644 tests/Bifrost.Web.Tests/OidcLogoutReachWarningTests.cs
create mode 100644 tests/Bifrost.Web.Tests/SuspensionReasonExposureTests.cs
diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md
index d6cb22d..b3c4121 100644
--- a/docs/oidc-issuer.md
+++ b/docs/oidc-issuer.md
@@ -103,6 +103,10 @@ to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configu
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.
+Without `BackchannelLogoutUri` none of this runs: suspension stops new sign-ins and token refresh (the authorize, token and userinfo
+endpoints refuse a suspended account) but does not end a chat session that is already live, and the issuer logs one warning at
+startup saying so. The other lever is MAS admin deactivation of the user (ops, not built here).
+
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
diff --git a/docs/registration-gates.md b/docs/registration-gates.md
index fd039be..5564b17 100644
--- a/docs/registration-gates.md
+++ b/docs/registration-gates.md
@@ -11,7 +11,7 @@ This page scopes the three that did not exist, says where each lives, and record
| Rate limits and brake | `OidcGuardMiddleware` (`docs/oidc-issuer.md`) | Merged. Covers `/connect/*` only, not the register endpoint. |
| Reserved names | `BifrostIdentityService.IsReserved`, 28 names | Merged, at creation and at authorize. |
| Chat tier, no vote | `Authenticated` / `PendingApproval` accounts chat and open no member or governance surface | Merged. |
-| Suspension | `MembershipSuspensionService` | Merged. |
+| Suspension | `MembershipSuspensionService` | Merged. The reason is `Membership.SuspensionReason`, written with the status change and its receipt, cleared on unsuspend, projected only to admins on the members page; the receipt (`AuditEvent.Detail`) holds no reason text. Receipts written before the column existed still hold their reasons as history; the member dashboard keeps filtering suspension receipts out. An unsuspend still requires a reason, but nothing stores it. |
| CAPTCHA | `Features/Registration/RegistrationChallengeService` | Built here. |
| Consent receipt | `ConsentReceipt`, `TrainingNotice` | Built here. |
| Email verification | nowhere | Scoped below, not built. |
diff --git a/src/Bifrost.Web/Data/Migrations/20261111110000_AddMembershipSuspensionReason.cs b/src/Bifrost.Web/Data/Migrations/20261111110000_AddMembershipSuspensionReason.cs
new file mode 100644
index 0000000..4dc7719
--- /dev/null
+++ b/src/Bifrost.Web/Data/Migrations/20261111110000_AddMembershipSuspensionReason.cs
@@ -0,0 +1,25 @@
+using Bifrost.Web.Data;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Bifrost.Web.Data.Migrations
+{
+ // Hand-written like ConsentReceiptsOneLivePerVersion: no dotnet-ef on the authoring host; the snapshot was edited to match.
+ // Reasons already written to AuditEvent.Detail stay there as history; new ones live only in this admin-read column.
+ [DbContext(typeof(BifrostDbContext))]
+ [Migration("20261111110000_AddMembershipSuspensionReason")]
+ public partial class AddMembershipSuspensionReason : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(name: "SuspensionReason", table: "Memberships", type: "text", nullable: true);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(name: "SuspensionReason", table: "Memberships");
+ }
+ }
+}
diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs
index 851318c..042e2f0 100644
--- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs
+++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs
@@ -821,6 +821,9 @@ namespace Bifrost.Web.Data.Migrations
.IsRequired()
.HasColumnType("text");
+ b.Property("SuspensionReason")
+ .HasColumnType("text");
+
b.Property("UserAccountId")
.HasColumnType("uuid");
diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs
index 2e5d680..00e3f9c 100644
--- a/src/Bifrost.Web/Domain/BifrostModels.cs
+++ b/src/Bifrost.Web/Domain/BifrostModels.cs
@@ -334,6 +334,9 @@ public sealed class Membership
public MembershipStatus Status { get; set; } = MembershipStatus.Authenticated;
+ /// Why an admin suspended the account; set and cleared only by MembershipSuspensionService and projected only to admins.
+ public string? SuspensionReason { get; set; }
+
public bool IsPlatformAdmin { get; set; }
public bool CanManageProjects { get; set; }
diff --git a/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs b/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs
index cb5626c..870ca4f 100644
--- a/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs
+++ b/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs
@@ -17,7 +17,7 @@ public enum SuspensionOutcome
///
/// The one place a membership becomes Suspended or stops being. Only a platform admin may call it, every use leaves an audit
-/// receipt naming the admin, the account and the reason, and no sign-in path writes or lifts a suspension (Heimdall and GitHub
+/// receipt naming the admin and the account (the reason is kept on the membership row, readable by admins only), and no sign-in path writes or lifts a suspension (Heimdall and GitHub
/// sign-in leave a Suspended membership as it is). Unsuspending restores Active for an account that was once approved,
/// Authenticated for one that never was; a pending request is not remembered.
///
@@ -65,14 +65,16 @@ public sealed class MembershipSuspensionService(BifrostDbContext dbContext, Audi
membership.Status = suspend
? MembershipStatus.Suspended
: membership.ApprovedAtUtc is null ? MembershipStatus.Authenticated : MembershipStatus.Active;
+ // The reason is a column on the membership, for admins; the receipt names who and what changed, never why.
var clipped = reason.Trim().Length > MaxReasonLength ? reason.Trim()[..MaxReasonLength] : reason.Trim();
- // One save: the status change and its receipt stand or fall together (RecordAsync saves the shared context).
+ membership.SuspensionReason = suspend ? clipped : null;
+ // One save: the status change, the reason and the receipt stand or fall together (RecordAsync saves the shared context).
await auditTrailService.RecordAsync(
actor.UserAccount.Id,
nameof(Membership),
membership.Id,
suspend ? "membership.suspended" : "membership.unsuspended",
- $"{(suspend ? "Suspended" : "Unsuspended")} {user.DisplayName} ({before} -> {membership.Status}): {clipped}",
+ $"{(suspend ? "Suspended" : "Unsuspended")} {user.DisplayName} ({before} -> {membership.Status}).",
cancellationToken);
return SuspensionOutcome.Done;
}
diff --git a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs
index 8ee13a0..6d41d73 100644
--- a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs
+++ b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs
@@ -12,7 +12,9 @@ public sealed class MembershipSynchronizationService(
BifrostDbContext dbContext,
BifrostIdentityService bifrostIdentityService,
IOptions bootstrapOptions,
- TimeProvider timeProvider)
+ TimeProvider timeProvider,
+ AuditTrailService auditTrailService,
+ ILogger logger)
{
public async Task SynchronizeAsync(ClaimsPrincipal principal, CancellationToken cancellationToken)
{
@@ -109,9 +111,14 @@ public sealed class MembershipSynchronizationService(
invitation.AcceptedAtUtc = now;
}
+ MembershipStatus? promotedFrom = null;
if (IsBootstrapAdmin(gitHubLogin, bootstrapOptions.Value))
{
- PromoteToBootstrapAdmin(userAccount.Membership!, now);
+ promotedFrom = userAccount.Membership!.Status;
+ if (!ApplicationBootstrapper.TryPromoteBootstrapAdmin(userAccount, now, logger))
+ {
+ promotedFrom = null;
+ }
}
else if (userAccount.Membership!.Status == MembershipStatus.Active)
{
@@ -125,22 +132,16 @@ public sealed class MembershipSynchronizationService(
userAccount.MemberProfile!.UpdatedAtUtc = now;
+ if (promotedFrom is { } before && before != userAccount.Membership!.Status)
+ {
+ // One save: the promotion and its receipt stand or fall together.
+ await ApplicationBootstrapper.RecordPromotionAsync(auditTrailService, userAccount, before, cancellationToken);
+ return;
+ }
+
await dbContext.SaveChangesAsync(cancellationToken);
}
- private static void PromoteToBootstrapAdmin(Bifrost.Web.Domain.Membership membership, DateTimeOffset now)
- {
- membership.Status = MembershipStatus.Active;
- membership.IsPlatformAdmin = true;
- membership.CanManageProjects = true;
- membership.CanManageLedger = true;
- membership.CanModerateMotions = true;
- membership.ApprovedAtUtc ??= now;
-
- ApplicationBootstrapper.EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
- ApplicationBootstrapper.EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
- }
-
private static bool TryReadGitHubIdentity(
ClaimsPrincipal principal,
out long gitHubUserId,
diff --git a/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs
index edf1dd3..c393afd 100644
--- a/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs
+++ b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs
@@ -14,10 +14,17 @@ public sealed class OidcClientSeeder(
IServiceScopeFactory scopeFactory,
OidcIssuerOptions options,
OidcKeyMaterial material,
- IOptions serverOptions) : IHostedService
+ IOptions serverOptions,
+ ILogger logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
+ if (string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri))
+ {
+ logger.LogWarning(
+ "Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri is not set: suspending an account stops new sign-ins and token refresh but does not end its existing chat sessions.");
+ }
+
// Reading the server options now runs their configuration, so a bad issuer stops startup rather than the first request.
_ = serverOptions.Value;
diff --git a/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs b/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs
index 1a3cd0f..04879e2 100644
--- a/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs
+++ b/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs
@@ -10,6 +10,7 @@ public sealed class ApplicationBootstrapper(
BifrostDbContext dbContext,
IOptions bootstrapOptions,
TimeProvider timeProvider,
+ AuditTrailService auditTrailService,
ILogger logger)
{
public async Task RunAsync(CancellationToken cancellationToken)
@@ -40,16 +41,11 @@ public sealed class ApplicationBootstrapper(
foreach (var adminUser in adminUsers.Where(x => x.Membership is not null))
{
- var membership = adminUser.Membership!;
- membership.Status = MembershipStatus.Active;
- membership.IsPlatformAdmin = true;
- membership.CanManageProjects = true;
- membership.CanManageLedger = true;
- membership.CanModerateMotions = true;
- membership.ApprovedAtUtc ??= now;
-
- EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
- EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
+ var before = adminUser.Membership!.Status;
+ if (TryPromoteBootstrapAdmin(adminUser, now, logger) && before != adminUser.Membership.Status)
+ {
+ await RecordPromotionAsync(auditTrailService, adminUser, before, cancellationToken);
+ }
}
if (dbContext.ChangeTracker.HasChanges())
@@ -58,6 +54,41 @@ public sealed class ApplicationBootstrapper(
}
}
+ ///
+ /// The one bootstrap-admin promotion, used at startup and at sign-in. A Suspended account is left exactly as it is (an admin's
+ /// decision; only the suspension service lifts it) and the refusal is logged by account id. Returns whether it promoted.
+ ///
+ public static bool TryPromoteBootstrapAdmin(UserAccount account, DateTimeOffset now, ILogger logger)
+ {
+ var membership = account.Membership!;
+ if (membership.Status == MembershipStatus.Suspended)
+ {
+ logger.LogWarning("Bootstrap admin promotion skipped for suspended account {UserAccountId}.", account.Id);
+ return false;
+ }
+
+ membership.Status = MembershipStatus.Active;
+ membership.IsPlatformAdmin = true;
+ membership.CanManageProjects = true;
+ membership.CanManageLedger = true;
+ membership.CanModerateMotions = true;
+ membership.ApprovedAtUtc ??= now;
+
+ EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
+ EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
+ return true;
+ }
+
+ /// The receipt for a bootstrap promotion that changed membership status; saves together with the promotion.
+ public static Task RecordPromotionAsync(AuditTrailService auditTrailService, UserAccount account, MembershipStatus before, CancellationToken cancellationToken) =>
+ auditTrailService.RecordAsync(
+ null,
+ nameof(Bifrost.Web.Domain.Membership),
+ account.Membership!.Id,
+ "membership.bootstrap-promoted",
+ $"Bootstrap admin promotion of {account.DisplayName} ({before} -> {account.Membership.Status}).",
+ cancellationToken);
+
public static void EnsureRole(
Bifrost.Web.Domain.Membership membership,
MemberRole role,
diff --git a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs
index cd0d8f2..0837f7e 100644
--- a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs
+++ b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs
@@ -92,7 +92,7 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
var recentActivity = await dbContext.AuditEvents
.AsNoTracking()
.Include(x => x.ActorUserAccount)
- .Where(x => !x.Action.StartsWith("membership.suspended") && !x.Action.StartsWith("membership.unsuspended")) // a reason is for admins, not the member dashboard
+ .Where(x => !x.Action.StartsWith("membership.suspended") && !x.Action.StartsWith("membership.unsuspended") && x.Action != "membership.bootstrap-promoted") // suspension reasons and the bootstrap-admin promotion are for admins, not the member dashboard
.OrderByDescending(x => x.OccurredAtUtc)
.Take(8)
.Select(x => new DashboardActivity(
diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml b/src/Bifrost.Web/Pages/App/Members/Index.cshtml
index b4d67e5..94e791d 100644
--- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml
+++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml
@@ -14,6 +14,11 @@
because trust and contributor accountability are product concerns, not just auth concerns.
+ @if (!string.IsNullOrEmpty(Model.Notice))
+ {
+ @Model.Notice
+ }
+
@if (Model.Actor.CanManageMembership)
{