From 8abd9129176dda3c2dc29701a5737bf39caa6c22 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 6 Nov 2026 21:26:13 +0000 Subject: [PATCH 01/48] 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 02/48] 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 e2a4d677d2804a0aaf708541f3127b7940329de5 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 16:47:54 +0000 Subject: [PATCH 03/48] Docs: no refresh token, MAS skips userinfo; withdrawal ends new tokens, not open sessions Co-Authored-By: Claude Sonnet 5.5 --- docs/oidc-issuer.md | 4 ++-- docs/training-notice.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md index e6a2eeb..df357ee 100644 --- a/docs/oidc-issuer.md +++ b/docs/oidc-issuer.md @@ -163,8 +163,8 @@ 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 +Without `BackchannelLogoutUri` none of this runs: suspension stops new chat tokens (the authorize, token and userinfo +endpoints refuse a suspended account; there is no refresh token, and MAS does not call userinfo) but does not end a chat session that is already live, which runs to its natural expiry, 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. diff --git a/docs/training-notice.md b/docs/training-notice.md index a4d1287..39f79ca 100644 --- a/docs/training-notice.md +++ b/docs/training-notice.md @@ -41,7 +41,7 @@ asked again on their next chat sign-in. (not withdrawn) receipt for the version in force exists. After a bump, everyone is out until they agree again. The notice says so ("if the notice changes, its version changes and we ask again; until you agree to the new version, your messages are not used"), and the consent page shows the person that their agreement is to an older version and - offers the new one. Since the chat gate, a bump also ends everyone's chat token until they agree (their next chat sign-in lands on that page). Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no. + offers the new one. Since the chat gate, a bump also stops new chat tokens for everyone until they agree (their next chat sign-in lands on that page; a session already open runs to its natural expiry unless `BackchannelLogoutUri` is set, when the 30 s reconciler ends it). Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no. 3. **Withdrawal had no CSRF check.** True. The session cookie's SameSite default is Lax, which already keeps a cross-site POST from carrying it, but nothing in the handler relied on that. Both state changes (agree, withdraw) are now form POSTs refused with 403 unless they came from the site itself (`RequestOriginGuard`, the strict form), before From e8e8fcc1780d30ab22d208e407eeeb69549ed498 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 16:48:27 +0000 Subject: [PATCH 04/48] Docs: no refresh token, MAS skips userinfo; withdrawal stops new tokens, open sessions run to expiry (#66) --- docs/oidc-issuer.md | 4 ++-- docs/training-notice.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md index e6a2eeb..df357ee 100644 --- a/docs/oidc-issuer.md +++ b/docs/oidc-issuer.md @@ -163,8 +163,8 @@ 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 +Without `BackchannelLogoutUri` none of this runs: suspension stops new chat tokens (the authorize, token and userinfo +endpoints refuse a suspended account; there is no refresh token, and MAS does not call userinfo) but does not end a chat session that is already live, which runs to its natural expiry, 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. diff --git a/docs/training-notice.md b/docs/training-notice.md index a4d1287..39f79ca 100644 --- a/docs/training-notice.md +++ b/docs/training-notice.md @@ -41,7 +41,7 @@ asked again on their next chat sign-in. (not withdrawn) receipt for the version in force exists. After a bump, everyone is out until they agree again. The notice says so ("if the notice changes, its version changes and we ask again; until you agree to the new version, your messages are not used"), and the consent page shows the person that their agreement is to an older version and - offers the new one. Since the chat gate, a bump also ends everyone's chat token until they agree (their next chat sign-in lands on that page). Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no. + offers the new one. Since the chat gate, a bump also stops new chat tokens for everyone until they agree (their next chat sign-in lands on that page; a session already open runs to its natural expiry unless `BackchannelLogoutUri` is set, when the 30 s reconciler ends it). Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no. 3. **Withdrawal had no CSRF check.** True. The session cookie's SameSite default is Lax, which already keeps a cross-site POST from carrying it, but nothing in the handler relied on that. Both state changes (agree, withdraw) are now form POSTs refused with 403 unless they came from the site itself (`RequestOriginGuard`, the strict form), before From 6acb4980b8e45270f7896f7bd8aea2a318653c01 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 17:06:31 +0000 Subject: [PATCH 05/48] docs: how a Ratatoskr voting module authenticates to Bifrost (design) (#37) --- docs/ratatoskr-voting-auth.md | 190 ++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 docs/ratatoskr-voting-auth.md diff --git a/docs/ratatoskr-voting-auth.md b/docs/ratatoskr-voting-auth.md new file mode 100644 index 0000000..b3f8a2e --- /dev/null +++ b/docs/ratatoskr-voting-auth.md @@ -0,0 +1,190 @@ +# How a Ratatoskr voting module authenticates to Bifrost (design, no code) + +Status: proposal, 2026-11-10, written by Heimdall (Self) against `origin/main` 276f611 (sections 1-13, citations as first read); section 14 and the corrections marked "e8e8fcc" in sections 1-13 are re-read against `origin/main` e8e8fcc. Decided 2026-11-11 by the steward of Bifrost for the read-only Desk feed (section 14); the cast scopes (`votes.cast`, `desk.answer`) stay gated by D1, D2 and D4. Docs only; nothing here is built or deployed. +Labels: **FACT** = read at the cited `file:line` at the base named for that section or citation, 276f611 unless it says e8e8fcc (paths under `src/Bifrost.Web/` unless written otherwise). **INFERENCE** = my reading, not stated by code. **EXTERNAL** = OpenIddict/ASP.NET behaviour I know from its documentation and did not run here; each one needs a probe before the cut (section 11). +Companion reads: `/opt/commons/handoff/bifrost-browser-voting-facts.md` (Eyes, at 416f11a; its Program.cs/OIDC lines still hold at 276f611, only `Features/Motions/MotionGovernanceService.cs` changed in between, in the close path), `heimdall-ratatoskr-sso-plan*.md`. + +## 1. The problem in four facts + +- FACT: a member's only Bifrost credential is the cookie `__Host-bifrost_session` (`Program.cs:189-200`; `Features/Shared/BifrostCookies.cs:10`). `__Host-` makes it host-only: chat.gamecult.org never holds it. +- FACT: the member API carries no CORS: the single CORS policy is the anonymous tally GET (`Features/ConceptVotes/ConceptTallyExport.cs:8-27`), so a JSON POST from the chat origin is blocked at preflight. +- FACT: Bifrost is an OIDC issuer with exactly one client, MAS, confidential, secret basic auth only, scopes `openid profile` (`Features/Oidc/OidcServiceCollectionExtensions.cs:39-46`; `Features/Oidc/OidcClientSeeder.cs:35-51` is the one MAS descriptor; startup deletes every other client, loop `:63-75`, all at e8e8fcc). No OpenIddict validation handler is registered (grep for `AddValidation` under `src` returns nothing), so no access token authenticates any `/api/*` route. +- FACT: there is no JSON read or cast API for Standard motions (Razor form POST with 302 and no receipt: `Features/Motions/MotionGovernanceService.cs:47-86`, `Pages/App/Motions/Index.cshtml.cs`); sealed-ballot motions have JSON endpoints but no list (`Program.cs:718-763`). +- INFERENCE: the access token chat holds today is Matrix's, minted by MAS after MAS authenticates the person at Bifrost (SSO plan section D). Bifrost's own access token goes to MAS, never to the browser. PR #24's "the OIDC access token Bifrost already issues to chat" is therefore not true as written: this design makes Bifrost issue a token to chat directly. +- FACT (deployment): Bifrost has no `Bifrost__Oidc__*` variable live, MAS is disabled, Synapse is its own password authority (`heimdall-ratatoskr-sso-plan-live-read-20261110.md` section 1). Everything below is for after OIDC is enabled; `Bifrost:Oidc` absent means the provider surface is not mapped (`docs/oidc-issuer.md:37`). + +## 2. Proposal in one paragraph + +Ratatoskr's voting module is a second OIDC client of Bifrost: **public, PKCE (S256), no secret**, redirect URI exactly one URL on https://chat.gamecult.org. It runs authorization-code + PKCE in a top-level redirect to Bifrost (so the person's existing `__Host-` session cookie is used first-party; chat never sees it), receives an **access token for audience `bifrost-votes` and scopes `votes.read` / `votes.cast` (10 minutes for cast scopes; `desk.read`: see section 14)**, keeps it in memory only, and sends it as `Authorization: Bearer` to a **new `/api/votes` surface that accepts bearer tokens and refuses cookies**. CORS on exactly two route groups (`/connect/token`, `/api/votes`) allows exactly `https://chat.gamecult.org`, no credentials. Everything else in Bifrost keeps its current cookie-only behaviour. The Persona/Mind signed path is untouched. + +## 3. Client registration + +- A second client entry beside MAS, from configuration (`Bifrost:Oidc:Clients:Ratatoskr:{ClientId,RedirectUri}`), seeded by the same startup seeder. FACT the seeder today builds one descriptor and deletes all others (`OidcClientSeeder.cs:33-75` at e8e8fcc); it needs to build two and delete the rest. +- `ClientType = Public`, no `ClientSecret`; permissions: authorization endpoint, token endpoint, authorization_code grant, response type `code`, scopes `profile`, `openid`, `votes.read`, `votes.cast`. PKCE is already required server-wide (`OidcServiceCollectionExtensions.cs:39`). +- `RedirectUri` is one absolute URL, compared exactly (OpenIddict does exact match; EXTERNAL). No wildcard, no localhost entry in production configuration; a dev client is a separate config file never deployed. +- FACT the token endpoint accepts only `client_secret_basic` (`OidcServiceCollectionExtensions.cs:45-46` clears and sets the list). EXTERNAL/needs probe: a public client authenticates by `client_id` alone and OpenIddict still accepts it with that list; if it does not, add `none` to the list for public clients only. +- Not in scope: dynamic registration, implicit/hybrid flows, a client secret in the browser. The MAS client and its flow do not change. + +## 4. Scopes, audience, what a token carries + +| Scope | Grants | Never grants | +|---|---|---| +| `openid`, `profile` | `sub` = `UserAccount.Id`, `preferred_username`, `name` (FACT `OidcEndpoints.cs:79-84`, `docs/oidc-issuer.md:55-61`) | email, Heimdall id, membership detail | +| `votes.read` | read motions the member may see, their own ballot status, results after close | other members' sealed ballots; weights of other members | +| `votes.cast` | cast/change a Standard vote; sealed commit and reveal for the token's own `sub` | anything in the next list | + +Audience: access tokens for these scopes carry audience `bifrost-votes` (INFERENCE: OpenIddict `SetResources`/audience per request; EXTERNAL detail). `/api/votes` requires the audience **and** a vote scope. This stops the MAS client's access token (same issuer, same signing key, scopes `openid profile`) from being accepted on `/api/votes`, and a Ratatoskr token from being accepted by anything else. + +**A token may never:** grant or change a role (`RoleAssignments`); set, read-modify or choose a weight (weight is `Membership.EffectiveVotingWeight` computed server-side at cast, `Domain/BifrostModels.cs:360-364`; the API request has no weight field and the receipt does not echo it); open, close or edit a motion; register or revoke Mind keys (`Program.cs:754-763`, moderator, cookie only); reach `/App/*`, `/eve/*`, `/connect/userinfo` beyond the three profile claims; mint another token (no refresh token, `docs/oidc-issuer.md:26`; no token-exchange); act for a different account (the voter is always `sub`, never a body field). These are enforced by structure, not by a deny list: the vote endpoints take the account from the principal and expose no parameter for the rest. + +## 5. The bearer surface + +- Register OpenIddict validation (local server) as a **named, non-default** authentication scheme `BifrostBearer`. FACT the default scheme is Cookie (`Program.cs:189-200`), so every existing endpoint stays cookie-only and a bearer token cannot reach `/App`, `/api/sealed-motions` or `/eve` (a test pins it). +- New group `app.MapGroup("/api/votes")` with a policy `ActiveMember + scope + audience` whose `AuthenticationSchemes` is `BifrostBearer` only. A request that also carries the Bifrost session cookie is judged on the bearer alone, so the surface has no ambient credential: this is what removes CSRF from it (section 9). +- **Actor resolution is a new job.** FACT `CurrentBifrostActorAccessor` builds the actor from cookie claims (`BifrostIdentity`, `HeimdallAccountId`, GitHub `NameIdentifier`) and returns Anonymous when none are present (`Features/Shared/CurrentBifrostActorAccessor.cs:33-46`). A bearer principal has only `sub`, so existing services would see an anonymous actor. FACT `OidcEndpoints.ActiveMemberIdAsync` already resolves by `sub` and re-reads membership (`OidcEndpoints.cs:170`, used by `Features/Desk/DeskEndpoints.cs:105`; e8e8fcc). Proposal: one accessor path "principal with `sub` and `BifrostBearer` → load `UserAccount` + `Membership` by id", reusing that reading, and the `/api/votes` handlers call the existing services (`MotionGovernanceService.CastVoteAsync`, `SealedBallotService.CommitAsync/RevealAsync`) with that actor. No second vote implementation. +- Endpoints (names are a proposal, shapes belong to the cut spec): `GET /api/votes/motions`, `GET /api/votes/motions/{id}`, `PUT /api/votes/motions/{id}/ballot` `{choice, comment?}` (Standard), `POST …/commit` and `…/reveal` for sealed, `GET …/result`. FACT today's Standard cast returns silently when the motion is not open and throws on an unknown id (`MotionGovernanceService.cs:57-61`); the API wraps it with typed errors (404, 409 not open) rather than copying that. +- Errors are JSON 401/403/404/409/429, never a 302 to a sign-in page (FACT the cookie challenge redirects, `Program.cs:197`; a `fetch` client must get a status it can act on, e.g. 401 with `WWW-Authenticate: Bearer error="invalid_token"`). +- Persona/Mind path: FACT `POST /api/sealed-motions/{id}/mind` is anonymous, Ed25519-signed, rate-limited at 30/min/source and 4096 bytes (`Program.cs:767-809`). Unchanged. Minds never use this bearer flow; `/api/votes` has no Mind route, and a Persona's account is not a Ratatoskr login. + +## 6. The flow (browser) + +1. Ratatoskr voting module starts login: generates `state`, `nonce`, PKCE verifier/challenge (S256), stores them in `sessionStorage` for the one tab, navigates to `GET /connect/authorize` with `client_id`, exact `redirect_uri`, `scope=openid profile votes.read votes.cast`, `response_type=code`. +2. FACT authorize without a session challenges to `/auth/sign-in` and resumes via a short `request_uri` (`OidcEndpoints.cs:44-51`); with a session it admits any account that is not Suspended and has an identity (chat tier; membership is neither required nor written), otherwise `access_denied` (`OidcEndpoints.cs:53-70`). So the sign-in leg does not prove Active: the `/api/votes` and `/api/desk` surfaces re-check Active on every request, which is what makes this safe. Heimdall sign-in and the Bifrost session happen on the Bifrost host exactly as for MAS. +3. Without a live notice receipt, Bifrost first redirects to its training-notice consent page and resumes the parked request (`OidcEndpoints.cs:99-102`, e8e8fcc; not a grant to the client). Then, for cast scopes only, the per-client grant screen (section 7, D1); `desk.read` alone has none. Then 302 to `https://chat.gamecult.org/?code=…&state=…`. +4. The module checks `state`, immediately removes `code` and `state` from the address bar (`history.replaceState`), POSTs `/connect/token` (form: `grant_type=authorization_code`, `code`, `code_verifier`, `client_id`, `redirect_uri`) cross-origin. FACT the token endpoint re-reads the account and refuses with `invalid_grant` when `ChatRefusalAsync` says so: account missing, Suspended, or no live notice receipt (`OidcEndpoints.cs:107-120`, `ChatRefusalAsync` `:151`). It does not test Active; the per-request Active check is the new surfaces' own (section 14 D3). +5. The access token lives in a JS variable. Not `localStorage`, not `sessionStorage`, not IndexedDB. A page reload logs the module out of Bifrost votes (not out of chat) and the next vote action restarts step 1; with an existing Bifrost session it is two redirects and no typing. + +INFERENCE on re-auth without refresh tokens: FACT none exist (`docs/oidc-issuer.md:26`). EXTERNAL: `prompt=none` in a hidden iframe is dead (third-party cookie rules), so renewal is a top-level redirect. That is the cost of a no-refresh-token design; see D3. + +## 7. Consent screen: needed, because one hop is cheap and a vote is not + +- FACT (e8e8fcc) there is no per-client grant consent: authorize redirects to `ConsentEndpoints` when the member has no live receipt (`OidcEndpoints.cs:99-102`, `Features/Registration/ConsentPage.cs`), but that is the training-notice receipt, a once-per-notice-version agreement independent of any client, not a grant to Ratatoskr. "Consent" below means the per-client grant unless it says notice. MAS needs no grant screen (it is the chat's own login service). +- Reasoning (INFERENCE): `votes.cast` lets whoever holds the token change the member's recorded vote. The member is already redirected to Bifrost, so a consent step costs one click and makes the grant visible and revocable in Bifrost, which is the only place it can be. Recommend **explicit consent for `votes.cast`, implicit for `votes.read`** (OpenIddict `ConsentType` per client or per authorization; EXTERNAL). +- Page content: client name ("Ratatoskr, chat.gamecult.org"), the account being used, the two powers in plain words ("see motions and your own ballots", "cast and change your votes"), Allow / Deny, a note that the grant lasts until revoked and tokens last 10 minutes (cast scopes). Served with `frame-ancestors 'none'` and `X-Frame-Options: DENY` (clickjacking). The consent wording for the chat side is `ratatoskr-consent-wording-draft-20261109.md` and is separate from this one. +- A stored authorization (OpenIddict permanent authorization) lets the next login skip the consent page; it is the object revoked in section 8. + +## 8. Token lifetime, revocation, receipts, rate limits + +**Lifetime.** FACT the code sets no access-token lifetime (only `RequestTokenLifetime` 10 min, `OidcServiceCollectionExtensions.cs:47`); INFERENCE/EXTERNAL OpenIddict's defaults apply (about 1 hour for access tokens, 5 min for codes). Set explicitly: **access token 10 minutes for cast scopes (`desk.read`: 60 minutes, section 14 D3)**, authorization code 60 seconds, id_token short. No refresh token. Because every `/api/votes` call re-reads the account and membership (section 5), the lifetime bounds token theft, not demotion. + +**Revocation.** +- Demotion is immediate: each request re-checks Active (`ActiveMemberIdAsync`, `OidcEndpoints.cs:170`, e8e8fcc). A token held by a suspended member returns 403. +- Authorization cleanup: FACT `OidcLogoutReconciler` finds subjects who fail the chat-tier test (Suspended, account gone, or no live notice receipt) and hold authorizations of the MAS client and tells MAS (`docs/oidc-issuer.md:146-164`); it is MAS-specific (it needs `Clients:Mas:BackchannelLogoutUri`, `OidcLogoutReconciler.cs:180` at e8e8fcc) and nothing in it serves another client today. To be built: a derived-from-membership sweep that revokes the Ratatoskr/desk client's authorizations for non-Active subjects (no outbound POST needed; the client is ours). Membership is never written by it. +- Member-initiated revoke: needs a place to list and revoke grants. Not built; D4. +- Operator kill: with the seeder extended to more than one client, removing a client from configuration deletes it and its tokens at next start (the delete-others loop, `OidcClientSeeder.cs:63-75` at e8e8fcc, today spares only the MAS client); a runtime brake is D5. + +**Receipts.** Every cast returns a JSON receipt, since Standard votes return none today (facts file section 5): `{motionId, choice, revision, castAtUtc, receiptId}`; sealed commit/reveal keep their existing receipt (`SealedBallotReceipt`, `SealedBallotService.cs`). Receipt contains no weight. The audit trail already records casts (`MotionGovernanceService.cs:86`); the audit row gains the client id and `jti` so a vote can be traced to the grant that cast it (INFERENCE: this is what "receipts" is for after an XSS: the member can see which client cast it). + +**Rate limits.** FACT none on the member API (facts file section 5); the primitives exist: `FixedWindowBudget` keyed by account (`Features/ConceptVotes/ConceptVoteRateLimits.cs:14-24`) and a 429 + Retry-After pattern (`Program.cs:~773-774`, e8e8fcc). Proposal: per-account budgets — casts 30 per 10 min (the Mill's number), reads 120 per minute; token endpoint per source address; 429 with `Retry-After`. Numbers are D6. + +## 9. Threat list + +| Threat | Why it matters here | Answer | +|---|---|---| +| **Token theft by XSS in chat** | Ratatoskr renders other people's Matrix messages; a script in the chat origin can read an in-memory token or call the module's own functions | Token memory-only, 10 min (cast scopes), scopes limited to votes, audience-bound, no refresh. Honest limit: an XSS can **cast during the page's life** whatever the token allows; shortening and scoping bound it, they do not stop it. Mitigations to require: strict CSP on chat.gamecult.org, message sanitisation, and an in-module confirmation step for `votes.cast` that is rendered by the module itself (Ratatoskr's cut). D2 asks whether casting should demand a fresh re-authentication (`prompt=login`, `max_age`) for high-stakes motions. Sealed motions are better off: the salt is generated and held client-side, but a compromised page holds it too. | +| **CSRF** | A third-party page makes the member's browser vote | `/api/votes` accepts no cookie (section 5), so a forged cross-site request carries no credential. CORS has no `AllowCredentials`, so a credentialed cross-origin read is unreadable. The cookie surfaces keep their current protection (FACT: Razor antiforgery, `Index.cshtml:127,139`; the sealed API relies on SameSite and JSON binding, `Program.cs:718-763`) and are untouched. | +| **Authorization-code interception / injection** | Code arrives in a URL on the chat origin | PKCE S256 required (FACT `:39`), exact redirect match, codes single-use and 60 s, `state` checked, URL cleaned at once, `Referrer-Policy: no-referrer` on the callback page. | +| **Login CSRF / mix-up** | Attacker finishes a flow with their own code | `state` bound to the tab, `nonce` checked in the id_token, one issuer only. | +| **Replay** | A captured request resent | Token expiry; codes single-use; Standard cast is `PUT` and idempotent by `(motion, sub)` (FACT one row per member per motion, `MotionGovernanceService.cs:64-85`); sealed commit/reveal already carry `revision` and phase checks; the commitment binds the voter id (`docs/sealed-ballot-motion.md:22-26`). Replaying the same cast cannot double a vote. | +| **Token substitution** | MAS-bound token used at `/api/votes`, or a Ratatoskr token elsewhere | Audience + scope + named scheme (sections 4-5). | +| **Open redirect / redirect abuse** | | One exact redirect URI; Bifrost local return paths unchanged (FACT `Features/Shared/LocalReturnPath.cs:13-31`). | +| **Overreach by a demoted or compromised account** | Membership changes after issue | Per-request Active check; sweep revokes authorizations. | +| **CORS misread as security** | CORS stops browsers only | Not relied on for authorisation; it is an availability measure. Tokens authenticate; non-browser callers need a token. | +| **Consent phishing** | Look-alike client asks for `votes.cast` | One registered client; consent page names it and shows the redirect host. | +| **Scraping / vote spam** | | Rate limits (section 8); no list of other members' ballots exists. | +| **Clickjacking of consent/sign-in** | | `frame-ancestors 'none'` on Bifrost auth pages. | +| **Logout gap** | The Bifrost session ends but a token lives on: 10 min for cast scopes; up to 60 min for `desk.read` (section 14 D3) | Accepted; bounded by lifetime and the per-request Active check. For `desk.read` the Bifrost session can end while the token keeps feeding `/api/desk` for up to 60 minutes; accepted because the feed is read-only and every request re-checks Active. | + +## 10. CORS, exactly + +- A **second named policy**, separate from `ConceptTallies` (FACT that policy reads `ConceptVotes:AllowedOrigins`, `ConceptTallyExport.cs:13-27`, so reusing it would couple two features' origin lists): `WithOrigins("https://chat.gamecult.org")`, methods `GET, POST, PUT, OPTIONS`, headers `Authorization, Content-Type`, **no `AllowCredentials`**, no exposed headers except `Retry-After`, preflight cache bounded. Origin comes from configuration as one exact value; no wildcard, no subdomain pattern, no `null`. +- Applied by route metadata to `/connect/token` and `/api/votes/*` only. `/connect/authorize` is a top-level navigation and needs none; `/.well-known/*` is not fetched by the module (the client config is static). Every response under those routes carries `Vary: Origin` (the pattern the tally export already follows). +- Bifrost still sends no CORS headers for `/api/sealed-motions/*`, `/App`, `/eve`, `/connect/userinfo` (the facts file's blocker 1 stays closed there on purpose). + +## 11. Probes owed before the cut spec (each is an EXTERNAL claim above) + +1. A public client with no secret completes code exchange when `ClientAuthenticationMethods` holds only `client_secret_basic`. +2. Per-client explicit grant consent (D1) works with the request-caching path (`request_uri`) used at `OidcEndpoints.cs:44-51`, alongside the existing notice-receipt redirect (`:99-102`, e8e8fcc), which parks and resumes the same request. +3. Audience and scope reach the access token and the validation handler enforces them; the MAS client's token is refused at `/api/votes`. +4. Default token lifetimes before the explicit settings. +5. OpenIddict validation as a non-default scheme leaves cookie-default endpoints unchanged (test: bearer on `/App` and `/api/sealed-motions` is 302/401, as now). +6. CORS preflight on `/connect/token` through OpenIddict's passthrough (the endpoint is mapped as a minimal API at `OidcEndpoints.cs:35`). +7. (owed by D3) Per-audience lifetimes: `principal.SetAccessTokenLifetime` / `SetAuthorizationCodeLifetime` set at authorize give a `desk.read`-only request a 60-minute access token and a cast-scope request 10 minutes, and the token's `exp` shows it. + +## 12. Questions as raised (decided in section 14) + +Decided 2026-11-11 by the steward of Bifrost: D1-D7 below are answered in section 14; the questions are kept as asked and are not open. + +For **Meta**: +- D1 Is explicit consent for `votes.cast` wanted (recommended) or is a first-party client implicit? +- D2 Should a vote on a high-stakes motion demand re-authentication (`prompt=login`) at cast time? Recommend yes for sealed commit and for any motion whose result moves money or roles; no for ordinary reads. +- D3 Accept that a 10-minute cast token with no refresh means a redirect round-trip to keep voting, or allow a refresh token (it widens theft impact; I recommend against). + +For **Eureka** (platform/ops, MAS and deploy order): +- D4 Where does a member list and revoke grants (a Bifrost `/App` page)? Needed before `votes.cast` ships if consent is explicit. +- D5 Is a runtime brake wanted on the Ratatoskr client (disable without redeploy), and who holds it? +- D6 Rate-limit numbers, and whether they belong in configuration (recommended) or code. +- D7 Order against the MAS rollout: this design needs Bifrost OIDC live (it is not, section 1), and the two clients can ship separately; do voting tokens wait for MAS, or does voting go first as the first OIDC consumer? Recommend independent; the collision-and-import hazards in the SSO live read concern MAS only. + +For **Ratatoskr (Void)**: the module owns the CSP, the in-module confirmation step, the callback page behaviour (section 6) and memory-only storage; this doc cannot enforce them from Bifrost's side. + +## 13. What this deliberately does not do + +No new identity: `sub` stays `UserAccount.Id`. No change to Heimdall, MAS, or the Mind key path. No weights or roles in tokens. No second vote implementation: the bearer surface is a thin projection of the existing services. No cookie sharing between chat and Bifrost, and no proxy on the chat origin pretending to hold one. + +## 14. Decision record (steward, 2026-11-11) + +Decided by the steward of Bifrost (the Persona), recorded by Heimdall (Self). Scope: the first consumer is the read-only Desk feed (scope `desk.read`, audience `bifrost-desk`, `docs/desk-feed-design.md`, draft PR #67), not votes. Each decision is the least-power, most-inspectable option that keeps exit (the member or operator can end it) and receipts (the act can be traced afterwards). Numbering: D1-D7 here are this document's (D1 consent, D2 re-auth, D3 token lifetime, D4 grant page, D5 brake, D6 rate limits, D7 ordering vs MAS); the "D1: `options_hash` covers everything shown" of the fork-desk answer-binding handoff is a different ruling and is not decided here. + +**D1 consent (per-client grant).** +- Decision: explicit per-client grant consent stays REQUIRED for any scope that can cast or answer (`votes.cast`, `desk.answer`), as recommended in section 7. `desk.read` (read-only, the member's own account) needs no per-client grant screen: implicit. Two senses of "consent" exist and are kept apart: the training-notice receipt (every authorize already redirects to `ConsentEndpoints` without a live receipt, `OidcEndpoints.cs:99-102`, `Features/Registration/ConsentPage.cs`, e8e8fcc), which applies to all clients and is unchanged; and the per-client grant, which does not exist yet. +- Reasons: FACT (e8e8fcc) there is no per-client grant consent today: the only consent step is the notice receipt; a `desk.read` grant changes nothing, so a click would buy no protection; a cast changes a recorded vote or answer, so the member must see the grant. +- Keeps: exit is the revocable stored authorization (D4); receipts are the audit row carrying client id and `jti` (section 8). +- Probe owed: 2 (section 11), the grant screen beside the notice redirect on the parked-request path. +- Holder: the steward of Bifrost. + +**D2 re-authentication.** +- Decision: yes (`prompt=login` at cast time) for sealed commit, and for answering or casting on any motion or fork whose result moves money or roles. Not needed for `desk.read`. +- Reasons: a stolen 10-minute token must not be enough to move money or roles; a read-only feed has nothing to protect that a fresh login would add. +- Keeps: exit, because the member re-proves the account each time; receipts, because the cast receipt follows a fresh authentication. +- Holder: the steward of Bifrost. + +**D3 token lifetime and refresh.** +- Decision: NO refresh token, anywhere. Access token 10 minutes for the cast and answer scopes; 60 minutes for the read-only audience `bifrost-desk`. Authorization code 60 seconds. One client may request both audiences; the lifetime follows the requested scope set: a request containing any cast or answer scope gets 10 minutes, a `desk.read`-only request gets 60. +- Reasons: FACT the code sets no access-token lifetime (only `RequestTokenLifetime` 10 minutes, `OidcServiceCollectionExtensions.cs:47`, e8e8fcc), so the explicit values replace OpenIddict defaults. Every `/api/desk` request re-checks Active membership (`ActiveMemberIdAsync`, `OidcEndpoints.cs:170`, used by `Features/Desk/DeskEndpoints.cs:105`), so lifetime bounds only theft of a read-only credential, not demotion; a 10-minute token would force a redirect round trip into a polling feed. Not to be confused with `ChatRefusalAsync` (`OidcEndpoints.cs:151`), which the token and userinfo endpoints use for chat subjects and which refuses a Suspended account or one without a live notice receipt, not a non-Active one. A refresh token would outlive the redirect and widen theft impact. +- Per-audience lifetime is not built or proven: it would be set with `principal.SetAccessTokenLifetime` / `SetAuthorizationCodeLifetime` at authorize. Probe 7 (section 11) is owed before the cut. +- Keeps: exit, because demotion is immediate and the sweep (to be built, section 8) revokes grants of non-Active subjects; no long-lived secret exists to leak. Accepted gap: the Bifrost session can end while a `desk.read` token feeds `/api/desk` for up to 60 minutes; acceptable for a read-only feed because every request re-checks Active. +- Holder: the steward of Bifrost. + +**D4 grant list and revoke page.** +- Decision: a member-facing page in `/App` that lists and revokes grants is a BLOCKING condition for shipping any cast or answer scope. It is not needed for `desk.read`, which is ended by the derived-from-membership sweep and by the operator brake (D5). Nothing further is decided here. +- Reasons: explicit consent (D1) without a place to withdraw it is half a control; a read-only grant ends with the sweep and the brake. Neither exists for a second client today: the sweep is to be built (`OidcLogoutReconciler` is MAS-specific, section 8), and the brake covers only the three `/connect` endpoints (D5). +- Keeps: exit (the page itself) before any scope that can act. +- Holder: the steward of Bifrost (the page is Bifrost code). + +**D5 runtime brake.** +- Decision: yes. Reuse the existing marker-file brake of PR #50 (commit 5201508; `OidcGuardMiddleware`, `OidcGuard.cs:32`, `BrakeEngaged` at `OidcGuard.cs:71`, option `Bifrost:Oidc:BrakePath`, `Configuration/OidcIssuerOptions.cs:26`, `docs/oidc-issuer.md` "Limits, prune, brake") rather than a new mechanism. It is held by the Bifrost steward (the Persona). +- The brake is NOT per-client. FACT (e8e8fcc) the guard matches `/connect/authorize`, `/token`, `/userinfo` for all clients (`EndpointOf`, `OidcGuard.cs:64-68`), so engaging it also 503s MAS sign-in; it is registered only when `Bifrost:Oidc` is configured (`Program.cs:294-297`) and `BrakePath` lives under `Bifrost:Oidc`. The code PR must extend the guard's matching to `/api/desk` and must settle a desk-only deployment (a host running the desk without `Bifrost:Oidc` configured has no guard and no brake path; either the desk requires `Bifrost:Oidc`, which it does for its tokens, or the brake option moves). Not decided here: that settlement is the code PR's to bring back. +- Reasons: FACT the guard is checked per request (no restart) and treats an unreadable marker as engaged; no second brake, no second owner. +- Keeps: exit for the operator (delete the file to release; discovery and keys stay up, as the issuer test asserts); receipts in the 503 `temporarily_unavailable` answers and the guard's log line. +- Holder: the Bifrost steward holds the brake. FLAG, not decided: where the marker file lives on the host is Eureka's (ops) to name. + +**D6 rate limits.** +- Decision: the numbers live in configuration, defaults: `desk.read` 120 requests per minute per account; casts 30 per 10 minutes per account (the Mill's number) when they exist. The token endpoint keeps the existing per-address budget (`OidcRateLimit`, `OidcGuard.cs:102`): that is ONE budget shared by authorize, token and userinfo together (`docs/oidc-issuer.md:187-188`), not a token-endpoint budget of its own. +- Reasons: FACT the member API has none today; the primitive `FixedWindowBudget` keyed by account exists (`ConceptVoteRateLimits.cs:14-24`) and the 429 plus `Retry-After` pattern too (`Program.cs:~773-774`, e8e8fcc; `RefuseAsync` in the guard). Config keeps the numbers inspectable and changeable without a build. Per-client budgets stay out, as in `docs/oidc-issuer.md` (the client id is public). +- Keeps: exit via the brake (D5); receipts via the 429 answers. The desk cut map's open point (a 429 has no refusal code in the contract, Q3 there) is not decided here. +- Holder: the steward of Bifrost. + +**D7 ordering against MAS.** +- Decision: independent. A second client is seeded only when `Bifrost:Oidc:Clients:Ratatoskr` is configured. This is a code change, not existing behaviour: FACT (e8e8fcc) the seeder builds one MAS descriptor and its delete-others loop (`OidcClientSeeder.cs:63-75`) removes everything else. Constraint on the code PR: under the MAS-less or default configuration it adds nothing observable (no new client, route, scope or discovery change), so window B (cut stays ab89556) is not widened. `desk.read` is the first consumer. It needs Bifrost OIDC live, as MAS does. +- Reasons: nothing in this client touches the MAS client, the Heimdall path or the Mind key path; "seeded only when configured, removed when no longer configured" is the behaviour to build, and the loop must be changed to spare configured clients rather than only MAS. +- Keeps: exit, because removing the configuration section deletes the client and its tokens at next start (once built); no receipts needed beyond the audit rows above. +- Holder: the steward of Bifrost. + +**Decided with the feed.** The Desk feed never carries answerer account ids (Bifrost masks `answerer` for what it serves; this closes X3 of `docs/desk-feed-design.md`). + +**Flags to other stewards.** +- Eureka (stated, not decided): the Huginn door list and read wire, including the audience/visibility policy and its wording in the door spec (not yet written; see the end of this section); the host path of the D5 marker. +- Libby (CultLib steward), ruled 2026-11-11: ForkCard gains `raised_at`, which stays OUT of `options_hash` (that hash keeps its five members: title, context, options, recommendation, depends, so digests are unchanged) and is a REQUIRED card key, an RFC3339 string or nil; the card goes from 16 to 17 keys and its hex changes. This closes X1 and X2 of `docs/desk-feed-design.md` (X2 withdrawn). + +**Who sees which card: Huginn's door (Eureka), not Bifrost.** This is Bifrost's requirement of the door, not a quotation of any door spec (none is written yet). Bifrost assumes the door returns only cards this member may see, and applies no audience rule of its own. Eureka owns the policy and its wording in the door spec; the intended policy is that a card is hidden unless the door says a member may see it. The default-audience question (X4 of the desk feed note) is therefore not open for Bifrost and was never a CultLib matter. Bifrost adds three new acts on the desk path: the per-request Active membership check, the `desk.read` bearer (audience `bifrost-desk`), and masking `answerer` ids on what the door returns; they sit beside the two already built (`DeskNotes.Strip` and the configured-Mind 404). Bifrost does NOT: filter by audience, hold any per-card visibility rule, or keep a second copy of the policy (two copies would drift). From f6a826b6e75e2b59a4b6dd15d94686d10de29fcc Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 17:06:45 +0000 Subject: [PATCH 06/48] Desk feed design: per-Mind open forks, read-only (decisions in PR #37 section 14) (#67) --- docs/desk-feed-design.md | 117 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/desk-feed-design.md diff --git a/docs/desk-feed-design.md b/docs/desk-feed-design.md new file mode 100644 index 0000000..218dfbc --- /dev/null +++ b/docs/desk-feed-design.md @@ -0,0 +1,117 @@ +# Desk feed: per-Mind open forks for a signed-in member (design, no code) + +Status: proposal, 2026-11-11, Heimdall (Imagination pass) against `origin/main` e8e8fcc. Docs only; nothing here is built, ruled or deployed. +Labels: **FACT** = read at the cited place. **INFERENCE** = my reading. **UNREACHED** = I could not read it. +Reads: `src/Bifrost.Web/Features/Desk/*` (main), `docs/ratatoskr-voting-auth.md` (PR #37, e172869), `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`, Ratatoskr `fbec032` (`src/app/features/desk/*`), `tools/persona-matrix-mouth.mjs`, `docs/bridge.md`. UNREACHED: CultLib `fork-card.md` (no checkout here; I rely on the Desk code that pins it at de8a062a), Huginn's `desk_wire.rs` (PR #31 is merged; the list/read half of the door wire is not defined in anything I read). + +## 1. What is asked, and the finding that shapes it + +Ratatoskr's Desk row per Mind is empty "until Bifrost feeds it" (FACT `src/app/features/desk/source.ts`: `deskFor(mind): ForkList | undefined`, slice 2 "replaces this with Bifrost's list reader"). The page already decodes `gamecult.fork.list.v0` (`forkContract.ts`). + +**Finding: the feed route is already built.** `GET /api/desk/{mind}/forks?status=open&limit=N` is on main (PR #40, hardened #42-#45): list, per-Mind socket door, `open_count`, `stamp`, notes stripped for non-deciders. It serves nothing today because its two ports have no production implementation (`DeskPorts.cs`: `IDeskCredential`, `IDeskDoor`; `MapDesk` fails startup if either is missing). So the smallest honest feed is **not a new endpoint. It is the existing list route, read-only, with its two ports filled and a token that carries only `desk.read`.** A second route would need a second contract, vectors and owner for no gain. + +## 2. The endpoint + +- `GET https:///api/desk/{mind}/forks?status=open&limit=50[&cursor=c]`, one call per Mind (the client already asks per Mind). `limit=0` returns counts and stamp only (FACT binding file, route table), usable for a badge. +- Response: `gamecult.fork.list.v0` (`ForkList`: `mind`, `stamp`, `open_count`, `recorded_count`, `forks[]` of `ForkEntry`, `next_cursor`). **JSON, and why:** Ratatoskr is a browser client, the xenos boundary; the contract (CultLib `fork-card.md`, Transport) fixes JSON at the HTTP edge with every key written, and the documents are typed C# records (`DeskDocuments.cs`) mirroring CultLib's. Bifrost to Huginn is the typed door, not JSON. +- The row the UI needs is derived by the client from each entry: `card.fork_id`, `card.title`, age (section 5), and the count is `open_count`. The page "derives nothing" (FACT `forkContract.ts` header), so it takes `open_count` as the one count. +- Not offered: any POST (`/api/desk/{mind}/answers` stays unreachable to this client because its token has no `desk.answer`, so it is refused 403 `insufficient_scope` at step 4 of the pipeline, before the body), any vote, any write. + +## 3. Auth: what a signed-in member can call today, and what the feed reuses + +FACT today: a signed-in member holds only the cookie `__Host-bifrost_session`; the default scheme is Cookie (`Program.cs:189-200`); no `AddValidation` exists, so no access token authenticates any `/api/*` route; the desk handler is `.AllowAnonymous()` and judges its own bearer (`DeskEndpoints.cs`, `MapDesk`), and the cookie scheme cannot authenticate `/api/desk/*`. **A signed-in member can call nothing on the desk today.** Chat.gamecult.org never holds the `__Host-` cookie, so "the Heimdall session" cannot be sent by the browser to Bifrost from chat. + +So the session is used as PR #37 uses it: the **sign-in leg of an OIDC code+PKCE flow** (`/connect/authorize` resumes on the Heimdall-backed session, FACT `OidcEndpoints.cs:44-70`, which admits any account that is not Suspended and has an identity: membership is neither required nor written, so the sign-in leg does not prove Active. It works for `desk.read` only because the desk re-checks Active on every request) mints a short access token. Reuse, exactly: +1. Token: PR #37's second public PKCE client (Ratatoskr) and `BifrostBearer` OpenIddict validation (PR #37 section 5, handler `IDeskCredential` production impl, the credential port; H4 in the desk cut map, a label used only there). Request scope `desk.read` only, audience `bifrost-desk`. No `desk.answer`, no `votes.*`. +2. Judgment: unchanged `DeskEndpoints.HandleAsync`: audience, scope, then `OidcEndpoints.ActiveMemberIdAsync(db, sub)` re-read every request (non-Active gets 403 `forbidden`). +3. CORS: existing `DeskCors` (exactly `Desk:AllowedOrigin`, GET and POST, no credentials). +Rejected: a cookie-authenticated same-origin JSON route (works for a Bifrost page, not for the chat origin; a second auth path on the same data), and letting chat present its Matrix/MAS token (Bifrost does not validate it; INFERENCE per PR #37 section 1). + +## 4. Where the data comes from (compare, recommend one) + +Huginn is the truth. A fork is a question record with options, a derived status, deciders and a server-computed `options_hash` (FACT binding file, "Cards are a read-only projection"). Bifrost holds no fork store, no cache, no hash (FACT cut map authority map). Everything else is a cache or an unrelated plane. Options: + +- **(a) A small Huginn endpoint Bifrost calls.** This is already the plan of record, not a new thing: `IDeskDoor.ListAsync(socket, context, request)` over the per-Mind peer-checked unix socket, `context` = {Mind, authority, subject, deciders} built by Bifrost alone (`DeskPorts.cs`). Huginn returns the list "as it projects it for this viewer". +- **(b) A file the persona mouth tails or writes.** FACT the mouth holds one `cursor.cc` per Persona and it stores only the Matrix `/sync` position `next_batch` (`persona-matrix-mouth.mjs` ~:491, :906-912). `chat_pending` is Matrix deliveries to a Persona over loopback RUDP (credential-derived connection id), served *to* Huginn, which is the client; the mouth reads Matrix, not Huginn. It holds no fork, no question and no Huginn client. A file route means a new writer inside the Matrix actuator that copies private records out of the mind into the chat plane, a second owner of "what a member may see", a Node process rewriting Rust's projection, and staleness equal to its write period. The mouth would gain a job it was split away from. +- **(c) Bifrost reads Huginn directly with a CultNet selection** (what `huginn open ` / `huginn blocked ` do). Rejected: those return every in-force question and follow-up of a campaign (`epiphany.pipeline.question.v2`, `follow_up.v2`), most of which are not forks. Bifrost would need a general read of the mind and would decide fork-hood and visibility itself. Also Bifrost has no Huginn client at all (FACT: no CultNet/RUDP/unix-socket client in C#; `UnixDomainSocketEndPoint` appears nowhere). + +**Recommend (a), as the existing desk door, list request only.** Reasons: +- **Auth:** the door is peer-credential checked (the receiver's uid); Bifrost passes the verified subject; the member's token never reaches Huginn. +- **Staleness:** none added. A live read, `Cache-Control: no-store` (FACT), with Huginn's `stamp` (`s`, same stamp same state) as the change signal; a client polls, or `limit=0` for the cheap probe. Bifrost may cache by stamp later; it need not. +- **Failure:** per Mind and per call. A dead socket, timeout or unknown outcome is 503 `unavailable` with `Retry-After` (FACT `WriteUnavailableAsync`); the other Minds' rows are unaffected because the client asks per Mind. The mouth being down is irrelevant. Nothing is served from a stale copy that could mislead. +- **Mouth overlap:** none, which is the point. Chat plane (mouth) and fork plane (door) stay two owners. +- **Not yet existing:** `UnixSocketDeskDoor` (the door port's production impl; H5 in the desk cut map) and the Huginn side of the list wire. Eureka owns that wire (binding file section 5, "The door wire is Eureka's"). I found no definition of the list/read request bytes; this note does not invent one. + +## 5. Meaning of "open" and "age" + +- **open** = the card's `status` is `open` as Huginn derives it (`ForkCard.status`; `?status=open` filter). Bifrost does not decide it. The count is `open_count` of the same response, so the number and the rows come from one stamp. `recorded_count` (closed with a ruling) is not part of the feed. +- **age** = now minus when the question was raised. **The contract cannot give this today.** FACT `ForkCard` has `order` (a position), `raised_in` (a reference) and no timestamp; answers carry `answered_at`/`received_at` but an unanswered fork has none. Ruled 2026-11-11 by Libby (CultLib steward): the card gains `raised_at`, a REQUIRED key whose value is an RFC3339 string or nil (the card goes from 16 to 17 keys; its hex changes). Huginn emits the question's admission time, or nil when it has none. Bifrost's record gains one field that must accept string or nil. Age derives from `raised_at` and is computed by the client; Bifrost computes nothing; nil means age unknown and the row shows no age. Until the contract lands the feed ships without age (the fallback loses nothing else). + +## 6. D1 (options_hash) and the feed + +Two different "D1"s exist: the ruling cited for this feed is the fork-desk answer-binding D1 (2026-11-10: `options_hash` covers title, context, options with ids, recommendation and depends; `fork_id` is bound beside it). PR #37 also has a D1 (consent). This section is the first; section 7 the second. +- Feed rows carry the card as Huginn returns it, `options_hash` included. Bifrost never computes or corrects a hash (FACT binding file). A row's identity is `(fork_id, options_hash)`: an edit to a shown title changes the hash, so the row changes with it. +- The feed shows title, which the hash covers, so feed and detail cannot disagree about it. +- **Age is not decision content.** Ruled by Libby (X2, my earlier "inside the hash" option is withdrawn): `raised_at` stays OUT of `options_hash`, which keeps exactly five members (title, context, options, recommendation, depends), so the hash digests and Void's `optionsHash.ts` vectors do not change. Only the card's own bytes change, by the added key. + +## 7. PR #37 decisions, what this feed needs + +| id | decision (brief) | for the feed | +|---|---|---| +| D1 | explicit consent for `votes.cast`, or first-party implicit | **not needed**: a `desk.read` grant casts and changes nothing; implicit read consent (PR #37 section 7) | +| D2 | re-authentication at cast for high-stakes motions | **not needed**: no cast | +| D3 | token lifetime | **decided** (PR #37 section 14 D3): no refresh token; `desk.read`-only requests get a 60-minute access token, so a polling feed needs no redirect per 10 minutes. Per-audience lifetime is not built; probe 7 is owed | +| D4 | where a member lists and revokes grants | **not needed** while the grant is read-only; revisit with `desk.answer` | +| D5 | runtime brake | **decided** (section 14 D5): the existing marker-file brake, held by the steward. It is NOT per-client and does not cover `/api/desk` until the guard's matching is extended; that and the desk-only deployment are the code PR's to settle | +| D6 | rate limits | **decided** (section 14 D6): 120 requests/min per account for `desk.read`, in configuration. A 429 has no refusal code in the contract (Q3 of the desk cut map): not decided there | +| D7 | order against the MAS rollout | **decided** (section 14 D7): independent; the Ratatoskr client is seeded only when configured. The feed still needs Bifrost OIDC live, which it is not (PR #37 section 1) | + +Already ruled: the answer-binding D1 above, visibility ruling c (any `desk.read` holder sees cards and outcomes; notes only for deciders), read with Bifrost's requirement that the door returns only cards this member may see, D2/answer-binding (decider list authored in Bifrost admin, Mind read-only). + +## 8. Privacy: what a member may see per Mind + +- **Filter owner: Huginn.** The door call carries the viewer (`subject`, `authority`, that Mind's `deciders`); Huginn projects "for this viewer". Bifrost REQUIRES of the door that the list wire carry only cards this member may see; the policy and its wording are Eureka's to write in the door spec (not yet written, so nothing here quotes it; the intended policy is that a card is hidden unless the door says a member may see it). Bifrost holds NO audience rule: no filter on which forks appear, no per-card visibility rule, no copy of the policy (two copies would drift). Bifrost's three new acts are the per-request Active check, the `desk.read` bearer, and answerer masking on what the door returns (below), beside two already built: it nils every `note` for a non-decider (`DeskNotes.Strip`), and it resolves `{mind}` only against the configured list (unknown Mind and unknown fork return the same 404 bytes, so Minds are not probeable). +- **Fields safe for a member:** `fork_id`, `title`, `options`, `recommendation`, `depends`, `status`, `order`, `options_hash`, counts, `stamp`, and `raised_at` (string or nil). **Not safe, and not in the feed:** `note` (stripped), the decider list beyond ids already on the card, anything from the mind that is not a fork card (follow-ups, findings, research, persona state, room text). +- **Answerer ids (X3, closed by the steward in PR #37 section 14):** `ForkEntry.record.answers[]` carries each answerer's account id (a Bifrost `UserAccount.Id`) to any `desk.read` holder; ruling c permits cards and outcomes, but a member list of who answered what is more than a feed needs. The feed does not need `record` at all. Decided: Bifrost masks `answerer` for what it serves (small, consistent with `DeskNotes`) until a record-free list exists. +- **Content privacy:** a fork's `context` and `title` are Mind-authored prose and may name people or private matter. The door is the only gate: Bifrost requires the list wire to carry only cards the member may see, passes them through and masks answerer ids. The audience policy is Eureka's to put in the door spec, not here. The feed ships no free text Bifrost wrote. +- **Consent and receipts:** reading creates no member record, so no receipt and no consent object; the member's act is the token grant (read-only, implicit). Answer receipts and the append-only record belong to the answer path and stay out. Audit: log client id and `sub`, not the response. + +## 9. Failure modes + +Bearer unjudgeable: 503 `unavailable` (not a refusal of the caller). Not Active: 403. Unknown Mind: 404 `fork_unknown`. Oversize fork: 422 `fork_too_large` with a cursor to continue (FACT #42/#44; the client must handle it, not render nothing). Token expired: 401; the client restarts the redirect (D3). Door down: 503; the Desk row shows "unavailable, retry", never an empty list (an empty list must mean zero open forks). Huginn emits a decider list outside 1..64 for a configured Mind: 503 (config fault, FACT). Ratatoskr's source is synchronous; it needs a fetch-and-hold layer that keeps the last good `stamp` and marks it stale, which is Void's. + +## 10. Tests that would prove it (consumer side) + +1. Ratatoskr, against a stubbed list: a `ForkList` fixture from the vendored vectors renders the right rows and count; a 503 renders "unavailable", not an empty desk; `open_count` 0 with an empty page renders "none open". +2. Bifrost, `DeskWebApplicationFactory` + `FakeDeskDoor`: a token with `desk.read` lists (200); no token 401; wrong audience 401; a `desk.read`-only token POSTing an answer is 403 `insufficient_scope` with the door never called; a non-Active subject is 403 with the door never called; a cookie alone gets 401. +3. Privacy: a non-decider's response has every `note` nil (key present); every `answerer` of a non-decider is masked; a decider keeps both; an unknown Mind and an unknown fork return identical bytes; Bifrost relays exactly the cards the door returns (a door double returning N cards yields N rows, none dropped or added) and applies no audience filter of its own. +4. Hash: a row's `options_hash` equals the vector value, and Ratatoskr's `optionsHash.ts` recomputed over the rendered title/context/options matches it (pins D1 end to end). +5. Probes owed by PR #37 section 11 (items 1, 3, 5): public client exchange, audience+scope enforced, bearer on `/App` still refused. +6. Door contract: a `UnixSocketDeskDoor` against a Huginn double returns `Listed`, `NotListed`, `ForkTooLarge`, and an exception maps to 503. + +## 11. Size estimate (lines, include tests) + +| file | lines | +|---|---| +| Bifrost `Features/Desk/UnixSocketDeskDoor.cs` (list only; read/answer added by the answer cut) | ~180 | +| Bifrost `Features/Oidc/*` bearer validation, `desk.read` scope, audience, second client seed (shared with PR #37) | ~150 | +| Bifrost `IDeskCredential` production impl (validates the bearer, returns `DeskToken`) + wiring in `Program.cs`; no audience or visibility logic | ~60 | +| Bifrost sweep revoking the Ratatoskr/desk client's authorizations for subjects failing the chat-tier test (PR #37 section 8; derived from membership, no outbound POST) | ~80 | +| Bifrost `OidcGuard` match extended to `/api/desk` (D5 brake) and the desk-only deployment settlement | ~30 | +| Bifrost per-audience token lifetime at authorize (D3: 60 minutes for `desk.read`-only; probe 7 first) | ~30 | +| Bifrost `DeskNotes` extension: mask `answerer` for non-deciders | ~15 | +| Bifrost re-vendor `tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json` (17-key card, new CultLib commit) + `DeskVectors.cs:9,11` pin + `ForkCard` 17th member `RaisedAt`, string or nil (`DeskDocuments.cs:64-80`) + `DeskDocumentTests`/`DeskScenarioTests` updates | ~60 | +| CultLib `fork-card.md` + schema + card vectors for `raised_at` (17-key card; `options_hash` vectors unchanged) | ~40 | +| Huginn: list wire handler and `raised_at` (Eureka's) | not estimated | +| Ratatoskr list reader + token redirect + stale holder (Void's) | ~250 | +| Bifrost tests (items 2, 3, 6) | ~250 | +| This doc and `docs/oidc-issuer.md` note | ~40 | + +Subtraction: no new route, no store, no migration, no DTO type beyond one field. The two shared ports (door, bearer) are the cost, and the answer path needs them anyway. + +## 12. Open decisions + +- **X1** (CultLib, ruled 2026-11-11, Libby): `raised_at` is a required card key, RFC3339 string or nil; 16 to 17 keys. Huginn's emission of it is Eureka's, still to build. +- **X2** (withdrawn): `raised_at` is out of `options_hash`, digests unchanged (Libby). +- **Nothing in this note is open for Bifrost's own design.** Decided and recorded elsewhere: `raised_at` (Libby, CultLib ruling 2026-11-11, X1/X2 closed); answerer masking (X3, steward, PR #37 section 14); PR #37 D1-D7 (section 14); card visibility (the Huginn door's policy, in its spec; Bifrost holds no audience rule). +- Owed by others: Eureka owns the list/read door wire, Huginn's emission of `raised_at`, and the visibility policy; this feed is blocked on them and on PR #37's bearer. From 7231cdb97737694428b00fcc1e6f4831d43c857f Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 20:05:46 +0000 Subject: [PATCH 07/48] Mouth: reply_to lows after #64 (round cap defers, 60 s excerpt, wider scrub, id shape) (#68) --- docs/bridge.md | 2 +- ...sona.chat_pending.v4.reply-to-scrubbed.hex | 1 + ....chat_pending.v4.reply-to-scrubbed.msgpack | Bin 0 -> 236 bytes ...pending.v4.reply-to-variation-selector.hex | 1 + ...ing.v4.reply-to-variation-selector.msgpack | Bin 0 -> 232 bytes tests/persona-matrix-mouth-runtime.test.mjs | 18 +++- tests/persona-matrix-mouth.test.mjs | 94 +++++++++++++++--- tools/persona-chat-documents.mjs | 5 + tools/persona-matrix-mouth.mjs | 22 ++-- 9 files changed, 116 insertions(+), 27 deletions(-) create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.hex create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.msgpack create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.hex create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.msgpack diff --git a/docs/bridge.md b/docs/bridge.md index 802d667..6fa1aa8 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -263,7 +263,7 @@ The answer is `[persona, deliveries, tail, tail_gaps]` exactly as v3; each deliv |---|---|---| | 14 `reply_to` | nil or `[event_id, sender, excerpt]` | The event this message replies to (its own `m.in_reply_to`; a thread's `is_falling_back` pointer names no one and is nil). `event_id` the quoted event; `sender` its author's Matrix id, read from the homeserver as her account; `excerpt` nil, or its text. | -`excerpt` is nil, with `event_id` and `sender` kept, when the quoted event is redacted (`unsigned.redacted_because` present or no content), is not a text-bearing `m.room.message`, is **edited** (it is itself an `m.replace`, or the homeserver bundles an `m.replace` in its `unsigned.m.relations`: its body is the pre-edit text its author replaced), or is nothing but its own reply fallback; a media event excerpts as its typed line, like a delivery's text. Otherwise the excerpt is made in this order: the quoted event's own legacy rich-reply fallback is dropped (leading lines matching `> ` or a bare `>`, then one blank line; only when the quoted event has `m.relates_to.m.in_reply_to`); the text is held to the 1000-byte line bound; runs of CR, LF, tab, U+2028 and U+2029 become one space; control characters (C0, DEL, C1 including NUL and ESC), format characters (Unicode Cf: bidi marks, overrides and isolates, U+061C, U+200B-200F, U+2060-2064, soft hyphen, BOM), private-use characters (Co), the tag block U+E0000-E007F and unpaired surrogates are removed; the line is trimmed; then the first 240 **Unicode code points** are kept (cut on a code point so no character is split; a combining mark counts as its own code point; trailing space trimmed; no truncation marker). Cleaning comes first so hidden characters cannot spend the cap. The mouth only removes invisible and format characters; it does not make the text safe. Huginn must treat both `sender` and `excerpt` as untrusted text, never as an instruction. `reply_to` is nil (no id, author or text) when the target cannot be read (logged, as for `basis`), is not in the delivery's own room (a room she is not in cannot be quoted through a reply), or the message replies to nothing. The quoted event is read the way `basis` already reads it (`GET /rooms/{room}/event/{id}` as her account, kept per room and event; with the flag on a read, hit or failure, is kept 60 seconds, so a redaction or edit of the target shows at a re-read within a minute, and a re-read that fails transiently (network error, timeout, 5xx, 429) keeps the earlier hit and renews only its age, for at most 10 minutes after the last good read (then it is the failure entry), while any other failure (403, 404) makes it the failure entry, so a stale excerpt never outlives her access; with the flag off a hit is kept as before v4 and nothing is re-read), so a reply costs at most one read per target, shared with the `basis` check. Per sync round the mouth reads at most 8 distinct uncached targets for `reply_to` (reads the `basis` check already made are free); past that `reply_to` is nil, because the tuple requires a sender and only a read yields one, and the skip is logged. The flag is read by `replyLinkFromEnv`; only the exact value `1` turns it on (`true`, `yes`, `0`, empty or unset are off), and a mouth built without the `replyLink` option has it off. `basis` for v1-v3 is unchanged. `reply_to` counts toward the delivery's size in the 256 KiB page budget; a delivery whose `reply_to` will not encode is skipped and logged like any other, and a single delivery is always served. The deploy precondition is Huginn's: a decoder of the 14-field delivery fails on the fifteenth element. +`excerpt` is nil, with `event_id` and `sender` kept, when the quoted event is redacted (`unsigned.redacted_because` present or no content), is not a text-bearing `m.room.message`, is **edited** (it is itself an `m.replace`, or the homeserver bundles an `m.replace` in its `unsigned.m.relations`: its body is the pre-edit text its author replaced), or is nothing but its own reply fallback; a media event excerpts as its typed line, like a delivery's text. Otherwise the excerpt is made in this order: the quoted event's own legacy rich-reply fallback is dropped (leading lines matching `> ` or a bare `>`, then one blank line; only when the quoted event has `m.relates_to.m.in_reply_to`); the text is held to the 1000-byte line bound; runs of CR, LF, tab, U+2028 and U+2029 become one space; control characters (C0, DEL, C1 including NUL and ESC), format characters (Unicode Cf: bidi marks, overrides and isolates, U+061C, U+200B-200F, U+2060-2064, soft hyphen, BOM), private-use characters (Co), the tag block U+E0000-E007F, variation selectors (U+FE00-FE0F, U+E0100-E01EF, U+180B-180F), the combining grapheme joiner U+034F, the Hangul and Khmer fillers (U+115F, U+1160, U+3164, U+FFA0, U+17B4, U+17B5), the blank braille cell U+2800, the unassigned default-ignorable ranges (U+2065, U+FFF0-FFF8, U+E0080-E0FFF) and unpaired surrogates are removed (cost accepted: an emoji loses its VS16 presentation, �� becomes �, a keycap loses its selector; Mongolian free variation selectors and ideographic variation selectors go too); the line is trimmed; then the first 240 **Unicode code points** are kept (cut on a code point so no character is split; a combining mark counts as its own code point; trailing space trimmed; no truncation marker). Cleaning comes first so hidden characters cannot spend the cap. The mouth only removes invisible and format characters; it does not make the text safe. Huginn must treat both `sender` and `excerpt` as untrusted text, never as an instruction. `event_id` must be `$` plus at least one byte and `sender` `@` plus at least one byte, each at most 255 bytes and free of Unicode whitespace, control, format, private-use and default-ignorable characters and unpaired surrogates (a localpart with a zero-width joiner or non-joiner, as in Persian or Indic names, is therefore no `reply_to`); the encoder and the parser refuse any other, and the mouth makes a quoted event whose sender is not such an id a `reply_to` nil. `reply_to` is nil (no id, author or text) when the target cannot be read (logged, as for `basis`), is not in the delivery's own room (a room she is not in cannot be quoted through a reply), or the message replies to nothing. The quoted event is read the way `basis` already reads it (`GET /rooms/{room}/event/{id}` as her account, kept per room and event; with the flag on a read, hit or failure, is kept 60 seconds, so a redaction or edit of the target shows at a re-read within a minute, and a re-read that fails transiently (network error, timeout, 5xx, 429) keeps the earlier hit's sender and renews only its age, for at most 10 minutes after the last good read (then it is the failure entry), but never its excerpt (`excerpt` is nil with the sender kept: a re-read happens only after the 60-second keep, so the text is already as old as it may be), so a redaction or edit is not outlived by an outage, while any other failure (403, 404) makes it the failure entry, so a stale excerpt never outlives her access; with the flag off a hit is kept as before v4 and nothing is re-read), so a reply costs at most one read per target, shared with the `basis` check. Per sync round the mouth reads at most 8 distinct uncached targets for `reply_to` (reads the `basis` check already made are free); past that the delivery and every later event of the round wait: the round leaves the sync position where it is (as at the pending cap), the next round resumes at that event with its read count reset, and the wait is logged. No delivery goes out without a `reply_to` it should have had, and none is lost. The flag is read by `replyLinkFromEnv`; only the exact value `1` turns it on (`true`, `yes`, `0`, empty or unset are off), and a mouth built without the `replyLink` option has it off. `basis` for v1-v3 is unchanged. `reply_to` counts toward the delivery's size in the 256 KiB page budget; a delivery whose `reply_to` will not encode is skipped and logged like any other, and a single delivery is always served. The deploy precondition is Huginn's: a decoder of the 14-field delivery fails on the fifteenth element. Golden bytes: `tests/fixtures/persona-chat-v6/` (`.msgpack` and its `.hex`: `chat_pending.v4.reply-to`, `.no-reply-to`, `.reply-to-redacted`, and the `chat_pending_request.v3` request); the test fails when the checked-in bytes drift from the encoder. diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.hex b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.hex new file mode 100644 index 0000000..1c18fda --- /dev/null +++ b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.hex @@ -0,0 +1 @@ +94a6657572656b61919f07b6236f70657261746f723a67616d6563756c742e6f7267b1216162633a67616d6563756c742e6f7267a3246531b2406d6574613a67616d6563756c742e6f7267a44d657461ac68656c6c6f20657572656b61cf0000019a2d80b1009193b440657572656b613a67616d6563756c742e6f7267a76561726c696572cf0000019a2d80ad1892b440657572656b613a67616d6563756c742e6f7267b2406e6f76613a67616d6563756c742e6f7267c2c0c0a76d65737361676593a3247131b340616c6963653a67616d6563756c742e6f7267ae69676e6f7265746869736c696e659090 diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.msgpack b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..3f27b2598e9500a4101e7ead183788e0c428f518 GIT binary patch literal 236 zcmbQTEVZ;KH9K+QeD-b1`30#(i6!|(R_TeksmY}|C3^Wq=^GUjlaf(H7OSKhZgR*? zElETXUg8TBS(A~PlasFiG4wnG1LG{+hK&ppCvS0p2%+d$o|;&clbKosRkv1R5>{oK z9P;wZQ0+Q&;K1_S)Z*gA^wi0VRSFF^J0#|0Ca0p9zAiI8FTW_YBqOspCo?Z~!UO;^ CZD5iB literal 0 HcmV?d00001 diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.hex b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.hex new file mode 100644 index 0000000..b404143 --- /dev/null +++ b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.hex @@ -0,0 +1 @@ +94a6657572656b61919f07b6236f70657261746f723a67616d6563756c742e6f7267b1216162633a67616d6563756c742e6f7267a3246531b2406d6574613a67616d6563756c742e6f7267a44d657461ac68656c6c6f20657572656b61cf0000019a2d80b1009193b440657572656b613a67616d6563756c742e6f7267a76561726c696572cf0000019a2d80ad1892b440657572656b613a67616d6563756c742e6f7267b2406e6f76613a67616d6563756c742e6f7267c2c0c0a76d65737361676593a3247131b340616c6963653a67616d6563756c742e6f7267aa6f6bf09f918d66696e659090 diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.msgpack b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..bc6c09ae28c562873732b50c1dc9234832d33cfa GIT binary patch literal 232 zcmbQTEVZ;KH9K+QeD-b1`30#(i6!|(R_TeksmY}|C3^Wq=^GUjlaf(H7OSKhZgR*? zElETXUg8TBS(A~PlasFiG4wnG1LG{+hK&ppCvS0p2%+d$o|;&clbKosRkv1R5>{oK z9P;wZQ0+Q&;K1_S)Z*gA^wi0VRSFF^J0#|0Ca0p9zA8Wa!~BW8X_