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

+ @if (!string.IsNullOrEmpty(Model.Notice)) + { + + } + @if (Model.Actor.CanManageMembership) {
@@ -186,6 +191,10 @@ @if (Model.Actor.CanManageMembership) { + @if (user.Status == MembershipStatus.Suspended && !string.IsNullOrEmpty(user.SuspensionReason)) + { +
Suspended: @user.SuspensionReason
+ } diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs index e9b536b..55fa91f 100644 --- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs @@ -22,6 +22,23 @@ public sealed class IndexModel( [BindProperty] public UpdateProfileInput Profile { get; set; } = new(); + /// Why the last membership action did nothing; one of , carried across the redirect. + [TempData] + public string? Notice { get; set; } + + private static readonly IReadOnlyDictionary RefusalNotices = new Dictionary + { + [SuspensionOutcome.NoReason] = "Nothing changed: a reason is required to suspend or unsuspend an account.", + [SuspensionOutcome.ProtectedAccount] = "Nothing changed: you cannot suspend yourself or another platform admin.", + [SuspensionOutcome.NoSuchAccount] = "Nothing changed: that account or its membership does not exist.", + [SuspensionOutcome.NotApplicable] = "Nothing changed: the account is already in that state." + }; + + internal const string ApproveSuspendedNotice = "Nothing changed: a suspended account can only be lifted with Unsuspend."; + internal const string NoMembershipNotice = "Nothing changed: that account has no membership yet."; + internal const string OwnAdminRoleNotice = "Nothing changed: you cannot remove or change your own platform admin role."; + internal const string RoleOnSuspendedNotice = "Nothing changed: a suspended account's roles cannot be changed until it is unsuspended."; + public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; public IReadOnlyList Users { get; private set; } = []; @@ -106,7 +123,8 @@ public sealed class IndexModel( if (user.Membership!.Status == MembershipStatus.Suspended) { - return RedirectToPage(); // only Unsuspend lifts a suspension + Notice = ApproveSuspendedNotice; // only Unsuspend lifts a suspension + return RedirectToPage(); } user.Membership.Status = MembershipStatus.Active; @@ -143,7 +161,13 @@ public sealed class IndexModel( var outcome = suspend ? await suspension.SuspendAsync(Actor, userAccountId, reason, cancellationToken) : await suspension.UnsuspendAsync(Actor, userAccountId, reason, cancellationToken); - return outcome == SuspensionOutcome.NotAdmin ? Forbid() : RedirectToPage(); + if (outcome == SuspensionOutcome.NotAdmin) + { + return Forbid(); + } + + Notice = RefusalNotices.GetValueOrDefault(outcome); + return RedirectToPage(); } public async Task OnPostToggleRoleAsync(Guid userAccountId, MemberRole role, CancellationToken cancellationToken) @@ -161,6 +185,19 @@ public sealed class IndexModel( if (user.Membership is null) { + Notice = NoMembershipNotice; + return RedirectToPage(); + } + + if (user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin) + { + Notice = OwnAdminRoleNotice; + return RedirectToPage(); + } + + if (user.Membership.Status == MembershipStatus.Suspended) + { + Notice = RoleOnSuspendedNotice; return RedirectToPage(); } @@ -175,8 +212,10 @@ public sealed class IndexModel( Notes = "Assigned from the member admin page." }); } - else if (!(user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin)) + else { + // Out of the collection too, so the flags below derive from the roles as they will stand after the save. + user.Membership.RoleAssignments.Remove(existingAssignment); dbContext.RoleAssignments.Remove(existingAssignment); } @@ -339,6 +378,7 @@ public sealed class IndexModel( user.MemberProfile?.Skills ?? string.Empty, user.MemberProfile?.Availability ?? string.Empty, user.Membership?.Status ?? MembershipStatus.Authenticated, + Actor.CanManageMembership ? user.Membership?.SuspensionReason ?? string.Empty : string.Empty, user.Membership?.RoleAssignments.Select(x => x.Role).OrderBy(x => x.ToString()).ToArray() ?? [], patronTier?.Label ?? string.Empty, patronTier?.Weight ?? 0m, @@ -426,6 +466,7 @@ public sealed class IndexModel( string Skills, string Availability, MembershipStatus Status, + string SuspensionReason, IReadOnlyList Roles, string PatronTierLabel, decimal PatronTierWeight, diff --git a/tests/Bifrost.Web.Tests/BootstrapAdminTests.cs b/tests/Bifrost.Web.Tests/BootstrapAdminTests.cs new file mode 100644 index 0000000..a980dd6 --- /dev/null +++ b/tests/Bifrost.Web.Tests/BootstrapAdminTests.cs @@ -0,0 +1,176 @@ +using System.Security.Claims; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Pages.App.Members; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests; + +/// Bootstrap promotion never lifts a suspension and leaves a receipt when it changes status; demoting an admin clears the admin flags. +public sealed class BootstrapAdminTests +{ + private const long BootstrapGitHubId = 4242; + + private static (Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory Factory, CapturingLoggerProvider Logs) NewFactory() + { + var logs = new CapturingLoggerProvider(); + var factory = new TestWebApplicationFactory().WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + return (factory, logs); + } + + private static async Task SeedBootstrapLoginAsync(IServiceProvider services, MembershipStatus status) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = new UserAccount + { + DisplayName = "Test Admin", + GitHubUserId = BootstrapGitHubId, + GitHubLogin = "test-admin", + NormalizedGitHubLogin = "TEST-ADMIN", + BifrostIdentity = "testadmin", + NormalizedBifrostIdentity = "testadmin", + MemberProfile = new MemberProfile { Nickname = "ta" }, + Membership = new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = status == MembershipStatus.Suspended ? DateTimeOffset.UtcNow : null } + }; + db.UserAccounts.Add(account); + await db.SaveChangesAsync(); + return account.Id; + } + + private static async Task<(Bifrost.Web.Domain.Membership Membership, List Receipts)> ReadAsync(IServiceProvider services, Guid accountId) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var membership = await db.Memberships.AsNoTracking().Include(x => x.RoleAssignments).SingleAsync(x => x.UserAccountId == accountId); + var receipts = await db.AuditEvents.AsNoTracking().Where(x => x.EntityId == membership.Id.ToString()).ToListAsync(); + return (membership, receipts); + } + + private static ClaimsPrincipal BootstrapPrincipal() => new(new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, BootstrapGitHubId.ToString()), + new Claim(ClaimTypes.Name, "test-admin") + ], "test")); + + [Theory] + [InlineData("startup")] + [InlineData("signin")] + public async Task A_suspended_bootstrap_login_stays_suspended_and_unpromoted(string path) + { + var (factory, logs) = NewFactory(); + using var _ = factory; + var id = await SeedBootstrapLoginAsync(factory.Services, MembershipStatus.Suspended); + var before = (await ReadAsync(factory.Services, id)).Receipts.Count; + + await RunPathAsync(factory.Services, path); + + var (membership, receipts) = await ReadAsync(factory.Services, id); + Assert.Equal(MembershipStatus.Suspended, membership.Status); + Assert.False(membership.IsPlatformAdmin); + Assert.Empty(membership.RoleAssignments); + Assert.Equal(before, receipts.Count); + Assert.Contains(logs.Lines, x => x.Contains("suspended account") && x.Contains(id.ToString())); + } + + [Theory] + [InlineData("startup")] + [InlineData("signin")] + public async Task A_bootstrap_promotion_that_changes_status_writes_a_receipt(string path) + { + var (factory, _) = NewFactory(); + using var _f = factory; + var id = await SeedBootstrapLoginAsync(factory.Services, MembershipStatus.Authenticated); + + await RunPathAsync(factory.Services, path); + + var (membership, receipts) = await ReadAsync(factory.Services, id); + Assert.Equal(MembershipStatus.Active, membership.Status); + Assert.True(membership.IsPlatformAdmin); + var receipt = Assert.Single(receipts, x => x.Action == "membership.bootstrap-promoted"); + Assert.Contains("Authenticated -> Active", receipt.Detail); + + await RunPathAsync(factory.Services, path); // already Active: no second receipt + Assert.Single((await ReadAsync(factory.Services, id)).Receipts, x => x.Action == "membership.bootstrap-promoted"); + } + + private static async Task RunPathAsync(IServiceProvider services, string path) + { + using var scope = services.CreateScope(); + if (path == "startup") + { + await scope.ServiceProvider.GetRequiredService().RunAsync(CancellationToken.None); + } + else + { + await scope.ServiceProvider.GetRequiredService().SynchronizeAsync(BootstrapPrincipal(), CancellationToken.None); + } + } + + private sealed class FixedActor(CurrentBifrostActor actor) : ICurrentBifrostActorAccessor + { + public Task GetAsync(CancellationToken cancellationToken) => Task.FromResult(actor); + } + + private static async Task SeedAsync(IServiceProvider services, string name, bool admin) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active, ApprovedAtUtc = DateTimeOffset.UtcNow }; + if (admin) + { + membership.IsPlatformAdmin = membership.CanManageProjects = membership.CanManageLedger = membership.CanModerateMotions = true; + membership.RoleAssignments.Add(new RoleAssignment { Role = MemberRole.PlatformAdmin }); + } + + var account = new UserAccount + { + DisplayName = name, + BifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20], + NormalizedBifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20], + Membership = membership + }; + db.UserAccounts.Add(account); + await db.SaveChangesAsync(); + return account.Id; + } + + private static async Task ToggleAsync(IServiceProvider services, Guid by, Guid target, MemberRole role) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var actor = new CurrentBifrostActor(true, await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.Id == by)); + var page = new IndexModel(db, new FixedActor(actor), scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), TimeProvider.System); + Assert.IsType(await page.OnPostToggleRoleAsync(target, role, CancellationToken.None)); + } + + [Fact] + public async Task Demoting_an_admin_through_the_page_clears_the_admin_flags_and_promoting_sets_them() + { + var (factory, _) = NewFactory(); + using var _f = factory; + var actingAdmin = await SeedAsync(factory.Services, "acting", admin: true); + var otherAdmin = await SeedAsync(factory.Services, "other", admin: true); + var member = await SeedAsync(factory.Services, "member", admin: false); + + await ToggleAsync(factory.Services, actingAdmin, otherAdmin, MemberRole.PlatformAdmin); + var (demoted, _) = await ReadAsync(factory.Services, otherAdmin); + Assert.False(demoted.IsPlatformAdmin); + Assert.False(demoted.CanManageProjects); + Assert.False(demoted.CanManageLedger); + Assert.False(demoted.CanModerateMotions); + Assert.DoesNotContain(demoted.RoleAssignments, x => x.Role == MemberRole.PlatformAdmin); + + await ToggleAsync(factory.Services, actingAdmin, member, MemberRole.PlatformAdmin); + var (promoted, _) = await ReadAsync(factory.Services, member); + Assert.True(promoted.IsPlatformAdmin); + Assert.Contains(promoted.RoleAssignments, x => x.Role == MemberRole.PlatformAdmin); + } +} diff --git a/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs b/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs new file mode 100644 index 0000000..b4261d8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs @@ -0,0 +1,202 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +/// A refused suspend or unsuspend tells the admin why on the page the redirect lands on, and shows only a fixed message. +public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) : IClassFixture +{ + private HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + BaseAddress = new Uri("https://localhost"), + HandleCookies = true + }); + + private async Task SeedMemberAsync(MembershipStatus status, bool withMembership = true) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = $"notice-{Guid.NewGuid():N}"[..20]; + var account = new UserAccount + { + DisplayName = id, BifrostIdentity = id, NormalizedBifrostIdentity = id, + Membership = withMembership ? new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = DateTimeOffset.UtcNow } : null + }; + db.UserAccounts.Add(account); + await db.SaveChangesAsync(); + return account.Id; + } + + private async Task SignedInAdminIdAsync(HttpClient client) + { + using var _ = await client.GetAsync("/App/Members"); // sign-in sync creates and promotes the bootstrap admin + using var scope = factory.Services.CreateScope(); + return (await scope.ServiceProvider.GetRequiredService().UserAccounts.AsNoTracking().SingleAsync(x => x.GitHubUserId == 12345)).Id; + } + + private static async Task TokenAsync(HttpClient client) + { + var html = await (await client.GetAsync("/App/Members")).Content.ReadAsStringAsync(); + return Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value; + } + + private static async Task PostThenFollowAsync(HttpClient client, string handler, Guid target, string reason, string? role = null) + { + var token = await TokenAsync(client); + Assert.NotEmpty(token); + using var post = await client.PostAsync($"/App/Members?handler={handler}", new FormUrlEncodedContent(new Dictionary + { + ["userAccountId"] = target.ToString(), + ["reason"] = reason, + ["role"] = role ?? "Producer", + ["__RequestVerificationToken"] = token + })); + Assert.Equal(HttpStatusCode.Redirect, post.StatusCode); + using var landed = await client.GetAsync(post.Headers.Location); + Assert.Equal(HttpStatusCode.OK, landed.StatusCode); + return await landed.Content.ReadAsStringAsync(); + } + + private static readonly Regex Alert = new("]*role=\"alert\"[^>]*>(.*?)

", RegexOptions.Singleline); + + [Fact] + public async Task A_suspend_without_a_reason_says_a_reason_is_required_and_changes_nothing() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var member = await SeedMemberAsync(MembershipStatus.Active); + + var html = await PostThenFollowAsync(client, "Suspend", member, " "); + + Assert.Contains("a reason is required", Alert.Match(html).Groups[1].Value); + using var scope = factory.Services.CreateScope(); + Assert.Equal(MembershipStatus.Active, (await scope.ServiceProvider.GetRequiredService().Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == member)).Status); + } + + [Fact] + public async Task A_suspend_of_the_admin_themselves_says_it_is_protected() + { + using var client = Client(); + var admin = await SignedInAdminIdAsync(client); + + var html = await PostThenFollowAsync(client, "Suspend", admin, "oops"); + + Assert.Contains("cannot suspend yourself or another platform admin", Alert.Match(html).Groups[1].Value); + } + + [Fact] + public async Task A_suspend_of_a_missing_account_says_so_and_never_echoes_the_submitted_text() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + const string canary = ""; + + var html = await PostThenFollowAsync(client, "Suspend", Guid.NewGuid(), canary); + + Assert.Contains("does not exist", Alert.Match(html).Groups[1].Value); + Assert.DoesNotContain("CANARY-notice-77", html); + } + + [Fact] + public async Task An_unsuspend_of_an_active_account_says_it_is_already_in_that_state_and_the_notice_shows_once() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var member = await SeedMemberAsync(MembershipStatus.Active); + + var html = await PostThenFollowAsync(client, "Unsuspend", member, "appeal"); + + Assert.Contains("already in that state", Alert.Match(html).Groups[1].Value); + Assert.DoesNotContain("role=\"alert\"", await (await client.GetAsync("/App/Members")).Content.ReadAsStringAsync()); + } + + [Fact] + public async Task A_successful_suspend_shows_no_refusal() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var member = await SeedMemberAsync(MembershipStatus.Active); + + var html = await PostThenFollowAsync(client, "Suspend", member, "spam"); + + Assert.DoesNotContain("role=\"alert\"", html); + } + + private async Task<(MembershipStatus Status, int Roles)> StateAsync(Guid user) + { + using var scope = factory.Services.CreateScope(); + var m = await scope.ServiceProvider.GetRequiredService().Memberships.AsNoTracking().Include(x => x.RoleAssignments).SingleAsync(x => x.UserAccountId == user); + return (m.Status, m.RoleAssignments.Count); + } + + [Fact] + public async Task Approving_a_suspended_account_says_only_unsuspend_lifts_it_and_leaves_it_suspended() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var member = await SeedMemberAsync(MembershipStatus.Suspended); + + var html = await PostThenFollowAsync(client, "Approve", member, ""); + + Assert.Contains("only be lifted with Unsuspend", Alert.Match(html).Groups[1].Value); + Assert.Equal(MembershipStatus.Suspended, (await StateAsync(member)).Status); + } + + [Fact] + public async Task Toggling_a_role_on_a_suspended_account_is_refused_with_a_notice_and_changes_nothing() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var member = await SeedMemberAsync(MembershipStatus.Suspended); + + var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer"); + + Assert.Contains("roles cannot be changed until it is unsuspended", Alert.Match(html).Groups[1].Value); + Assert.Equal(0, (await StateAsync(member)).Roles); + } + + [Fact] + public async Task Toggling_a_role_on_an_account_without_a_membership_says_so() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var member = await SeedMemberAsync(MembershipStatus.Active, withMembership: false); + + var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer"); + + Assert.Contains("has no membership yet", Alert.Match(html).Groups[1].Value); + } + + [Fact] + public async Task An_admin_toggling_their_own_platform_admin_role_is_refused_and_keeps_the_role() + { + using var client = Client(); + var admin = await SignedInAdminIdAsync(client); + var before = (await StateAsync(admin)).Roles; + + var html = await PostThenFollowAsync(client, "ToggleRole", admin, "", role: "PlatformAdmin"); + + Assert.Contains("cannot remove or change your own platform admin role", Alert.Match(html).Groups[1].Value); + Assert.Equal(before, (await StateAsync(admin)).Roles); + } + + [Fact] + public async Task A_role_toggle_on_an_active_account_still_works_without_a_refusal() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var member = await SeedMemberAsync(MembershipStatus.Active); + + var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer"); + + Assert.DoesNotContain("role=\"alert\"", html); + Assert.True((await StateAsync(member)).Roles >= 1); + } +} diff --git a/tests/Bifrost.Web.Tests/MembershipSuspensionTests.cs b/tests/Bifrost.Web.Tests/MembershipSuspensionTests.cs index 85e12ca..97f0c99 100644 --- a/tests/Bifrost.Web.Tests/MembershipSuspensionTests.cs +++ b/tests/Bifrost.Web.Tests/MembershipSuspensionTests.cs @@ -57,6 +57,12 @@ public sealed class MembershipSuspensionTests : IClassFixture StatusAsync(Guid id) => InScopeAsync(async (_, db) => (await db.Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == id)).Status); + private Task ReasonAsync(Guid id) => InScopeAsync(async (_, db) => + (await db.Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == id)).SuspensionReason); + + private Task MembershipIdAsync(Guid id) => InScopeAsync(async (_, db) => + (await db.Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == id)).Id); + private Task> AuditAsync(string action) => InScopeAsync((_, db) => db.AuditEvents.AsNoTracking().Where(x => x.Action == action).ToListAsync()); @@ -78,9 +84,12 @@ public sealed class MembershipSuspensionTests : IClassFixture x.Detail.Contains("spam in chat")); + var membershipId = (await MembershipIdAsync(seed.MemberId)).ToString(); + var receipt = Assert.Single(await AuditAsync("membership.suspended"), x => x.EntityId == membershipId); Assert.Equal(seed.AdminId, receipt.ActorUserAccountId); Assert.Contains("Active -> Suspended", receipt.Detail); + Assert.DoesNotContain("spam in chat", receipt.Detail); + Assert.Equal("spam in chat", await ReasonAsync(seed.MemberId)); } [Fact] @@ -95,7 +104,7 @@ public sealed class MembershipSuspensionTests : IClassFixture await sp.GetRequiredService().GetAsync(seed.OrdinaryId, CancellationToken.None)); Assert.DoesNotContain(snapshot.RecentActivity, x => x.Detail.Contains("private-reason-canary")); - Assert.Contains(await AuditAsync("membership.suspended"), x => x.Detail.Contains("private-reason-canary")); + Assert.DoesNotContain(await AuditAsync("membership.suspended"), x => x.Detail.Contains("private-reason-canary")); + Assert.Equal("private-reason-canary", await ReasonAsync(seed.MemberId)); } [Fact] @@ -132,9 +142,16 @@ public sealed class MembershipSuspensionTests : IClassFixture + { + (await db.Memberships.SingleAsync(x => x.UserAccountId == seed.MemberId)).SuspensionReason = "old reason"; + await db.SaveChangesAsync(); + return 0; + }); Assert.Equal(SuspensionOutcome.Done, await UnsuspendAsync(seed.AdminId, seed.MemberId, "appeal upheld")); Assert.Equal(restored, await StatusAsync(seed.MemberId)); - Assert.Contains(await AuditAsync("membership.unsuspended"), x => x.Detail.Contains("appeal upheld") && x.ActorUserAccountId == seed.AdminId); + Assert.Contains(await AuditAsync("membership.unsuspended"), x => x.ActorUserAccountId == seed.AdminId && !x.Detail.Contains("appeal upheld")); + Assert.Null(await ReasonAsync(seed.MemberId)); Assert.Equal(SuspensionOutcome.NotApplicable, await UnsuspendAsync(seed.AdminId, seed.MemberId, "again")); } diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 1fc57d2..012b061 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -26,6 +26,7 @@ public sealed class MigrationDiscoveryTests "20261109210000_AddMindKeys", "20261111090000_AddConsentReceipts", "20261111100000_ConsentReceiptsOneLivePerVersion", + "20261111110000_AddMembershipSuspensionReason", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. @@ -55,7 +56,7 @@ public sealed class MigrationDiscoveryTests } [Fact] - public void All_sixteen_migrations_are_listed_in_order() + public void All_migrations_are_listed_in_order() { Assert.Equal(ExpectedMigrations, Discovered()); } diff --git a/tests/Bifrost.Web.Tests/OidcLogoutReachWarningTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutReachWarningTests.cs new file mode 100644 index 0000000..acd4cbe --- /dev/null +++ b/tests/Bifrost.Web.Tests/OidcLogoutReachWarningTests.cs @@ -0,0 +1,50 @@ +using Bifrost.Web.Tests.Support; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests; + +/// Without a back-channel logout URL suspension cannot end live chat sessions, and the host says so once at startup. +public sealed class OidcLogoutReachWarningTests +{ + private const string Marker = "does not end its existing chat sessions"; + + private static int Warnings(Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory factory, CapturingLoggerProvider logs) + { + _ = factory.Services; // starts the host and its hosted services + return logs.Lines.Count(x => x.Contains(Marker)); + } + + private static Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory WithLogs(Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory factory, CapturingLoggerProvider logs) => + factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + + [Fact] + public void OIDC_configured_without_the_logout_url_warns_exactly_once() + { + var logs = new CapturingLoggerProvider(); + using var plain = new OidcWebApplicationFactory(); + using var factory = WithLogs(plain, logs); + + Assert.Equal(1, Warnings(factory, logs)); + } + + [Fact] + public void OIDC_with_the_logout_url_does_not_warn() + { + var logs = new CapturingLoggerProvider(); + using var plain = new OidcLogoutFactory(); + using var factory = WithLogs(plain, logs); + + Assert.Equal(0, Warnings(factory, logs)); + } + + [Fact] + public void OIDC_unconfigured_does_not_warn() + { + var logs = new CapturingLoggerProvider(); + using var plain = new TestWebApplicationFactory(); + using var factory = WithLogs(plain, logs); + + Assert.Equal(0, Warnings(factory, logs)); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs index 582fa04..1ef58c0 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs @@ -359,6 +359,11 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory public FakeMasHandler Mas { get; } = new(); + /// The raw token response and access token of the last . + public string LastTokenResponse { get; private set; } = string.Empty; + + public string LastAccessToken { get; private set; } = string.Empty; + public ManualClock Time { get; } = new(); public OidcLogoutReconciler Reconciler => Services.GetServices().OfType().Single(); @@ -399,7 +404,7 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory }; /// Registers a member, sets , and completes one authorization-code flow for them. - public async Task<(string Identity, string IdToken)> GrantAsync(MembershipStatus status = MembershipStatus.Active) + public async Task<(string Identity, string IdToken)> GrantAsync(MembershipStatus status = MembershipStatus.Active, string? suspensionReason = null) { using var client = CreateClient(HostClientOptions()); var identity = $"member-{Guid.NewGuid():N}"; @@ -409,6 +414,14 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory } await SetMembershipAsync(identity, MembershipStatus.Active); + if (suspensionReason is not null) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); + db.Memberships.Single(x => x.UserAccountId == account.Id).SuspensionReason = suspensionReason; + await db.SaveChangesAsync(); + } var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48)); var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); @@ -444,7 +457,10 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{OidcTestKit.ClientId}:{Kit.ClientSecret}"))); using var tokens = await bare.SendAsync(exchange); Assert.Equal(HttpStatusCode.OK, tokens.StatusCode); - var idToken = (await tokens.Content.ReadFromJsonAsync()).GetProperty("id_token").GetString()!; + var body = await tokens.Content.ReadFromJsonAsync(); + var idToken = body.GetProperty("id_token").GetString()!; + LastTokenResponse = body.GetRawText(); + LastAccessToken = body.GetProperty("access_token").GetString()!; if (status != MembershipStatus.Active) { diff --git a/tests/Bifrost.Web.Tests/SuspensionReasonExposureTests.cs b/tests/Bifrost.Web.Tests/SuspensionReasonExposureTests.cs new file mode 100644 index 0000000..0921036 --- /dev/null +++ b/tests/Bifrost.Web.Tests/SuspensionReasonExposureTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +/// The suspension reason is for admins: no member-facing page or the OIDC token and userinfo output carries it. +public sealed class SuspensionReasonExposureTests +{ + private const string Canary = "REASON-CANARY-9d2f-do-not-leak"; + + private static async Task SeedAsync(IServiceProvider services, string identity, MembershipStatus status, string? reason) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.UserAccounts.Add(new UserAccount + { + DisplayName = identity, BifrostIdentity = identity, NormalizedBifrostIdentity = identity, + Membership = new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = DateTimeOffset.UtcNow, SuspensionReason = reason } + }); + await db.SaveChangesAsync(); + } + + private static async Task GetAsync(HttpClient client, string path, string? identity) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.Add("X-Test-Bifrost-Identity", identity); + using var response = await client.SendAsync(request); + Assert.True(response.StatusCode == HttpStatusCode.OK, path); + return await response.Content.ReadAsStringAsync(); + } + + private sealed class FixedActor(CurrentBifrostActor actor) : ICurrentBifrostActorAccessor + { + public Task GetAsync(CancellationToken cancellationToken) => Task.FromResult(actor); + } + + // Guards the projection on its own: the view's admin gate cannot be the only thing keeping the reason off a non-admin's page model. + [Fact] + public async Task The_members_page_model_carries_no_reason_for_a_non_admin_viewer_and_does_for_an_admin() + { + using var factory = new TestWebApplicationFactory(); + await SeedAsync(factory.Services, "suspendedtwo", MembershipStatus.Suspended, Canary); + using var scope = factory.Services.CreateScope(); + var sp = scope.ServiceProvider; + var db = sp.GetRequiredService(); + var viewer = await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.BifrostIdentity == "suspendedtwo"); + + async Task> UsersSeenBy(bool admin) + { + viewer.Membership!.IsPlatformAdmin = admin; + var model = new Bifrost.Web.Pages.App.Members.IndexModel(db, new FixedActor(new CurrentBifrostActor(true, viewer)), + sp.GetRequiredService(), sp.GetRequiredService(), TimeProvider.System); + await model.OnGetAsync(CancellationToken.None); + return model.Users; + } + + Assert.All(await UsersSeenBy(admin: false), u => Assert.DoesNotContain(Canary, u.SuspensionReason)); + Assert.Contains(await UsersSeenBy(admin: true), u => u.SuspensionReason == Canary); + } + + [Fact] + public async Task The_member_dashboard_hides_the_bootstrap_promotion_receipt_but_shows_ordinary_activity() + { + using var factory = new TestWebApplicationFactory(); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.AuditEvents.Add(new AuditEvent { Action = "membership.bootstrap-promoted", Detail = "PROMO-CANARY-41", EntityType = "Membership", EntityId = "x", OccurredAtUtc = DateTimeOffset.UtcNow }); + db.AuditEvents.Add(new AuditEvent { Action = "membership.approved", Detail = "ORDINARY-CANARY-41", EntityType = "Membership", EntityId = "x", OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1) }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost") }); + var html = await client.GetStringAsync("/App"); + + Assert.Contains("ORDINARY-CANARY-41", html); + Assert.DoesNotContain("PROMO-CANARY-41", html); + } + + [Fact] + public async Task Member_facing_pages_never_carry_the_reason_and_the_admin_members_page_does() + { + using var factory = new TestWebApplicationFactory(); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost") }); + await SeedAsync(factory.Services, "suspendedone", MembershipStatus.Suspended, Canary); + await SeedAsync(factory.Services, "ordinaryone", MembershipStatus.Active, null); + + foreach (var path in new[] { "/App", "/App/Members", "/Membership/Status" }) + { + Assert.DoesNotContain(Canary, await GetAsync(client, path, "ordinaryone")); + } + + Assert.DoesNotContain(Canary, await GetAsync(client, "/Membership/Status", "suspendedone")); + + using var admin = factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri("https://localhost") }); + Assert.Contains(Canary, await admin.GetStringAsync("/App/Members")); // the default test sign-in is a bootstrap admin + } + + [Fact] + public async Task The_OIDC_token_response_and_id_token_never_carry_the_reason() + { + using var factory = new OidcLogoutFactory(); + var (_, idToken) = await factory.GrantAsync(suspensionReason: Canary); + + Assert.DoesNotContain(Canary, factory.LastTokenResponse); + var payload = new JsonWebToken(idToken).EncodedPayload; + Assert.DoesNotContain(Canary, Encoding.UTF8.GetString(Base64UrlEncoder.DecodeBytes(payload))); + } + + // Userinfo is broken in some environments before this change (OidcIssuerTests' userinfo cases fail identically there). + [Fact] + public async Task OIDC_userinfo_never_carries_the_reason() + { + using var factory = new OidcLogoutFactory(); + _ = await factory.GrantAsync(suspensionReason: Canary); + + using var client = factory.CreateClient(factory.HostClientOptions()); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", factory.LastAccessToken); + using var userinfo = await client.GetAsync("/connect/userinfo"); + Assert.Equal(HttpStatusCode.OK, userinfo.StatusCode); + Assert.DoesNotContain(Canary, await userinfo.Content.ReadAsStringAsync()); + } +} From 7813426e6d4e07623e3f80ac113420bb667c28e7 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 14:12:51 +0000 Subject: [PATCH 004/102] Drop HTMX (unpkg, no integrity); guard against third-party assets (#61) --- AGENTS.md | 2 +- README.md | 2 +- docs/bifrost-mvp-plan.md | 4 +- docs/concept-mill-votes-target.md | 2 +- src/Bifrost.Web/Pages/App/Index.cshtml | 7 +- src/Bifrost.Web/Pages/App/Index.cshtml.cs | 7 - src/Bifrost.Web/Pages/Index.cshtml | 2 +- src/Bifrost.Web/Pages/Mill/Vote.cshtml | 6 +- src/Bifrost.Web/Pages/Shared/_Layout.cshtml | 3 +- .../NoThirdPartyAssetTests.cs | 132 ++++++++++++++++++ 10 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs diff --git a/AGENTS.md b/AGENTS.md index c06e133..0b17617 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ ## Locked Technical Defaults - stack: ASP.NET Core 8 LTS -- UI: Razor Pages + HTMX +- UI: Razor Pages - database: PostgreSQL - hosting target: `bifrost.gamecult.org` on Yggdrasil - deployment pattern: nginx + systemd + localhost-only Postgres, matching `E:\Projects\gamecult-ops` diff --git a/README.md b/README.md index 33302e1..4994f86 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ It is closer to deployable now, but it is still not a finished internet-facing m ## Chosen Stack - ASP.NET Core 8 LTS -- Razor Pages + HTMX +- Razor Pages - PostgreSQL - Bifrost-native identity registration - GitHub OAuth for linked sign-in and repository integration diff --git a/docs/bifrost-mvp-plan.md b/docs/bifrost-mvp-plan.md index fa1bd10..29fd545 100644 --- a/docs/bifrost-mvp-plan.md +++ b/docs/bifrost-mvp-plan.md @@ -4,7 +4,7 @@ - Create Bifrost as a separate GameCult app repo and deploy it on Yggdrasil. - Build a member alpha, not a public launch. -- Use ASP.NET Core 8 with Razor Pages + HTMX and PostgreSQL. +- Use ASP.NET Core 8 with Razor Pages and PostgreSQL. - Support invite-only GitHub-linked members, GitHub-backed and internal work items, app-native governance, Reddit organizing threads, and internal ledgers. - Preserve the Labor Platform loop: user/member/patron demand raises work priority and reward pressure, contributors pick up valuable work, maintainers accept completion artifacts, and Bifrost credits the contributor plus any bounty/reward allocation. - Do not include Ethereum, wallets, DAO mechanics, or payout execution in v1. @@ -13,7 +13,7 @@ ## System Shape - One ASP.NET web app process for v1. -- Server-rendered UI with HTMX for progressive interactions. +- Server-rendered UI; no client-side framework or third-party script. - PostgreSQL as the system of record. - Bifrost-native identity registration for login, with GitHub OAuth available as a linked external identity. - GitHub App plus webhooks for issue and pull request lifecycle sync. diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md index c20525d..30a523e 100644 --- a/docs/concept-mill-votes-target.md +++ b/docs/concept-mill-votes-target.md @@ -53,7 +53,7 @@ Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only 21. Main lacks EF migration discovery metadata on seven migrations: `20260622142000_AddBridgeActionIdentityProvenance`, `20260622202000_AddBridgeActionTargetSurfaceName` and `20260705190000_AddPatronSupportProjectAttribution` have neither `[DbContext]` nor `[Migration]`; `20260608210000_AddHeimdallUserIdentity`, `20260609143000_AddPatronSupportDerivation`, `20260609160000_AddExternalPatronProviderIntake` and `20260622165000_AddBifrostNativeIdentity` have `[Migration]` only; none of the seven has a `.Designer.cs`. EF discovers a migration only with both attributes, so `MigrateAsync` from main would skip them on a fresh database. `daedcc4` adds exactly those attributes (17 lines). The other five migrations have Designer files and are discovered. Migrations run at startup when `Bootstrap:ApplyMigrationsOnStartup` is true (`Features/Shared/ApplicationBootstrapper.cs:17-21`); the live env sets that key. 22. Tests use EF InMemory (`tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs:52-58`), which enforces no unique index; a uniqueness rule must be proven in service code and in the model/migration metadata, not by an InMemory insert failing. `TestAuthHandler` offers two actors only: the bootstrap admin `test-admin`, or anonymous via header `X-Test-Anonymous` (`Support/TestAuthHandler.cs:17-37`). `CurrentBifrostActor` is a public record with a public constructor (`Features/Shared/CurrentBifrostActor.cs:5`), so service tests can build account and member actors directly. `Program` is `public partial` (`Program.cs:582`). 23. Startup configuration rules live in one owner, `Features/Shared/StartupConfigurationValidator.cs`, which skips Development and Testing and throws on a missing production key (`:15-74`). -24. The shared layout loads HTMX from `https://unpkg.com/htmx.org@1.9.12` (`Pages/Shared/_Layout.cshtml:65`); no page posts with `hx-post` today. Admin and vote handlers use plain Razor page handlers with `RedirectToPage()` (`Pages/App/Motions/Index.cshtml.cs:56-61`). +24. The shared layout loads no third-party script or stylesheet (HTMX was dropped; `NoThirdPartyAssetTests` fails on any cross-origin ` @await RenderSectionAsync("Scripts", required: false) diff --git a/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs b/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs new file mode 100644 index 0000000..5deb1a8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +// The site serves every script and stylesheet itself: a page that pulls code or styling from another origin hands +// that origin the visitor's session. Fails on any cross-origin ", true)] + [InlineData("", true)] + [InlineData("html.Append(\"", false)] + [InlineData("repo", false)] + public void The_source_pattern_catches_cross_origin_assets_and_allows_same_origin_and_fonts(string markup, bool flagged) => + Assert.Equal(flagged, CrossOriginSource().IsMatch(markup)); + + [Theory] + [InlineData("
", true)] + [InlineData("
", true)] + [InlineData("token=ab-hx-9f3", false)] + public void Htmx_is_matched_only_as_an_attribute(string markup, bool flagged) => + Assert.Equal(flagged, HtmxAttribute().IsMatch(markup)); + + [Fact] + public async Task Real_pages_load_no_third_party_asset() + { + var collection = "c" + Guid.NewGuid().ToString("N"); + var identity = $"assets-{Guid.NewGuid():N}"; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject { Collection = collection, Key = "alpha", Title = "alpha", RegisteredAtUtc = DateTimeOffset.UtcNow }); + db.UserAccounts.Add(new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = identity }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost") }); + var pages = new (string Path, string? Identity, bool Anonymous)[] + { + ("/", null, true), + ($"/mill/{collection}/alpha", null, true), + ($"/mill/{collection}/alpha", null, false), + ("/App", null, false), + ("/auth/sign-in", null, true), + ("/auth/bifrost/register", null, true), + ("/auth/bifrost/consent", identity, false), + }; + + foreach (var (path, who, anonymous) in pages) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + if (anonymous) + { + request.Headers.Add("X-Test-Anonymous", "true"); + } + else if (who is not null) + { + request.Headers.Add("X-Test-Bifrost-Identity", who); + } + + using var response = await client.SendAsync(request); + Assert.True(response.StatusCode == HttpStatusCode.OK, $"{path} returned {response.StatusCode}"); + var html = await response.Content.ReadAsStringAsync(); + Assert.False(string.IsNullOrWhiteSpace(html), path); + Assert.DoesNotContain("unpkg.com", html, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("htmx", html, StringComparison.OrdinalIgnoreCase); + Assert.False(HtmxAttribute().IsMatch(html), $"{path} carries an hx- attribute"); + Assert.False(CrossOriginAsset().IsMatch(html), $"{path} loads a cross-origin asset"); + } + } +} From e8df60630e7211c7c48dc670e8b50a63a4dd954f Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 14:12:55 +0000 Subject: [PATCH 005/102] Prefix lengths: host-level startup tests (tests only) (#62) --- .../ConceptVoteRateLimitTests.cs | 96 ++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index d334541..7510ed8 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -516,6 +516,96 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture(() => factory.CreateClient()); + + Assert.Contains(key, Messages(failure)); + } + + // Boundary lengths start in Development and Testing and a cast from a real client address works. Production is + // not started here: it additionally demands a database, keys path and secrets this factory does not supply. + [Theory] + [InlineData("Development", 0, 0)] + [InlineData("Development", 32, 128)] + [InlineData("Testing", 0, 0)] + [InlineData("Testing", 32, 128)] + public async Task Boundary_prefix_lengths_start_and_a_cast_from_a_client_address_succeeds( + string environment, int v4, int v6) + { + using var factory = WithPrefixes(environment, v4, v6); + var collection = await NewSubject(factory); + + using var v4Cast = await new Client(factory, "203.0.113.200").Post(collection, "Up"); + using var v6Cast = await new Client(factory, "2001:db8:5::1").Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.Redirect, v4Cast.StatusCode); + Assert.Equal(HttpStatusCode.Redirect, v6Cast.StatusCode); + } + + [Fact] + public async Task A_zero_length_prefix_puts_every_address_of_the_family_in_one_bucket() + { + using var factory = WithPrefixes("Development", 0, 0); + var collection = await NewSubject(factory); + // One device (one cookie, one mint) arriving from changing addresses: only the cast bucket can refuse it. + var device = new Client(factory, "198.51.100.1"); + for (var i = 0; i < 3; i++) + { + using var ok = await device.Post(collection, "Up", $"198.51.100.{i + 1}"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Up", "192.0.2.77"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + private WebApplicationFactory WithPrefixes(string environment, int v4, int v6) => + _factory.WithWebHostBuilder(b => + { + b.UseEnvironment(environment); + b.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:IPv4PrefixLength"] = v4.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["ConceptVotes:IPv6PrefixLength"] = v6.ToString(System.Globalization.CultureInfo.InvariantCulture) + })); + }); + + private static string Messages(Exception failure) + { + var lines = new List(); + for (var e = failure; e is not null; e = e.InnerException!) + { + lines.Add(e.Message); + if (e.InnerException is null) + { + break; + } + } + + return string.Join("\n", lines); + } + private WebApplicationFactory Capped(int maxTrackedKeys) => _factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(new Dictionary @@ -531,10 +621,12 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture x.StartsWith(CookieName + "=", StringComparison.Ordinal)) : null; - private async Task NewSubject() + private Task NewSubject() => NewSubject(_factory); + + private static async Task NewSubject(WebApplicationFactory factory) { var collection = "c" + Guid.NewGuid().ToString("N"); - using var scope = _factory.Services.CreateScope(); + using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.ConceptSubjects.Add(new ConceptSubject { From ab89556101f982ee567b186659f9893fdab74322 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 14:32:06 +0000 Subject: [PATCH 006/102] Chat sign-in needs a live consent receipt; notice frozen as version 1 (#59) --- docs/oidc-issuer.md | 72 +++++++- docs/registration-gates.md | 10 +- docs/training-notice.md | 60 +++--- ...261111120000_ClearLegacyConsentReceipts.cs | 26 +++ .../Features/Oidc/OidcEndpoints.cs | 40 ++-- .../Features/Oidc/OidcLogoutReconciler.cs | 4 +- .../Features/Registration/ConsentEndpoints.cs | 22 ++- .../Features/Registration/ConsentPage.cs | 15 +- .../Features/Registration/RegistrationPage.cs | 6 +- .../Features/Registration/TrainingNotice.cs | 15 +- tests/Bifrost.Web.Tests/ConsentTests.cs | 48 ++++- .../MigrationDiscoveryTests.cs | 1 + tests/Bifrost.Web.Tests/OidcIssuerTests.cs | 172 +++++++++++++++++- tests/Bifrost.Web.Tests/OidcLogoutTests.cs | 19 ++ .../RegistrationFormTests.cs | 5 +- .../RegistrationGateTests.cs | 5 +- .../Bifrost.Web.Tests/Support/OidcTestKit.cs | 59 +++++- .../Support/RegistrationKit.cs | 5 +- .../TrainingNoticeFrozenTests.cs | 44 +++++ 19 files changed, 536 insertions(+), 92 deletions(-) create mode 100644 src/Bifrost.Web/Data/Migrations/20261111120000_ClearLegacyConsentReceipts.cs create mode 100644 tests/Bifrost.Web.Tests/TrainingNoticeFrozenTests.cs diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md index b3c4121..e6a2eeb 100644 --- a/docs/oidc-issuer.md +++ b/docs/oidc-issuer.md @@ -5,7 +5,8 @@ Authentication Service (MAS) can sign chat users in with their Bifrost account. Bifrost; it does not issue the identity chat sees. The provider is OpenIddict (`OpenIddict.AspNetCore` and `OpenIddict.EntityFrameworkCore`, 7.7.1). Bifrost owns two -decisions at the authorize endpoint: who the chat user is, and whether they may get a session. +decisions at the authorize endpoint: who the chat user is, and whether they may get a session (not suspended, and agreed to +the training notice in force; see "Chat needs a receipt"). ## Endpoints @@ -80,17 +81,76 @@ The authorize endpoint reads the cookie session and the same actor facts the `/A accounts and the system names, the same 28 as MAS `banned_usernames`): `error=access_denied` with `error_description=The account's Bifrost identity is reserved.` Creation refuses these names, but a row minted before that refusal may hold one and there is no rename path, so the issuer never vouches for a Mind's or a system's localpart. The refusal is at authorize only: a code or access token issued for such a row before this check deployed stays redeemable until it expires (minutes), and token and userinfo re-read membership, not the name. Whether any such row exists is not known from the code; one query of `UserAccounts` against the 28 names would say. +- Signed in, not refused above, but no live consent receipt for the training notice version in force + (`TrainingNotice.Version`; none ever, withdrawn, or for another version): **not an OIDC error.** A 302 to + `/auth/bifrost/consent?returnUrl=`. No code is issued and the + client's `redirect_uri` is not called. The consent page shows the notice and an Agree button; agreeing is a 303 back to + the `returnUrl`, which resumes the same parked request and issues the code. The parked request is single use and lives ten + minutes (it is spent only by an OpenIddict response, which the redirect is not), so a member who agrees after it has expired + gets MAS's error and starts the sign-in again, now with a receipt. This check comes last, so a suspended, identity-less or + reserved account is refused as before and never invited to agree. - Otherwise: a code is issued. -- Token endpoint and userinfo: suspension is read again; an account suspended or deleted since gets `invalid_grant` (token) or 403 (userinfo). The logout reconciler ends the sessions of those same accounts; a chat-tier account is never logged out for lacking membership. +- Token endpoint and userinfo: suspension and the receipt are read again, on every call. An account suspended or deleted + since, or without a live current receipt since (withdrawn, or the notice version bumped), gets `invalid_grant` (token) or + 403 `insufficient_access` (userinfo), with the description of the reason, and no claim. So an access token already in a + chat client's hands stops answering userinfo the moment the receipt goes, and answers again if the member agrees again + (this path does not revoke the token; the back-channel reconciler below does, when it is configured). There is no refresh token, so refresh needs no gate. + The logout reconciler ends the sessions of those same accounts; a chat-tier account is never logged out for lacking membership. + +## Chat needs a receipt + +The issuer is the chat tier: every token it issues is a chat token. Ruled by Meta, 2026-11-11: speaking in chat needs agreement +to the training notice, and nothing else does. A live `ConsentReceipt` for `TrainingNotice.Version` is required (the same +reading as the consent page's "agreed", `ConsentEndpoints.HasLiveReceiptAsync`); account sign-in, the members page and votes need no +receipt. A member with no receipt keeps all of those and loses only the chat token until they agree on the consent page. + +Where they learn it: the refused authorize lands them on the consent page, which states what agreement is for ("to speak in a +recorded room"), shows the notice, and returns them to chat sign-in when they agree. Nothing else prompts them: the +members page and the sign-in form do not nag, and an existing member who never opens chat is never asked. + +What a Persona or Eureka sees: + +| Step | Receipt live and current | No receipt, withdrawn, or another version | +| --- | --- | --- | +| Authorize | 302 to the client with `code` | 302 to `/auth/bifrost/consent?returnUrl=...`; no `code`, no `error` | +| Token | 200 with tokens | 400 `invalid_grant`, description "Agreement to the training notice in force is needed to speak in a recorded room." | +| Userinfo | 200 with `sub`, `preferred_username`, `name` | 403 `insufficient_access`; no claim in the body | +| Back-channel logout | none | Only when `BackchannelLogoutUri` is set (the reconciler runs): MAS is sent a logout for the subject and its authorizations are revoked, within one reconciler tick. Otherwise an already-open session is not ended; only new logins and userinfo calls are refused | + +Deploy note: on the first reconciler tick, every subject holding a MAS authorization and no receipt (all existing chat users, admins included) is sent a back-channel logout and loses its authorizations. That is the ruling working as designed, not a fault; they return through the consent page. + +The gate is on the token, so as built it covers every room the MAS login reaches. Which rooms are "recorded", and whether any +room is not, is the chat side's to say; the issuer does not know. + +### Userinfo smoke notes for Eureka + +Run against a deployed instance with two throwaway accounts that hold a membership of any kind: A has agreed on its consent +page, B has not (register without ticking the box, or click Withdraw). `$ISS` is the issuer, `$MAS` the registered client with +its secret, sessions are the cookie jars of a browser signed in as A or as B. Use the MAS client's own redirect URI. + +1. Sign in as A through chat (MAS), or by hand (the first authorize answer is a 302 to `?request_uri=...`; follow that hop, the next answer is the code redirect): `GET $ISS/connect/authorize?response_type=code&client_id=...&redirect_uri=...&scope=openid%20profile&state=s&nonce=n&code_challenge=...&code_challenge_method=S256` with A's cookie. + Expect 302 to the redirect URI with `code` and `state`. Exchange it: `POST $ISS/connect/token` with `client_secret_basic` + and `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`. Expect 200 with `access_token` and `id_token`. +2. `GET $ISS/connect/userinfo` with `Authorization: Bearer `. Expect 200 and exactly `sub`, `preferred_username`, `name`. +3. Same authorize request with B's cookie. Expect 302 whose `Location` starts `/auth/bifrost/consent?returnUrl=%2Fconnect%2Fauthorize%3Frequest_uri`, + and no `code` anywhere. (OpenIddict first parks the request and redirects to the same URL with a `request_uri`; follow + that one hop, the consent redirect is the next answer. A missing receipt ignores `prompt=none`: still a 302 to the consent page, never `consent_required`.) `GET` that Location with B's cookie: expect 200 and a page that contains + "to speak in a recorded room" and a form posting to `/auth/bifrost/consent/accept`. +4. Withdraw A: on A's consent page click Withdraw (a form `POST /auth/bifrost/consent/withdraw`, 303 back). Repeat step 2 with + A's old access token: expect 403, `WWW-Authenticate` with `error="insufficient_access"`, and no `sub` or `preferred_username` + in the body. Repeat step 1's authorize for A: expect the consent redirect of step 3, not a code. +5. A clicks Agree. Repeat step 1's authorize and token, then step 2: expect 302 with `code`, 200 tokens, 200 userinfo. +6. A code obtained before step 4 and exchanged after it: expect 400 `invalid_grant` and no `access_token`. +7. Control: with the receipt gone, B can still sign in to Bifrost, open `/App/Members` and vote; only chat is refused. ## Back-channel logout When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the subjects holding valid authorizations of the MAS client; for each subject who may no longer chat (the same -reading the token and userinfo endpoints use: membership `Suspended`, a deleted account, or a subject that is not an account id; an -account with no membership or a pending one may chat and is left alone) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations, -which also invalidates their tokens. Membership is never written by this path: any way of being suspended or deleted (admin -action, sync, a database edit) is covered because the work is derived from membership state. +reading the token and userinfo endpoints use: membership `Suspended`, a deleted account, a subject that is not an account id, or +no live receipt for the notice version in force; an account with no membership or a pending one that has agreed may chat and is left alone) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations, +which also invalidates their tokens. Membership and receipts are never written by this path: any way of being suspended, deleted or withdrawn (admin +action, sync, a database edit, the consent page) is covered because the work is derived from stored state. The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it, `aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the diff --git a/docs/registration-gates.md b/docs/registration-gates.md index 5564b17..e9896f7 100644 --- a/docs/registration-gates.md +++ b/docs/registration-gates.md @@ -80,10 +80,11 @@ raise the wait or lower the budget; do not add a visual or audio puzzle, which e Lives in Bifrost: the account, the receipt and the registration are one transaction, and chat admission is Bifrost's decision (`admission-is-bifrost-membership`). -- Consent is optional. Registration succeeds with or without it; a receipt exists only if the person agreed. See - `training-notice.md` for why and for what the notice says. +- Consent is optional at registration. Registration succeeds with or without it; a receipt exists only if the person + agreed. It is not optional for chat: the OIDC chat sign-in needs a live receipt for the version in force + (`oidc-issuer.md`, Who may sign in). See `training-notice.md` for why and for what the notice says. - Notice: `TrainingNotice.Version` (positive integer, no leading zeros, per the version rule in Heimdall's - `answer-collector-consent.md`) and `TrainingNotice.Text`, served at `GET /auth/bifrost/notice`. Version 2 is in force. + `answer-collector-consent.md`) and `TrainingNotice.Text`, served at `GET /auth/bifrost/notice`. Version 1 is in force, and its text is frozen (a test pins its hash). Changing the text is a version bump, never an edit in place. The text states that nothing from Discord is used. - Receipt: `ConsentReceipt` (account, notice version, `AcceptedAtUtc`, `WithdrawnAtUtc`). One live (not withdrawn) receipt per account and version; withdrawn rows stay as history. At registration an `acceptedNoticeVersion` equal to @@ -91,7 +92,8 @@ decision (`admission-is-bifrost-membership`). nothing; absent means no receipt. - Consent page: `GET /auth/bifrost/consent`, for any signed-in account (native, provider, existing). It shows the notice, the account's state (no agreement, agreed to the current version, agreed to an older one, withdrawn) and the buttons - that apply: Withdraw whenever any receipt is live, Agree whenever none is live for the version in force (so an account + that apply (with a `returnUrl` query a refused chat sign-in sends, a local path only, Agree returns the member there): + Withdraw whenever any receipt is live, Agree whenever none is live for the version in force (so an account on an older version sees both). `POST /auth/bifrost/consent/accept` and `POST /auth/bifrost/consent/withdraw` are form posts refused with 403 unless they came from the site itself (the strict `RequestOriginGuard`), before anything is read or written. Agree is idempotent; withdraw stamps `WithdrawnAtUtc` once and deletes nothing; agreeing again after a withdrawal writes diff --git a/docs/training-notice.md b/docs/training-notice.md index 3cc0be8..a4d1287 100644 --- a/docs/training-notice.md +++ b/docs/training-notice.md @@ -1,20 +1,32 @@ -# Training notice, version 2 +# Training notice, version 1 -Eureka proposed a tighter notice and listed five gaps in the receipt flow (PR #55). The steward (Heimdall) ruled on -2026-11-11. The served text is `TrainingNotice.Text` and nothing else: this page records why it says what it says and -what each gap became, and does not repeat the wording. Contract for the receipt and the export is in -`registration-gates.md`. +The served text is `TrainingNotice.Text` and nothing else: this page records why it says what it says and what each gap in +the receipt flow became, and does not repeat the wording. Contract for the receipt and the export is in +`registration-gates.md`. What the receipt gates in chat is in `oidc-issuer.md`. -## What version 2 changed from version 1 +Version 1 is the first notice that will be live under that number, but the number has been used before: main carried a +version 1 with different words from #54 onward, and #58 labelled its drafts version 2. Receipts therefore do not migrate. +**The text is frozen as version 1**: a test pins its hash, so editing the words without bumping `TrainingNotice.Version` +fails a test. A reworded notice is version 2, with its own pinned hash. -| Version 1 | Version 2 | +## Before deploy + +Migration `20261111120000_ClearLegacyConsentReceipts` deletes every row from `ConsentReceipts` when it runs, because +version 1 receipts were given to different words and must not count as agreement to these. Manual emptying is therefore only +a belt-and-braces check: confirm the table is empty after the migrate on any database that ever ran main at or after +`33be966`. Meta/ops must still confirm whether anything was deployed with receipts in it; if so, the people who agreed are +asked again on their next chat sign-in. + +## What the notice says and why + +| The notice says | Why | | --- | --- | -| "Chat messages and activity" | Names what counts: the text of messages with time and room, tied to the account name. Not the email address, sign-in details or anything not posted. | -| "train and evaluate AI models" | "that GameCult builds and runs". Whether a dataset or model may leave GameCult is Meta's call, still open (below); the notice makes no claim either way beyond what it says. | -| "No Discord material is used" | "Nothing that came from Discord is used, in any form." Kept: `gamecult-chat:ruling:discord-taints-training`. Whether any path carries Discord content into a chat room is for the chat steward to confirm (not established here), and any that exists must be excluded from every set for the sentence to stay true. | -| Consent required to register | Optional. See below. | -| "withdraw at any time" with no door | Names the door: the consent page, while signed in. | -| Silent on deletion | Says plainly that withdrawing deletes nothing and what it does and does not undo. | +| What counts: the text of messages with time and room, tied to the account name. Not the email address, sign-in details or anything not posted. | Names the data instead of "messages and activity". | +| "that GameCult builds and runs" | Whether a dataset or model may leave GameCult is Meta's call, still open (below); the notice claims nothing either way. | +| "Nothing that came from Discord is used, in any form." | `gamecult-chat:ruling:discord-taints-training`. Whether any path carries Discord content into a chat room is for the chat steward to confirm (not established here), and any that exists must be excluded from every set for the sentence to stay true. | +| "Agreement is needed to speak in a recorded room. You can have a GameCult account, see the members page and vote without agreeing." | Meta's ruling, 2026-11-11: the chat token needs a live receipt for the version in force; nothing else does. "to speak in a recorded room" is settled wording. | +| "Lines you have already spoken stay: withdrawing deletes nothing." | Withdrawing ends future speaking and future training use; it recalls nothing. The sentence is on the page where the Agree button is (the consent page and the registration form both render the notice beside it), so nobody agrees believing they can unsay a line. | +| Names the door: the consent page, while signed in. | "Withdraw at any time" with no door is no promise. | ## Rulings on the five gaps @@ -29,24 +41,27 @@ what each gap became, and does not repeat the wording. Contract for the receipt (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. 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 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. 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 anything is read or written. 4. **Provider-created and existing accounts have no receipt.** Correct, and now stated as the rule: no receipt, no use. - They can opt in on the consent page like anyone else, which is also the only way an existing member is asked. + Since the 2026-11-11 ruling it is also: no receipt, no chat token. They keep their account, the members page and their + votes, and learn it where it bites: the refused chat sign-in lands them on the consent page (`oidc-issuer.md`, Chat needs a + receipt). Nobody is emailed or prompted elsewhere. 5. **No export exists.** True. Every promise about use is a promise about code not written. The notice must not be treated as operative for real people until the export enforces the contract with a test that a withdrawn or - never-agreed account's rows are absent (`registration-gates.md`, Consent receipt, Consumer). No deploy has put - version 2 in front of anyone. + never-agreed account's rows are absent (`registration-gates.md`, Consent receipt, Consumer). Confirm (Before deploy) that no + deploy has put an earlier version 1 in front of anyone. ## Consent is optional -Registration succeeds with or without agreement. A receipt exists only if the person agreed. The contract already -excluded accounts without a receipt, so optional costs nothing in correctness; it costs training data, and it removes -the objection that agreement obtained as a condition of an account is not a free choice. This is the steward's ruling -on Eureka's recommendation; Meta can overrule it. One consequence: the registration form no longer enforces the notice. +Registration succeeds with or without agreement. A receipt exists only if the person agreed. The account is not +conditioned on agreement, which removes the objection that agreement obtained as a condition of an account is not a free +choice; what agreement buys is speaking in a recorded room, and the form says so beside the checkbox. This is the +steward's ruling on Eureka's recommendation, and Meta's ruling of 2026-11-11 added the chat gate. One consequence: the +registration form does not enforce the notice. ## Still open for Meta @@ -54,11 +69,10 @@ on Eureka's recommendation; Meta can overrule it. One consequence: the registrat messages to answer them is covered. The notice is silent on both. - Deletion of messages on request, who does it, and the contact words. The notice offers none. - Controller and contact, and minimum age. Nothing here addresses either. -- Whether existing members should be asked to opt in beyond the consent page existing. - Counsel's reading of consent under the law that applies to the people registering. Not researched here. ## What remains - A signed-out native account cannot withdraw until sign-in recovery exists. - No training export, so no operative use. -- Wording changes are a version bump with a new text; never edit version 2's text in place after it has been served. +- Wording changes are a version bump with a new text and a new pinned hash in `TrainingNoticeFrozenTests`; never edit a version's text in place; the frozen-hash test fails if you do. diff --git a/src/Bifrost.Web/Data/Migrations/20261111120000_ClearLegacyConsentReceipts.cs b/src/Bifrost.Web/Data/Migrations/20261111120000_ClearLegacyConsentReceipts.cs new file mode 100644 index 0000000..985be6a --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261111120000_ClearLegacyConsentReceipts.cs @@ -0,0 +1,26 @@ +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + // Hand-written like AddMembershipSuspensionReason: data only, so no schema change and the snapshot is untouched. + // Notice v1 was reused for new words, so any receipt written against the earlier v1 text must not count as agreement + // to these. Every row goes; people who agreed are asked again on their next chat sign-in. + [DbContext(typeof(BifrostDbContext))] + [Migration("20261111120000_ClearLegacyConsentReceipts")] + public partial class ClearLegacyConsentReceipts : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DELETE FROM \"ConsentReceipts\";"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // Deleted agreements cannot be restored, and must not be. + } + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 1a90c23..41ae175 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Registration; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authentication; @@ -23,6 +24,7 @@ public static class OidcEndpoints public const string SuspendedDescription = "The account is suspended."; public const string NoIdentityDescription = "The account has no Bifrost identity."; public const string ReservedIdentityDescription = "The account's Bifrost identity is reserved."; + public const string ConsentRequiredDescription = "Agreement to the training notice in force is needed to speak in a recorded room."; private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme; private const string IdentityAuthenticationType = "Bifrost.Oidc"; @@ -34,7 +36,7 @@ public static class OidcEndpoints app.MapMethods(OidcServiceCollectionExtensions.UserInfoPath, ["GET", "POST"], (Delegate)UserInfoAsync).AllowAnonymous(); } - private static async Task AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors) + private static async Task AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors, BifrostDbContext db) { var request = http.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenIddict request could not be retrieved."); @@ -91,19 +93,27 @@ public static class OidcEndpoints _ => [Destinations.AccessToken] }); + // Last, so a refusal that no agreement can cure (suspended, no identity, reserved) is never answered with an invitation to agree. + // Not an OIDC error: the member is sent to the consent page with this request's resume path, and the parked request + // (single use, ten minutes) is still there when they come back, because only an OpenIddict response spends it. + if (!await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted)) + { + return Results.Redirect(ConsentEndpoints.PathFor(http.Request.PathBase + http.Request.Path + http.Request.QueryString)); + } + return Results.SignIn(principal, properties: null, OpenIddictScheme); } private static async Task ExchangeAsync(HttpContext http, BifrostDbContext db) { // Only the authorization code grant is enabled, so OpenIddict has refused every other grant already. - // The code's own principal carries the claims chosen at authorize time; suspension is read again here, - // because the code outlives the authorize decision (an account suspended since gets no tokens). + // The code's own principal carries the claims chosen at authorize time; suspension and agreement are read again here, + // because the code outlives the authorize decision (an account suspended or withdrawn since gets no tokens). var result = await http.AuthenticateAsync(OpenIddictScheme); var subject = result.Principal?.GetClaim(Claims.Subject); - if (!await IsStillChatEligibleAsync(db, subject, http.RequestAborted)) + if (await ChatRefusalAsync(db, subject, http.RequestAborted) is { } refusal) { - return Deny(Errors.InvalidGrant, SuspendedDescription); + return Deny(Errors.InvalidGrant, refusal); } return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme); @@ -117,10 +127,10 @@ public static class OidcEndpoints return Results.Challenge(authenticationSchemes: [OpenIddictScheme]); } - // An access token outlives the authorize decision too: an account suspended since is no longer answered. - if (!await IsStillChatEligibleAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted)) + // An access token outlives the authorize decision too: an account suspended since, or one whose agreement has gone, is no longer answered. + if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted) is { } refusal) { - return Deny(Errors.InsufficientAccess, SuspendedDescription); + return Deny(Errors.InsufficientAccess, refusal); } var claims = new Dictionary { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) }; @@ -135,19 +145,25 @@ public static class OidcEndpoints /// /// The one reading of "this subject may still chat", shared by the token and userinfo endpoints and the logout reconciler: - /// the account exists and its membership is not Suspended. Membership may be absent, pending or Active. Re-read on every call. + /// null when they may, else the description of why not. The account exists, its membership is not Suspended (absent, pending + /// or Active all chat), and it holds a live receipt for the notice version in force. Re-read on every call. /// - internal static async Task IsStillChatEligibleAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + internal static async Task ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) { if (!Guid.TryParse(subject, out var accountId)) { - return false; + return SuspendedDescription; } var account = await db.UserAccounts.AsNoTracking() .Include(x => x.Membership) .SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken); - return account is not null && account.Membership?.Status != MembershipStatus.Suspended; + if (account is null || account.Membership?.Status == MembershipStatus.Suspended) + { + return SuspendedDescription; + } + + return await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription; } /// The account id of the subject when it is an Active member now, else null. The door to the fork desk (cookie surfaces check IsActiveMember); a chat-tier token does not pass it. Re-read on every call. diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs index 63df719..273acd9 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -11,7 +11,7 @@ using static OpenIddict.Abstractions.OpenIddictConstants; namespace Bifrost.Web.Features.Oidc; /// -/// Ends the chat sessions of a subject who is no longer allowed to chat (suspended, or the account gone). Each tick it lists the subjects holding valid +/// Ends the chat sessions of a subject who is no longer allowed to chat (suspended, the account gone, or no live receipt for the training notice in force). Each tick it lists the subjects holding valid /// authorizations of the configured client; for each one the shared chat-eligibility reading refuses, it POSTs one signed /// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The /// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it. @@ -127,7 +127,7 @@ public sealed class OidcLogoutReconciler( { await using var scope = scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - if (await OidcEndpoints.IsStillChatEligibleAsync(db, subject, cancellationToken)) + if (await OidcEndpoints.ChatRefusalAsync(db, subject, cancellationToken) is null) { _backoff.Remove(subject); return; diff --git a/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs b/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs index d4e9d86..6682d14 100644 --- a/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs +++ b/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs @@ -20,7 +20,7 @@ public static class ConsentEndpoints public static void MapConsent(this IEndpointRouteBuilder app) { - app.MapGet(Path, async (ICurrentBifrostActorAccessor actors, BifrostDbContext db, CancellationToken ct) => + app.MapGet(Path, async (HttpContext http, ICurrentBifrostActorAccessor actors, BifrostDbContext db, CancellationToken ct) => { var actor = await actors.GetAsync(ct); if (!actor.IsAuthenticated || actor.UserAccount is null) @@ -28,7 +28,7 @@ public static class ConsentEndpoints return Results.Redirect($"/auth/sign-in?returnUrl={Uri.EscapeDataString(LocalReturnPath.Parse(Path).Value)}"); } - return await PageAsync(db, actor.UserAccount.Id, null, StatusCodes.Status200OK, ct); + return await PageAsync(db, actor.UserAccount.Id, null, StatusCodes.Status200OK, ct, ReturnPathOf(http.Request.Query["returnUrl"])); }).AllowAnonymous(); app.MapPost(Path + "/accept", async ( @@ -53,7 +53,7 @@ public static class ConsentEndpoints var form = await ReadFormAsync(http.Request, ct); if (form is null || form["version"].ToString() != TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture)) { - return await PageAsync(db, actor.UserAccount.Id, $"Send the version in force, {TrainingNotice.Version}, to agree.", StatusCodes.Status400BadRequest, ct); + return await PageAsync(db, actor.UserAccount.Id, $"Send the version in force, {TrainingNotice.Version}, to agree.", StatusCodes.Status400BadRequest, ct, ReturnPathOf(form?["returnUrl"] ?? default)); } var accountId = actor.UserAccount.Id; @@ -77,7 +77,7 @@ public static class ConsentEndpoints } } - return Back(); + return new SeeOther(ReturnPathOf(form["returnUrl"]) ?? Path); }).AllowAnonymous(); app.MapPost(Path + "/withdraw", async ( @@ -133,9 +133,17 @@ public static class ConsentEndpoints private static IResult Refused() => Results.Text("Cross-site consent changes are refused.", statusCode: StatusCodes.Status403Forbidden); + /// Where the consent page sends the member after agreeing: a local path () the refused chat authorize names, else the page itself. + internal static string? ReturnPathOf(Microsoft.Extensions.Primitives.StringValues requested) => + requested.ToString() is { Length: > 0 } text ? LocalReturnPath.Parse(text).Value : null; + + /// The consent page that a refused chat sign-in lands on, carrying the request to resume once the member agrees. + public static string PathFor(string returnPath) => $"{Path}?returnUrl={Uri.EscapeDataString(returnPath)}"; + private static IResult Back() => new SeeOther(Path); - private static Task HasLiveReceiptAsync(BifrostDbContext db, Guid accountId, CancellationToken ct) => + /// The one reading of "may speak": a live receipt for the version in force. A receipt for any other version, or a withdrawn one, does not count. + internal static Task HasLiveReceiptAsync(BifrostDbContext db, Guid accountId, CancellationToken ct) => db.ConsentReceipts.AnyAsync(x => x.UserAccountId == accountId && x.NoticeVersion == TrainingNotice.Version && x.WithdrawnAtUtc == null, ct); private static async Task ReadFormAsync(HttpRequest request, CancellationToken ct) @@ -172,10 +180,10 @@ public static class ConsentEndpoints } } - private static async Task PageAsync(BifrostDbContext db, Guid accountId, string? error, int status, CancellationToken ct) + private static async Task PageAsync(BifrostDbContext db, Guid accountId, string? error, int status, CancellationToken ct, string? returnPath = null) { var receipts = await db.ConsentReceipts.AsNoTracking().Where(x => x.UserAccountId == accountId).ToListAsync(ct); - return new HtmlPage(ConsentPage.Render(ConsentPage.StateOf(receipts), error), status); + return new HtmlPage(ConsentPage.Render(ConsentPage.StateOf(receipts), error, returnPath), status); } private sealed class HtmlPage(string html, int status) : IResult diff --git a/src/Bifrost.Web/Features/Registration/ConsentPage.cs b/src/Bifrost.Web/Features/Registration/ConsentPage.cs index 2e7595c..f34719f 100644 --- a/src/Bifrost.Web/Features/Registration/ConsentPage.cs +++ b/src/Bifrost.Web/Features/Registration/ConsentPage.cs @@ -42,25 +42,26 @@ public static class ConsentPage private static string Iso(DateTimeOffset time) => time.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", System.Globalization.CultureInfo.InvariantCulture); - public static string Render(ConsentState state, string? error) + public static string Render(ConsentState state, string? error, string? returnPath = null) { static string E(string? text) => WebUtility.HtmlEncode(text ?? ""); var version = TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); var (message, anyLive) = state switch { - ConsentState.Current current => ($"You agreed to version {version} on {Iso(current.AgreedAtUtc)}. Your messages may be used as the notice says.", true), - ConsentState.Older older => ($"You agreed to version {older.Version} on {Iso(older.AgreedAtUtc)}; version {version} is in force and your messages are not used until you agree to it.", true), - ConsentState.Withdrawn withdrawn => ($"You withdrew on {Iso(withdrawn.WithdrawnAtUtc)}. Your messages are not used.", false), - _ => ("You have not agreed. Your messages are not used.", false) + ConsentState.Current current => ($"You agreed to version {version} on {Iso(current.AgreedAtUtc)}. You may speak in a recorded room, and your messages may be used as the notice says.", true), + ConsentState.Older older => ($"You agreed to version {older.Version} on {Iso(older.AgreedAtUtc)}; version {version} is in force; you cannot speak in a recorded room and your messages are not used until you agree to it.", true), + ConsentState.Withdrawn withdrawn => ($"You withdrew on {Iso(withdrawn.WithdrawnAtUtc)}. You cannot speak in a recorded room, and your messages are not used. Lines you already spoke stay.", false), + _ => ("You have not agreed. You cannot speak in a recorded room, and your messages are not used.", false) }; var summary = error is null ? "" : $""""""; // Withdraw whenever any live receipt exists (an older one included); Agree whenever none is live for the version in force. + var back = returnPath is null ? "" : $""""""; var withdrawButton = anyLive ? """""" : ""; var agreeButton = state is ConsentState.Current ? "" - : $"""
"""; + : $"""
{back}
"""; var button = agreeButton + withdrawButton; return $$""" @@ -91,11 +92,11 @@ public static class ConsentPage

Training consent

{{summary}}

{{E(message)}}

- {{button}}
Training notice, version {{version}} {{TrainingNotice.Html("notice")}}
+ {{button}} diff --git a/src/Bifrost.Web/Features/Registration/RegistrationPage.cs b/src/Bifrost.Web/Features/Registration/RegistrationPage.cs index 5fcb252..ef0e232 100644 --- a/src/Bifrost.Web/Features/Registration/RegistrationPage.cs +++ b/src/Bifrost.Web/Features/Registration/RegistrationPage.cs @@ -99,7 +99,7 @@ public static class RegistrationPage var link = model.ErrorField switch { RegistrationField.Identity => """""", - RegistrationField.Consent => """""", + RegistrationField.Consent => """""", _ => "" }; summary.Append($""""""); @@ -154,8 +154,8 @@ public static class RegistrationPage
Training notice, version {{notice}} {{TrainingNotice.Html("notice")}} - -

Optional. You can create an account without agreeing; then none of your messages are used. You can agree later on your consent page.

+ +

You can create an account without agreeing. Agreement is needed to speak in a recorded room, and you can give it later on your consent page. Withdrawing ends future speaking there; lines you have already spoken stay.

diff --git a/src/Bifrost.Web/Features/Registration/TrainingNotice.cs b/src/Bifrost.Web/Features/Registration/TrainingNotice.cs index 770919f..a823426 100644 --- a/src/Bifrost.Web/Features/Registration/TrainingNotice.cs +++ b/src/Bifrost.Web/Features/Registration/TrainingNotice.cs @@ -5,21 +5,22 @@ namespace Bifrost.Web.Features.Registration; /// /// The training notice a person may agree to. Changing the wording in substance means a new (a -/// positive integer, no leading zeros), because a receipt records the version the person agreed to; a receipt for an -/// older version no longer counts and the person is asked again on the consent page. +/// positive integer, no leading zeros), because a receipt records the version the person agreed to; a receipt for any +/// other version no longer counts, the chat sign-in is refused, and the person is asked again on the consent page. +/// The text is frozen once served: a test pins its hash per version, so an edit without a version bump fails. /// public static class TrainingNotice { - public const int Version = 2; + public const int Version = 1; public const string Text = - "GameCult chat training notice, version 2\n\n" + + "GameCult chat training notice, version 1\n\n" + "GameCult may use messages you post in GameCult chat to train and evaluate AI models that GameCult builds and runs.\n\n" + "What counts. The text of your messages, with their time and room, tied to your account name. Not your email address, your sign-in details or anything you did not post. Nothing that came from Discord is used, in any form.\n\n" + - "It is your choice. You can use GameCult chat without agreeing. If you do not agree, none of your messages are used.\n\n" + + "It is your choice. Agreement is needed to speak in a recorded room. You can have a GameCult account, see the members page and vote without agreeing. If you do not agree, you cannot speak in a recorded room and none of your messages are used.\n\n" + "If you agree, we record your account, this notice's version number and the time. Nothing else is stored with it.\n\n" + - "If you withdraw, we leave all your messages out of every training set made after that moment. Withdrawing deletes nothing: your messages stay in chat and on our servers, anything already used to train a model stays used, and a dataset already built is not recalled. We keep the record that you agreed and when you withdrew.\n\n" + - "You can agree or withdraw at any time on your consent page while signed in. If the notice changes, its version changes and we ask again; until you agree to the new version, your messages are not used."; + "If you withdraw, you can no longer speak in a recorded room, and we leave all your messages out of every training set made after that moment. Lines you have already spoken stay: withdrawing deletes nothing. Your messages stay in chat and on our servers, anything already used to train a model stays used, and a dataset already built is not recalled. We keep the record that you agreed and when you withdrew.\n\n" + + "You can agree or withdraw at any time on your consent page while signed in. If the notice changes, its version changes and we ask again; until you agree to the new version, you cannot speak in a recorded room and your messages are not used."; /// The text as HTML-encoded paragraphs inside a div with the given id. public static string Html(string id) diff --git a/tests/Bifrost.Web.Tests/ConsentTests.cs b/tests/Bifrost.Web.Tests/ConsentTests.cs index ff3a627..e4e088e 100644 --- a/tests/Bifrost.Web.Tests/ConsentTests.cs +++ b/tests/Bifrost.Web.Tests/ConsentTests.cs @@ -135,6 +135,39 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur Assert.Contains($"name=\"version\" value=\"{TrainingNotice.Version}\"", html); } + [Fact] + public async Task The_page_states_what_agreement_is_for_before_the_agree_button() + { + var identity = NewIdentity(); + Seed(identity); + var html = await PageHtmlAsync(identity); + + var notice = html.IndexOf("to speak in a recorded room", StringComparison.Ordinal); + Assert.True(notice >= 0 && html.Contains("Lines you have already spoken stay"), "the notice says what agreement is for and that spoken lines stay"); + Assert.True(notice < html.IndexOf("action=\"/auth/bifrost/consent/accept\"", StringComparison.Ordinal), "the notice comes before the button that agrees to it"); + } + + [Fact] + public async Task Agreeing_from_a_refused_chat_sign_in_returns_to_the_request_and_a_foreign_path_does_not_survive() + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/bifrost/consent?returnUrl=" + Uri.EscapeDataString("/connect/authorize?request_uri=urn%3Aabc")); + Sign(request, identity); + using var page = await client.SendAsync(request); + Assert.Contains("name=\"returnUrl\" value=\"/connect/authorize?request_uri=urn%3Aabc\"", await page.Content.ReadAsStringAsync()); + + using var agreed = await PostAsync(client, "accept", identity, new Dictionary { ["version"] = TrainingNotice.Version.ToString(), ["returnUrl"] = "/connect/authorize?request_uri=urn%3Aabc" }); + Assert.Equal(HttpStatusCode.SeeOther, agreed.StatusCode); + Assert.Equal("/connect/authorize?request_uri=urn%3Aabc", agreed.Headers.Location!.OriginalString); + + using var foreign = await PostAsync(client, "accept", identity, new Dictionary { ["version"] = TrainingNotice.Version.ToString(), ["returnUrl"] = "//evil.example/x" }); + Assert.Equal(HttpStatusCode.SeeOther, foreign.StatusCode); + Assert.Equal("/App", foreign.Headers.Location!.OriginalString); + } + [Fact] public async Task Agreement_to_the_current_version_shows_its_time_and_only_the_withdraw_button() { @@ -151,10 +184,10 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur public async Task Agreement_to_an_older_version_says_it_no_longer_counts_and_offers_both_agree_and_withdraw() { var identity = NewIdentity(); - Seed(identity, (1, Agreed, null)); + Seed(identity, (TrainingNotice.Version - 1, Agreed, null)); var html = await PageHtmlAsync(identity); - Assert.Contains($"You agreed to version 1 on 2026-11-01T12:30:05Z; version {TrainingNotice.Version} is in force and your messages are not used until you agree to it", html); + Assert.Contains($"You agreed to version {TrainingNotice.Version - 1} on 2026-11-01T12:30:05Z; version {TrainingNotice.Version} is in force; you cannot speak in a recorded room and your messages are not used until you agree to it", html); Assert.Contains("action=\"/auth/bifrost/consent/accept\"", html); Assert.Contains("action=\"/auth/bifrost/consent/withdraw\"", html); } @@ -163,7 +196,7 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur public async Task A_live_receipt_for_the_version_in_force_outranks_an_older_live_one() { var identity = NewIdentity(); - Seed(identity, (1, Agreed, null), (TrainingNotice.Version, Agreed.AddDays(1), null)); + Seed(identity, (TrainingNotice.Version - 1, Agreed, null), (TrainingNotice.Version, Agreed.AddDays(1), null)); var html = await PageHtmlAsync(identity); Assert.Contains($"You agreed to version {TrainingNotice.Version} on 2026-11-02T12:30:05Z", html); @@ -225,7 +258,7 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur public async Task Withdraw_stamps_every_live_receipt_including_an_older_version() { var identity = NewIdentity(); - Seed(identity, (1, Agreed, null), (TrainingNotice.Version, Agreed.AddDays(1), null)); + Seed(identity, (TrainingNotice.Version - 1, Agreed, null), (TrainingNotice.Version, Agreed.AddDays(1), null)); using var client = Client(); using var response = await PostAsync(client, "withdraw", identity); @@ -267,17 +300,18 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur public async Task Accept_next_to_an_older_live_receipt_adds_one_for_the_version_in_force() { var identity = NewIdentity(); - Seed(identity, (1, Agreed, null)); + Seed(identity, (TrainingNotice.Version - 1, Agreed, null)); using var client = Client(); using var response = await PostAsync(client, "accept", identity); Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); - Assert.Equal([1, TrainingNotice.Version], Receipts(identity).Select(x => x.NoticeVersion).Order().ToArray()); + Assert.Equal([TrainingNotice.Version - 1, TrainingNotice.Version], Receipts(identity).Select(x => x.NoticeVersion).Order().ToArray()); } [Theory] - [InlineData("1")] + [InlineData("0")] + [InlineData("2")] [InlineData("3")] [InlineData("abc")] [InlineData("")] diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 012b061..6d902cb 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -27,6 +27,7 @@ public sealed class MigrationDiscoveryTests "20261111090000_AddConsentReceipts", "20261111100000_ConsentReceiptsOneLivePerVersion", "20261111110000_AddMembershipSuspensionReason", + "20261111120000_ClearLegacyConsentReceipts", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. diff --git a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs index 2b698f0..2c3b9a5 100644 --- a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -224,6 +224,7 @@ public sealed class OidcIssuerTests : IClassFixture using var factory = new OidcFixedIdentityFactory(ordinary); using var client = factory.CreateClient(ClientOptions()); await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}"); + await factory.AgreeAsync(factory.FixedAccountId); var request = NewRequest(); var final = await DriveAsync(client, request); @@ -825,6 +826,164 @@ public sealed class OidcIssuerTests : IClassFixture Assert.Equal(OidcForeignIssuerFactory.PublicBaseUrl, discovery.GetProperty("issuer").GetString()); } + // ---- chat needs a live receipt for the notice version in force; nothing else does ---- + + private const string ConsentPath = "/auth/bifrost/consent"; + + /// A form post as the consent page's own buttons send it: the site's own Origin, no antiforgery token. + private async Task ConsentPostAsync(HttpClient client, string action, string? returnUrl = null) + { + var fields = new Dictionary { ["version"] = Bifrost.Web.Features.Registration.TrainingNotice.Version.ToString() }; + if (returnUrl is not null) + { + fields["returnUrl"] = returnUrl; + } + + using var message = new HttpRequestMessage(HttpMethod.Post, $"{ConsentPath}/{action}") { Content = new FormUrlEncodedContent(fields) }; + message.Headers.TryAddWithoutValidation("Origin", Origin.GetLeftPart(UriPartial.Authority)); + return await client.SendAsync(message); + } + + [Theory] + [InlineData(ReceiptState.None)] + [InlineData(ReceiptState.Withdrawn)] + [InlineData(ReceiptState.OtherVersion)] + public async Task Chat_authorize_without_a_live_current_receipt_sends_the_member_to_the_consent_page_with_the_request_to_resume(ReceiptState receipt) + { + var (client, _) = await SignedInAsync(MembershipStatus.Active, "No Agreement", receipt); + var request = NewRequest(); + + var final = await DriveAsync(client, request); + + Assert.Equal(HttpStatusCode.Redirect, final.StatusCode); + var location = final.Headers.Location!.OriginalString; + Assert.DoesNotContain("code=", location); + Assert.DoesNotContain("error=", location); + Assert.StartsWith(ConsentPath + "?returnUrl=", location); + var resume = ReturnUrl(final); + Assert.StartsWith("/connect/authorize?", resume); + Assert.Equal(resume, LocalReturnPath.Parse(resume).Value); // short enough to survive as a local return path + + using var page = await client.GetAsync(location); + var html = await page.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + Assert.Contains("action=\"/auth/bifrost/consent/accept\"", html); + Assert.Contains("to speak in a recorded room", html); + } + + [Fact] + public async Task Agreeing_on_the_consent_page_resumes_the_refused_request_and_chat_works() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Authenticated, "Late Agreer", ReceiptState.None); + var request = NewRequest(); + var refused = await DriveAsync(client, request); + var resume = ReturnUrl(refused); + + using var agreed = await ConsentPostAsync(client, "accept", resume); + Assert.Equal(HttpStatusCode.SeeOther, agreed.StatusCode); + Assert.Equal(resume, agreed.Headers.Location!.OriginalString); + + using var resumed = await client.GetAsync(resume); + var code = AssertRedirectToClient(resumed, request.State)["code"]; + Assert.NotNull(code); + var tokens = await ExchangeAsync(client, request, code!); + using var userinfo = await UserInfoAsync(tokens.AccessToken); + Assert.Equal(HttpStatusCode.OK, userinfo.StatusCode); + Assert.Equal(identity, JsonDocument.Parse(await userinfo.Content.ReadAsStringAsync()).RootElement.GetProperty("preferred_username").GetString()); + } + + [Theory] + [InlineData(ReceiptState.Withdrawn)] + [InlineData(ReceiptState.OtherVersion)] + public async Task A_code_and_a_token_issued_before_the_agreement_went_stop_working(ReceiptState gone) + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Silent"); + var pendingRequest = NewRequest(); + var pendingCode = await CodeAsync(client, pendingRequest); + var tokens = await CompleteFlowAsync(client); + using (var before = await UserInfoAsync(tokens.AccessToken)) + { + Assert.Equal(HttpStatusCode.OK, before.StatusCode); + } + + await _factory.SetReceiptAsync(identity, gone); + + using var code = await TokenAsync(client, pendingRequest, pendingCode); + var codeBody = await code.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, code.StatusCode); + Assert.Contains("invalid_grant", codeBody); + Assert.DoesNotContain("access_token", codeBody); + + using var after = await UserInfoAsync(tokens.AccessToken); + var afterBody = await after.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.Forbidden, after.StatusCode); + Assert.Contains("insufficient_access", string.Join(" ", after.Headers.WwwAuthenticate.Select(x => x.ToString()))); + Assert.DoesNotContain(identity, afterBody); + Assert.DoesNotContain("preferred_username", afterBody); + } + + [Fact] + public async Task Withdrawing_on_the_consent_page_ends_chat_and_agreeing_again_restores_it() + { + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Changes Mind"); + var tokens = await CompleteFlowAsync(client); + + using (var withdrawn = await ConsentPostAsync(client, "withdraw")) + { + Assert.Equal(HttpStatusCode.SeeOther, withdrawn.StatusCode); + } + + using (var silenced = await UserInfoAsync(tokens.AccessToken)) + { + Assert.Equal(HttpStatusCode.Forbidden, silenced.StatusCode); + } + + var request = NewRequest(); + Assert.StartsWith(ConsentPath, (await DriveAsync(client, request)).Headers.Location!.OriginalString); + + using (var again = await ConsentPostAsync(client, "accept")) + { + Assert.Equal(HttpStatusCode.SeeOther, again.StatusCode); + } + + var fresh = await CompleteFlowAsync(client); + using var restored = await UserInfoAsync(fresh.AccessToken); + Assert.Equal(HttpStatusCode.OK, restored.StatusCode); + } + + [Fact] + public async Task Without_a_receipt_account_sign_in_the_members_page_and_votes_still_work() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Member Only", ReceiptState.None); + var accountId = _factory.AccountId(identity); + + foreach (var path in new[] { "/App", "/App/Members", ConsentPath }) + { + using var page = await client.GetAsync(path); + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + } + + var collection = "c" + Guid.NewGuid().ToString("N"); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject { Collection = collection, Key = "alpha", Title = "alpha", RegisteredAtUtc = DateTimeOffset.UtcNow }); + await db.SaveChangesAsync(); + } + + using var vote = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler=Up") { Content = new FormUrlEncodedContent([]) }; + vote.Headers.TryAddWithoutValidation("Origin", Origin.GetLeftPart(UriPartial.Authority)); + using var voted = await client.SendAsync(vote); + Assert.Equal(HttpStatusCode.Redirect, voted.StatusCode); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Single(db.ConceptAppraisals.AsNoTracking().Where(x => x.Subject!.Collection == collection && x.UserAccountId == accountId)); + } + + Assert.Empty(_factory.ReceiptsOf(identity)); // none of that wrote an agreement + } + // ---- helpers ---- private static WebApplicationFactoryClientOptions ClientOptions() => new() @@ -836,17 +995,22 @@ public sealed class OidcIssuerTests : IClassFixture private HttpClient NewClient() => _factory.CreateClient(ClientOptions()); - private static async Task RegisterAsync(HttpClient client, string identity) + private static async Task RegisterAsync(HttpClient client, string identity, bool agree = true) { - using var response = await RegistrationKit.RegisterAsync(client, identity); + using var response = await RegistrationKit.RegisterAsync(client, identity, agree: agree); Assert.Equal(HttpStatusCode.OK, response.StatusCode); return identity.ToLowerInvariant(); } - private async Task<(HttpClient Client, string Identity)> SignedInAsync(MembershipStatus status, string displayName) + private async Task<(HttpClient Client, string Identity)> SignedInAsync(MembershipStatus status, string displayName, ReceiptState receipt = ReceiptState.Current) { var client = NewClient(); - var identity = await RegisterAsync(client, $"member-{Guid.NewGuid():N}"); + var identity = await RegisterAsync(client, $"member-{Guid.NewGuid():N}", agree: receipt != ReceiptState.None); + if (receipt is ReceiptState.Withdrawn or ReceiptState.OtherVersion) + { + await _factory.SetReceiptAsync(identity, receipt); + } + await _factory.SetMembershipAsync(identity, status); await _factory.SetDisplayNameAsync(identity, displayName); return (client, identity); diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index c167fc2..c2e5b0f 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -76,6 +76,25 @@ public sealed class OidcLogoutTests Assert.Single(factory.Mas.Bodies); } + [Theory] + [InlineData(ReceiptState.Withdrawn)] + [InlineData(ReceiptState.OtherVersion)] + [InlineData(ReceiptState.None)] + public async Task An_account_whose_agreement_has_gone_is_logged_out_while_one_that_agreed_is_left_alone(ReceiptState gone) + { + using var factory = new OidcLogoutFactory(); + var (silenced, _) = await factory.GrantAsync(MembershipStatus.Active); + var (agreed, _) = await factory.GrantAsync(MembershipStatus.Active); + await factory.SetReceiptAsync(silenced, gone); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = new JsonWebToken(Assert.Single(factory.Mas.LogoutTokens())); + Assert.Equal(factory.AccountId(silenced).ToString("D"), token.Subject); + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(silenced).ToString("D"))); + Assert.True(factory.ValidAuthorizations(factory.AccountId(agreed).ToString("D")) > 0); + } + [Fact] public async Task Two_logouts_for_one_subject_carry_different_jti() { diff --git a/tests/Bifrost.Web.Tests/RegistrationFormTests.cs b/tests/Bifrost.Web.Tests/RegistrationFormTests.cs index 260e865..9f49010 100644 --- a/tests/Bifrost.Web.Tests/RegistrationFormTests.cs +++ b/tests/Bifrost.Web.Tests/RegistrationFormTests.cs @@ -174,7 +174,8 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl Assert.Contains("", html); Assert.Matches("]*/>", html); Assert.DoesNotMatch("]*\\bchecked\\b", html); - Assert.Contains("I agree to this notice (optional)", html); + Assert.Contains("I agree to this notice, to speak in a recorded room", html); + Assert.Contains("Withdrawing ends future speaking there; lines you have already spoken stay.", html); Assert.DoesNotMatch("]*\\brequired\\b", html); foreach (var id in new[] { "identity", "displayName", "consent" }) { @@ -283,7 +284,7 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl var identity = NewIdentity(); var before = Counts(); var fields = FormKit.Fields(html, identity, nonce: FormKit.Solved(html)); - fields["acceptedNoticeVersion"] = "1"; + fields["acceptedNoticeVersion"] = (TrainingNotice.Version + 1).ToString(); using var response = await FormKit.PostAsync(client, fields); var again = await response.Content.ReadAsStringAsync(); diff --git a/tests/Bifrost.Web.Tests/RegistrationGateTests.cs b/tests/Bifrost.Web.Tests/RegistrationGateTests.cs index 49d2e78..2356f60 100644 --- a/tests/Bifrost.Web.Tests/RegistrationGateTests.cs +++ b/tests/Bifrost.Web.Tests/RegistrationGateTests.cs @@ -84,7 +84,7 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl [Theory] [InlineData(0)] [InlineData(-1)] - [InlineData(1)] + [InlineData(2)] [InlineData(3)] public async Task A_wrong_notice_version_is_refused_and_creates_nothing(int version) { @@ -234,7 +234,6 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl var db = scope.ServiceProvider.GetRequiredService(); var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); var receipt = db.ConsentReceipts.Single(x => x.UserAccountId == account.Id); - Assert.Equal(2, receipt.NoticeVersion); Assert.Equal(TrainingNotice.Version, receipt.NoticeVersion); Assert.Null(receipt.WithdrawnAtUtc); Assert.InRange((factory.Time.GetUtcNow() - receipt.AcceptedAtUtc).Duration(), TimeSpan.Zero, TimeSpan.FromSeconds(5)); @@ -247,7 +246,7 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl var notice = await client.GetFromJsonAsync("/auth/bifrost/notice"); - Assert.Equal(2, notice.GetProperty("version").GetInt32()); + Assert.Equal(TrainingNotice.Version, notice.GetProperty("version").GetInt32()); Assert.Contains("Discord", notice.GetProperty("text").GetString()); } diff --git a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs index a9b304f..2a6aed1 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs @@ -16,6 +16,15 @@ using Microsoft.Extensions.DependencyInjection.Extensions; namespace Bifrost.Web.Tests.Support; +/// The consent receipt an account holds, from a consumer's side: current, none, withdrawn, or for a version other than the one in force. +public enum ReceiptState +{ + Current, + None, + Withdrawn, + OtherVersion +} + /// What is wrong with the signing key file a test host is started with. public enum OidcKeyFault { @@ -225,6 +234,48 @@ public class OidcWebApplicationFactory : TestWebApplicationFactory await db.SaveChangesAsync(); } + /// Leaves the account with exactly the receipt state a test names: none, or one receipt for the version in force, a withdrawn one, or one for another version. + public async Task SetReceiptAsync(string identity, ReceiptState state) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id; + db.ConsentReceipts.RemoveRange(db.ConsentReceipts.Where(x => x.UserAccountId == id)); + await db.SaveChangesAsync(); + await AddReceiptAsync(db, id, state); + } + + public List ReceiptsOf(string identity) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return db.ConsentReceipts.AsNoTracking().Where(x => x.UserAccount!.NormalizedBifrostIdentity == identity).ToList(); + } + + /// A receipt for an account that exists only as an actor fixture. + public async Task AgreeAsync(Guid accountId) + { + using var scope = Services.CreateScope(); + await AddReceiptAsync(scope.ServiceProvider.GetRequiredService(), accountId, ReceiptState.Current); + } + + private static async Task AddReceiptAsync(BifrostDbContext db, Guid accountId, ReceiptState state) + { + if (state != ReceiptState.None) + { + var now = DateTimeOffset.UtcNow; + db.ConsentReceipts.Add(new ConsentReceipt + { + UserAccountId = accountId, + // Another version stands for a notice that has since been bumped (or, in a future bump, the one before it). + NoticeVersion = state == ReceiptState.OtherVersion ? Bifrost.Web.Features.Registration.TrainingNotice.Version - 1 : Bifrost.Web.Features.Registration.TrainingNotice.Version, + AcceptedAtUtc = now, + WithdrawnAtUtc = state == ReceiptState.Withdrawn ? now : null + }); + await db.SaveChangesAsync(); + } + } + public Guid AccountId(string identity) { using var scope = Services.CreateScope(); @@ -264,18 +315,20 @@ public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory ///
public sealed class OidcFixedIdentityFactory(string identity, MembershipStatus status = MembershipStatus.Active) : OidcWebApplicationFactory { + public Guid FixedAccountId { get; } = Guid.NewGuid(); + protected override void ConfigureActors(IServiceCollection services) { services.RemoveAll(); - services.AddScoped(_ => new FixedIdentity(identity, status)); + services.AddScoped(_ => new FixedIdentity(FixedAccountId, identity, status)); } - private sealed class FixedIdentity(string identity, MembershipStatus status) : ICurrentBifrostActorAccessor + private sealed class FixedIdentity(Guid id, string identity, MembershipStatus status) : ICurrentBifrostActorAccessor { public Task GetAsync(CancellationToken cancellationToken) => Task.FromResult(new CurrentBifrostActor(true, new UserAccount { - Id = Guid.NewGuid(), + Id = id, BifrostIdentity = identity, NormalizedBifrostIdentity = identity, // as given: the guard, not the fixture, must fold case DisplayName = "Fixed Identity", diff --git a/tests/Bifrost.Web.Tests/Support/RegistrationKit.cs b/tests/Bifrost.Web.Tests/Support/RegistrationKit.cs index 7f15ff3..72654cb 100644 --- a/tests/Bifrost.Web.Tests/Support/RegistrationKit.cs +++ b/tests/Bifrost.Web.Tests/Support/RegistrationKit.cs @@ -33,7 +33,8 @@ public static class RegistrationKit string identity, string? displayName = null, string? returnUrl = null, - bool anonymous = false) + bool anonymous = false, + bool agree = true) { var (challenge, nonce) = await SolveAsync(client); return await PostAsync(client, new @@ -43,7 +44,7 @@ public static class RegistrationKit returnUrl, challenge, nonce, - acceptedNoticeVersion = TrainingNotice.Version + acceptedNoticeVersion = agree ? TrainingNotice.Version : (int?)null }, anonymous); } diff --git a/tests/Bifrost.Web.Tests/TrainingNoticeFrozenTests.cs b/tests/Bifrost.Web.Tests/TrainingNoticeFrozenTests.cs new file mode 100644 index 0000000..c829bd6 --- /dev/null +++ b/tests/Bifrost.Web.Tests/TrainingNoticeFrozenTests.cs @@ -0,0 +1,44 @@ +using System.Security.Cryptography; +using System.Text; +using Bifrost.Web.Features.Registration; + +namespace Bifrost.Web.Tests; + +/// +/// A receipt records agreement to specific words, so served words never change under a version: text change requires a +/// version bump. When the notice is reworded, bump and ADD a line here; never edit one. +/// +public sealed class TrainingNoticeFrozenTests +{ + private static readonly Dictionary Frozen = new() + { + [1] = "9ba9be747a7387d70f2f3ee75fd7460a2e6cbe40a8c7e80aae7d7efd9b33a314" + }; + + [Fact] + public void The_text_of_the_version_in_force_is_the_text_that_was_frozen_for_it() + { + Assert.True(Frozen.TryGetValue(TrainingNotice.Version, out var pinned), $"Version {TrainingNotice.Version} has no frozen hash: add one when you bump the version."); + var actual = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(TrainingNotice.Text))).ToLowerInvariant(); + Assert.True(pinned == actual, $"The notice text changed but the version is still {TrainingNotice.Version}. Bump TrainingNotice.Version and add its hash ({actual}) here; do not edit the old line."); + } + + [Fact] + public void The_notice_names_what_agreement_is_for_and_what_withdrawal_leaves_standing() + { + Assert.Contains("to speak in a recorded room", TrainingNotice.Text); + Assert.Contains("Lines you have already spoken stay", TrainingNotice.Text); + Assert.StartsWith($"GameCult chat training notice, version {TrainingNotice.Version}\n", TrainingNotice.Text); + } + + [Fact] + public void The_consent_page_status_lines_keep_their_exact_words() + { + var at = new DateTimeOffset(2026, 11, 1, 12, 30, 5, TimeSpan.Zero); + var v = TrainingNotice.Version; + Assert.Contains($"You agreed to version {v} on 2026-11-01T12:30:05Z. You may speak in a recorded room, and your messages may be used as the notice says.", ConsentPage.Render(new ConsentState.Current(at), null)); + Assert.Contains($"You agreed to version 0 on 2026-11-01T12:30:05Z; version {v} is in force; you cannot speak in a recorded room and your messages are not used until you agree to it.", ConsentPage.Render(new ConsentState.Older(0, at), null)); + Assert.Contains("You withdrew on 2026-11-01T12:30:05Z. You cannot speak in a recorded room, and your messages are not used. Lines you already spoke stay.", ConsentPage.Render(new ConsentState.Withdrawn(at), null)); + Assert.Contains("You have not agreed. You cannot speak in a recorded room, and your messages are not used.", ConsentPage.Render(new ConsentState.None(), null)); + } +} From dc35c4a2542e868df6d5c06f2166732ac6be37ac Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 16:06:28 +0000 Subject: [PATCH 007/102] Mouth: chat_pending.v4 reply_to (flag, default off) (#63) --- docs/bridge.md | 16 ++- ...lt.persona.chat_pending.v4.no-reply-to.hex | 1 + ...ersona.chat_pending.v4.no-reply-to.msgpack | Bin 0 -> 197 bytes ...sona.chat_pending.v4.reply-to-redacted.hex | 1 + ....chat_pending.v4.reply-to-redacted.msgpack | Bin 0 -> 222 bytes ...ecult.persona.chat_pending.v4.reply-to.hex | 1 + ...t.persona.chat_pending.v4.reply-to.msgpack | Bin 0 -> 252 bytes ...mecult.persona.chat_pending_request.v3.hex | 1 + ...lt.persona.chat_pending_request.v3.msgpack | 1 + tests/persona-matrix-mouth-runtime.test.mjs | 108 ++++++++++++++ tests/persona-matrix-mouth.test.mjs | 133 +++++++++++++++++- tools/persona-chat-documents.mjs | 38 ++++- tools/persona-matrix-mouth.mjs | 84 ++++++++--- 13 files changed, 363 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.no-reply-to.hex create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.no-reply-to.msgpack create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-redacted.hex create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-redacted.msgpack create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to.hex create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to.msgpack create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending_request.v3.hex create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending_request.v3.msgpack diff --git a/docs/bridge.md b/docs/bridge.md index e899b6c..a68d186 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -206,7 +206,7 @@ A client asks for the facts by the kind it polls with: `gamecult.persona.chat_pe | Field | Type | Meaning | |---|---|---| -| 12 `basis` | `"direct"`, `"tag"`, `"reply"`, `"room"` or nil | Why the message is for this Persona, by what Matrix says, first that holds: `direct` the room is one of her DMs; `tag` `m.mentions.user_ids` lists her id, or the client sent no `m.mentions` at all and an `` in the HTML body is exactly her `matrix.to` link; `reply` it replies to, or is in a thread under, an event her account wrote (her Self run speaks as her account, so its words count; any member's message in a thread under one of her events is a `reply` too, and the target is read once per room and event, a failure for a minute); `room` an `@room` mention. A `notice` or an `edit` costs no reply lookup. Known limit: lookups run one at a time inside the `/sync` round (10 s each, at most two per reply), so a slow homeserver slows the round; they are not parallelised or capped per round. | +| 12 `basis` | `"direct"`, `"tag"`, `"reply"`, `"room"` or nil | Why the message is for this Persona, by what Matrix says, first that holds: `direct` the room is one of her DMs; `tag` `m.mentions.user_ids` lists her id, or the client sent no `m.mentions` at all and an `` in the HTML body is exactly her `matrix.to` link; `reply` it replies to, or is in a thread under, an event her account wrote (her Self run speaks as her account, so its words count; any member's message in a thread under one of her events is a `reply` too, and the target is read once per room and event and kept for a minute, hit or failure); `room` an `@room` mention. A `notice` or an `edit` costs no reply lookup. Known limit: lookups run one at a time inside the `/sync` round (10 s each, at most two per reply), so a slow homeserver slows the round; they are not parallelised or capped per round. | | 13 `form` | `"message"`, `"notice"` or `"edit"` | `m.notice` is an automated line; `m.replace` is an edit of an earlier message. Only a `message` can address anyone. | What the mouth cannot know, and so what a nil `basis` means: **unproven, not "not for her"**. Her handle and public name in the text (any casing, with or without `@`, `'s`), the names her stewardships list, a project's repo name, and whether the sender is the operator or a Mind are all matched by Huginn, not here. A reply whose target the homeserver will not give is nil and logged. The mouth also cannot see whether a message was meant for her without naming her. Every delivery is still delivered, claimed tags included: nothing is dropped in the mouth, so the Huginn rule cannot lose a message the mouth had no way to judge. @@ -253,6 +253,20 @@ A line a delivery already shows and a room she cannot speak in change nothing he Golden bytes: `tests/fixtures/persona-chat-v5/` (`chat_pending.v3.tail` carries two lines and no gap, `chat_pending.v3.tail-gaps` one line and two gap rooms; the earlier v3 samples gain an empty fourth element); Huginn pins the same bytes. +### Reply link (`chat_pending.v4`) + +A reply to anyone but the Persona arrives bare in v3: `basis` is `reply` only when the quoted author is her account. v4 adds what the message quotes. **It is off unless the mouth runs with `CHAT_PENDING_V4=1`** (exactly `1`; default off), because Huginn pins the tuple bytes and must read v4 first. With the flag off, `gamecult.persona.chat_pending_request.v3` is not a served kind: it is refused as any unknown kind is (`chat_refusal` `UnknownKind`, logged), byte for byte, and v1/v2/v3 requests are answered as before. With it on, that request kind (the v1 tuple `[persona, after_seq]` again; the kind asked by names the answer) is answered with `gamecult.persona.chat_pending.v4`. + +The answer is `[persona, deliveries, tail, tail_gaps]` exactly as v3; each delivery is the 14 v3 fields, then field 14: + +| Field | Type | Meaning | +|---|---|---| +| 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) and bidi marks, overrides and isolates (U+200E/F, U+202A-202E, U+2066-2069) 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, and the excerpt is untrusted text for a prompt, never 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 for 60 seconds, hit or failure, so a redaction or edit of the target shows at a re-read within a minute and a cached excerpt never outlives that), so a reply costs at most one read per target, shared with the `basis` check. 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. + Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority. diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.no-reply-to.hex b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.no-reply-to.hex new file mode 100644 index 0000000..a0d45dd --- /dev/null +++ b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.no-reply-to.hex @@ -0,0 +1 @@ +94a6657572656b61919f07b6236f70657261746f723a67616d6563756c742e6f7267b1216162633a67616d6563756c742e6f7267a3246531b2406d6574613a67616d6563756c742e6f7267a44d657461ac68656c6c6f20657572656b61cf0000019a2d80b1009193b440657572656b613a67616d6563756c742e6f7267a76561726c696572cf0000019a2d80ad1892b440657572656b613a67616d6563756c742e6f7267b2406e6f76613a67616d6563756c742e6f7267c2c0c0a76d657373616765c09090 diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.no-reply-to.msgpack b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.no-reply-to.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..169db8d7c44ff9d133d67de627aa5c2052960152 GIT binary patch literal 197 zcmbQTEVZ;KH9K+QeD-b1`30#(i6!|(R_TeksmY}|C3^Wq=^GUjlaf(H7OSKhZgR*? zElETXUg8TBS(A~PlasFiG4wnG1LG{+hK&ppCvS0p2%+d$o|;&clbKosRkv1R5>{oK a9P;wZQ0+Q&;K1_S)Z*gA^wa|rCIA4vQBctU literal 0 HcmV?d00001 diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-redacted.hex b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-redacted.hex new file mode 100644 index 0000000..0b2da2c --- /dev/null +++ b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-redacted.hex @@ -0,0 +1 @@ +94a6657572656b61919f07b6236f70657261746f723a67616d6563756c742e6f7267b1216162633a67616d6563756c742e6f7267a3246531b2406d6574613a67616d6563756c742e6f7267a44d657461ac68656c6c6f20657572656b61cf0000019a2d80b1009193b440657572656b613a67616d6563756c742e6f7267a76561726c696572cf0000019a2d80ad1892b440657572656b613a67616d6563756c742e6f7267b2406e6f76613a67616d6563756c742e6f7267c2c0c0a76d65737361676593a3247131b340616c6963653a67616d6563756c742e6f7267c09090 diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-redacted.msgpack b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-redacted.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..dc3f351b7e1021fea29f02387e85da3b89712cc3 GIT binary patch literal 222 zcmbQTEVZ;KH9K+QeD-b1`30#(i6!|(R_TeksmY}|C3^Wq=^GUjlaf(H7OSKhZgR*? zElETXUg8TBS(A~PlasFiG4wnG1LG{+hK&ppCvS0p2%+d$o|;&clbKosRkv1R5>{oK o9P;wZQ0+Q&;K1_S)Z*gA^wi0VRSFF^J0#|0Ca0p9eqh1`02GW`zyJUM literal 0 HcmV?d00001 diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to.hex b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to.hex new file mode 100644 index 0000000..8f6304c --- /dev/null +++ b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to.hex @@ -0,0 +1 @@ +94a6657572656b61919f07b6236f70657261746f723a67616d6563756c742e6f7267b1216162633a67616d6563756c742e6f7267a3246531b2406d6574613a67616d6563756c742e6f7267a44d657461ac68656c6c6f20657572656b61cf0000019a2d80b1009193b440657572656b613a67616d6563756c742e6f7267a76561726c696572cf0000019a2d80ad1892b440657572656b613a67616d6563756c742e6f7267b2406e6f76613a67616d6563756c742e6f7267c2c0c0a76d65737361676593a3247131b340616c6963653a67616d6563756c742e6f7267be63616e20736f6d656f6e65206c6f6f6b20617420746865206275696c643f9090 diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to.msgpack b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..ef6ed113eb9188ccc3d89bc46263b0cb7795bc1a GIT binary patch literal 252 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#|0Ca0p9zArg3PoX$JH#I*mRUs!oKU*QO SM4==jRUxS~GbhD `${[...bytes].map(b => b.toString(16).padStart(2, "0")).join("")}\n`; +for (const [name, { tuple, parse }] of Object.entries(v6Samples)) { + test(`golden fixture: ${name} is byte-stable (msgpack and hex) and parses back`, async () => { + const bytes = Buffer.from(msgpack.encode(tuple)); + if (process.env.UPDATE_FIXTURES === "1") { await mkdir(v6Dir, { recursive: true }); await writeFile(resolve(v6Dir, `${name}.msgpack`), bytes); await writeFile(resolve(v6Dir, `${name}.hex`), hexOf(bytes)); } + assert.deepEqual(bytes, await readFile(resolve(v6Dir, `${name}.msgpack`)), "wire bytes changed: Huginn decodes these fixtures"); + assert.equal(await readFile(resolve(v6Dir, `${name}.hex`), "utf8"), hexOf(bytes), "the hex dump is the msgpack file"); + assert.deepEqual(parse(msgpack.decode(bytes)), parse(tuple)); + if (name.includes("pending.v4")) { + const decoded = msgpack.decode(bytes); + assert.equal(decoded.length, 4, "persona, deliveries, tail, tail_gaps"); + assert.equal(decoded[1][0].length, 15, "the 14 v3 fields, then reply_to"); + assert.deepEqual(decoded[1][0][14], name.endsWith("no-reply-to") ? null : [quoted.eventId, quoted.sender, name.endsWith("redacted") ? null : quoted.excerpt]); + } + }); +} + +test("v1, v2 and v3 bytes are unchanged by v4: a delivery without reply_to encodes to the v3 bytes plus one nil, and the older tuples never see the field", async () => { + const v3 = msgpack.encode(docs.pendingV3Tuple({ persona: "eureka", deliveries: [v4Delivery] })); + assert.deepEqual(docs.deliveryV3Tuple({ ...v4Delivery, replyTo: quoted }), docs.deliveryV3Tuple(v4Delivery), "reply_to never reaches a v3 tuple"); + assert.deepEqual(docs.deliveryTuple({ ...v4Delivery, replyTo: quoted }), docs.deliveryTuple(v4Delivery), "nor a v2 tuple"); + assert.deepEqual(docs.deliveryV4Tuple(v4Delivery).slice(0, 14), docs.deliveryV3Tuple(v4Delivery)); + assert.equal(msgpack.decode(v3)[1][0].length, 14); + const golden = msgpack.decode(await readFile(resolve(v5Dir, "gamecult.persona.chat_pending.v3.unproven-notice.msgpack"))); + assert.equal(golden[1][0].length, 14, "the pinned v3 fixture stays 14 fields"); +}); + +test("reply_to tuple: an excerpt over 240 code points, an empty id or sender, or a bad shape is refused; the cap counts code points, not bytes", () => { + const ok = n => docs.deliveryV4Tuple({ ...v4Delivery, replyTo: { ...quoted, excerpt: "\u{1F600}".repeat(n) } }); + assert.equal([...ok(240)[14][2]].length, 240, "240 astral characters (960 bytes) fit"); + assert.throws(() => ok(241), /at most 240 code points/); + assert.throws(() => docs.deliveryV4Tuple({ ...v4Delivery, replyTo: { ...quoted, sender: "" } }), /reply_to sender/); + assert.throws(() => docs.parseDeliveryV4([...docs.deliveryV3Tuple(v4Delivery), ["$q"]]), /3-field/); + assert.throws(() => docs.parseDeliveryV4(docs.deliveryV3Tuple(v4Delivery)), /15-field/); +}); + +test("v4 paging: reply_to counts toward each delivery's size, and one oversize or malformed delivery cannot break the page", () => { + const mk = (n, replyTo, body = "ok") => ({ ...v4Delivery, seq: n, eventId: `$e${n}`, text: body, replyTo }); + const logs = []; + const page = pageOf("eureka", [mk(1, quoted), mk(2, { ...quoted, excerpt: "x".repeat(241) }), mk(3, null)], msgpack, l => logs.push(l), true, undefined, true); + assert.deepEqual(page[1].map(t => t[0]), [1, 3], "the delivery whose reply_to will not encode is skipped, logged, and the rest served"); + assert.ok(logs.some(l => l.includes("delivery 2 skipped"))); + const size = msgpack.encode(pageOf("eureka", [mk(1, quoted)], msgpack, () => {}, true, undefined, true)[1][0]).length; + const bare = msgpack.encode(pageOf("eureka", [mk(1, null)], msgpack, () => {}, true, undefined, true)[1][0]).length; + assert.ok(size > bare + quoted.excerpt.length, "the quote is part of the delivery's size"); + const many = Array.from({ length: 400 }, (_, i) => mk(i + 1, { ...quoted, excerpt: "y".repeat(240) }, "x".repeat(3900))); + const paged = pageOf("eureka", many, msgpack, () => {}, true, undefined, true); + assert.ok(msgpack.encode(paged).length <= docs.limits.replyBytes + 1000 && paged[1].length > 1 && paged[1].length < 400, "quotes count toward the 256 KiB budget"); + assert.equal(pageOf("eureka", [mk(1, quoted, "x".repeat(4000))], msgpack, () => {}, true, undefined, true)[1].length, 1, "a single delivery is always served"); + assert.equal(docs.parsePendingV3(pageOf("eureka", [mk(1, quoted)], msgpack, () => {}, true))?.deliveries.length, 1, "asked as v3, the same delivery is served without reply_to"); + assert.equal(pageOf("eureka", [mk(1, quoted)], msgpack, () => {}, true)[1][0].length, 14); +}); + +// Dial a throwaway endpoint, send one request and return the first document answered. +async function askOnce({ replyLink, kind, tuple }) { + const mouth = { pendingAfter: () => [{ ...v4Delivery, replyTo: quoted }], tail: async () => ({ lines: [], gaps: [], rooms: [] }), speakableRooms: () => [] }; + const key = "connection-key-v4"; + const logs = []; + const server = await startChatEndpoint({ mouths: new Map([["eureka", mouth]]), runtime, port: 0, connectionKey: key, runtimeId: "mouth-v4", log: l => logs.push(l), ...(replyLink === undefined ? {} : { replyLink }) }); + const client = dgram.createSocket("udp4"); + await new Promise(ok => client.bind(0, "127.0.0.1", ok)); + const connection = new runtime.cultnet.CultNetRudpSocketTransportConnection({ runtimeId: "huginn-v4", socket: client, mode: "client", remoteHost: "127.0.0.1", remotePort: server.address.port, connectionId: connectionIdFor(key), initialSequence: 1, resendDelayMs: 50, resendPollMs: 10, maxFragmentBytes: 1200 }); + try { + const got = new Promise((ok, fail) => { const t = setTimeout(() => fail(new Error(`no reply; logged ${logs.join(" | ")}`)), 5000); connection.on("frame", v => { clearTimeout(t); ok(runtime.cultnet.parseCultNetMessage(msgpack.decode(v.payload), "cultnet.schema.v0").document); }); }); + connection.connect(); + for (let i = 0; i < 100 && !connection.connected; i++) await new Promise(r => setTimeout(r, 20)); + connection.send("schema", msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(docs.envelope({ kind, recordKey: "eureka", payload: msgpack.encode(tuple), sourceRuntimeId: "huginn-v4", sourceAgentId: "eureka", sourceRole: "persona-organ" }), "cultnet.schema.v0"))); + return { document: await got, logs }; + } finally { server.close(); connection.close(); try { client.close(); } catch { /* closed */ } } +} + +test("v4 flag off (the default): a chat_pending_request.v3 is answered exactly as any unknown kind is, and the v3 kind is unchanged", async () => { + const request = docs.pendingRequestTuple({ persona: "eureka", afterSeq: 0 }); + for (const flag of [undefined, false]) { + const { document, logs } = await askOnce({ replyLink: flag, kind: docs.kinds.pendingRequestV4, tuple: request }); + assert.deepEqual([document.schemaId, docs.parseRefusal(msgpack.decode(document.payload))], [docs.kinds.refusal, { persona: "eureka", refusal: "UnknownKind" }]); + assert.ok(logs.some(l => l.includes("unknown kind: gamecult.persona.chat_pending_request.v3"))); + const same = await askOnce({ replyLink: flag, kind: "gamecult.persona.chat_pending_request.v9", tuple: request }); + assert.deepEqual(Buffer.from(same.document.payload), Buffer.from(document.payload), "byte for byte the answer to a kind that never existed"); + } + const v3 = await askOnce({ replyLink: false, kind: docs.kinds.pendingRequestV3, tuple: request }); + assert.equal(v3.document.schemaId, docs.kinds.pendingV3); + assert.equal(msgpack.decode(v3.document.payload)[1][0].length, 14, "a v3 request never gets reply_to, flag or no"); +}); + +test("v4 flag on: the v3 request kind is answered with chat_pending.v4 over RUDP, reply_to in field 14 of each delivery; v1 and v3 requests are unchanged", async () => { + const request = docs.pendingRequestTuple({ persona: "eureka", afterSeq: 0 }); + const { document } = await askOnce({ replyLink: true, kind: docs.kinds.pendingRequestV4, tuple: request }); + assert.equal(document.schemaId, docs.kinds.pendingV4); + const wire = msgpack.decode(document.payload); + assert.deepEqual(wire[1][0][14], [quoted.eventId, quoted.sender, quoted.excerpt]); + assert.equal(docs.parsePendingV4(wire).deliveries[0].replyTo.sender, "@alice:gamecult.org"); + const v3 = await askOnce({ replyLink: true, kind: docs.kinds.pendingRequestV3, tuple: request }); + assert.equal(msgpack.decode(v3.document.payload)[1][0].length, 14); + const v1 = await askOnce({ replyLink: true, kind: docs.kinds.pendingRequest, tuple: request }); + assert.equal(msgpack.decode(v1.document.payload)[1][0].length, 12); +}); diff --git a/tests/persona-matrix-mouth.test.mjs b/tests/persona-matrix-mouth.test.mjs index b2ed814..49245ff 100644 --- a/tests/persona-matrix-mouth.test.mjs +++ b/tests/persona-matrix-mouth.test.mjs @@ -10,7 +10,7 @@ import { createImageStore, RETAIN_MS } from "../tools/persona-chat-images.mjs"; import { personaStoreRoot } from "./image-bytes.mjs"; import * as docs from "../tools/persona-chat-documents.mjs"; import { gif, png, svg } from "./image-bytes.mjs"; -import { createMouth, pageOf, bodyText, createBrakeReader, guarded, redact, roomsFor, projectRoomsVariable } from "../tools/persona-matrix-mouth.mjs"; +import { createMouth, replyLinkFromEnv, pageOf, bodyText, createBrakeReader, guarded, redact, roomsFor, projectRoomsVariable } from "../tools/persona-matrix-mouth.mjs"; const TOKEN = "syt_SECRET_TOKEN_9f3"; const HS_NAME = "gamecult.test"; @@ -1721,3 +1721,134 @@ test("tail gaps: the read timer is cleared when the read answers, and a mouth th assert.ok([...made].every(t => cleared.has(t)), "no read timer is left running"); } finally { globalThis.setTimeout = realSet; globalThis.clearTimeout = realClear; await hs.close(); } }); + +// ---- Reply link (chat_pending.v4, docs/bridge.md "Reply link"): the quoted event's id, author and excerpt, behind replyLink (default off). +const ALICE_ID = `@alice:${HS_NAME}`; +const quoting = (id, to, extra = {}) => withContent(id, META, { body: "re", "m.relates_to": { "m.in_reply_to": { event_id: to }, ...extra } }); +const target = (id, sender, body, extra = {}) => ({ ...withContent(id, sender, { body }, 1000), room_id: ROOM, ...extra }); +const quotes = made => made.map(d => [d.eventId, d.basis, d.replyTo]); +async function linked(events, preload, replyLink = true) { + const hs = await fakeHomeserver({ preload: { [ROOM]: preload }, sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events } } }) } }); + try { + const mouth = mouthFor(hs, { replyLink }); + await mouth.syncOnce(); + return { made: await mouth.syncOnce(), hs }; + } finally { await hs.close(); } +} + +test("reply link: a reply to anyone carries the quoted id, the author's Matrix id and the text; basis stays reply only for her own words", async () => { + const { made } = await linked( + [quoting("$a", "$hers"), quoting("$b", "$alices"), withContent("$c", META, { body: "plain" })], + [target("$hers", SELF, "my words"), target("$alices", ALICE_ID, "can someone look at the build?")]); + assert.deepEqual(quotes(made), [ + ["$a", "reply", { eventId: "$hers", sender: SELF, excerpt: "my words" }], + ["$b", null, { eventId: "$alices", sender: ALICE_ID, excerpt: "can someone look at the build?" }], + ["$c", null, null], + ]); +}); + +test("reply link: flag off (default) delivers no replyTo and the basis is what it always was", async () => { + const pre = [target("$hers", SELF, "my words"), target("$alices", ALICE_ID, "secret words")]; + const { made } = await linked([quoting("$a", "$hers"), quoting("$b", "$alices")], pre, false); + assert.deepEqual(quotes(made), [["$a", "reply", null], ["$b", null, null]]); +}); + +test("reply link: the excerpt is cut at 240 code points, not bytes, and never splits a character", async () => { + const emoji = "\u{1F600}".repeat(300), ascii = "a".repeat(300), combining = "e�".repeat(200); + const { made } = await linked([quoting("$e", "$t1"), quoting("$a", "$t2"), quoting("$c", "$t3"), quoting("$s", "$t4")], + [target("$t1", ALICE_ID, emoji), target("$t2", ALICE_ID, ascii), target("$t3", ALICE_ID, combining), target("$t4", ALICE_ID, "short")]); + const ex = made.map(d => d.replyTo.excerpt); + assert.deepEqual([...ex[0]].length, 240); + assert.equal(ex[0], "\u{1F600}".repeat(240), "240 astral characters, 960 bytes"); + assert.equal(ex[1], "a".repeat(240)); + assert.equal([...ex[2]].length, 240, "code points: a combining mark counts as one, as documented"); + assert.equal(ex[3], "short"); + assert.ok(ex.every(e => !e.includes("�"))); +}); + +test("reply link: a redacted quoted event keeps its id and author but no text; so does a non-text one", async () => { + const { made } = await linked([quoting("$r", "$red"), quoting("$n", "$nobody"), quoting("$m", "$img")], [ + target("$red", ALICE_ID, "the secret that was redacted", { content: {}, unsigned: { redacted_because: { type: "m.room.redaction" } } }), + target("$nobody", ALICE_ID, "", { content: {} }), + target("$img", ALICE_ID, "x", { type: "m.sticker", content: { body: "private caption" } }), + ]); + assert.deepEqual(made.map(d => d.replyTo), [ + { eventId: "$red", sender: ALICE_ID, excerpt: null }, + { eventId: "$nobody", sender: ALICE_ID, excerpt: null }, + { eventId: "$img", sender: ALICE_ID, excerpt: null }, + ]); + const redactedStillText = await linked([quoting("$r", "$red")], [target("$red", ALICE_ID, "still has a body", { unsigned: { redacted_because: {} } })]); + assert.equal(redactedStillText.made[0].replyTo.excerpt, null, "a redaction marker wins even if a body is present"); +}); + +test("reply link: a quoted event in a room she cannot read, an unreadable one and a thread fallback give no replyTo and no text", async () => { + const { made } = await linked( + [quoting("$other", "$foreign"), quoting("$gone", "$missing"), quoting("$th", "$alices", { rel_type: "m.thread", event_id: "$root", is_falling_back: true })], + [{ ...target("$foreign", ALICE_ID, "from another room"), room_id: "!elsewhere:gamecult.test" }, target("$alices", ALICE_ID, "fallback target")]); + assert.deepEqual(made.map(d => d.replyTo), [null, null, null]); + assert.ok(!JSON.stringify(made.map(d => d.replyTo)).includes("room") && made.every(d => d.replyTo === null), "no id, author or text of the foreign event travels"); +}); + +test("reply link: the same quoted event is read once, and a lookup cache hit from the basis check serves the quote", async () => { + const { made, hs } = await linked([quoting("$a", "$hers"), quoting("$b", "$hers")], [target("$hers", SELF, "mine")]); + assert.deepEqual(made.map(d => d.replyTo?.excerpt), ["mine", "mine"]); + assert.equal(hs.seen.filter(r => r.path.includes("/event/")).length, 1); +}); + +test("reply link: an edited target is quoted without its pre-edit text, whether it is the edit or has one bundled; id and author stay", async () => { + const { made } = await linked([quoting("$a", "$bundled"), quoting("$b", "$edit")], [ + target("$bundled", ALICE_ID, "old wording", { unsigned: { "m.relations": { "m.replace": { event_id: "$x" } } } }), + target("$edit", ALICE_ID, "* new wording", { content: { msgtype: "m.text", body: "* new wording", "m.relates_to": { rel_type: "m.replace", event_id: "$orig" } } }), + ]); + assert.deepEqual(made.map(d => d.replyTo), [{ eventId: "$bundled", sender: ALICE_ID, excerpt: null }, { eventId: "$edit", sender: ALICE_ID, excerpt: null }]); +}); + +test("reply link: a cached target is re-read after 60 s, so a redaction or edit in between shows; inside 60 s the cached read stands", async () => { + const clock = { t: 1_000_000 }; + const mine = target("$t", ALICE_ID, "before"); + const hs = await fakeHomeserver({ preload: { [ROOM]: [mine] }, sync: since => since === null ? { next_batch: "b0" } : { next_batch: `b${since}x`, ...joined({ [ROOM]: { timeline: { events: [quoting(`$q${since}`, "$t")] } } }) } }); + try { + const mouth = mouthFor(hs, { replyLink: true, now: () => clock.t }); + await mouth.syncOnce(); + const first = await mouth.syncOnce(); + mine.content = {}; mine.unsigned = { redacted_because: {} }; + clock.t += 59_000; + const within = await mouth.syncOnce(); + clock.t += 2_000; + const after = await mouth.syncOnce(); + assert.deepEqual([first, within, after].map(m => m[0].replyTo.excerpt), ["before", "before", null]); + } finally { await hs.close(); } +}); + +test("reply link: the target's own reply fallback is dropped before the cap; a body of only fallback has no excerpt", async () => { + const inReply = { "m.in_reply_to": { event_id: "$earlier" } }; + const withFallback = body => ({ content: { msgtype: "m.text", body, "m.relates_to": inReply } }); + const { made } = await linked([quoting("$a", "$f1"), quoting("$b", "$f2"), quoting("$c", "$f3"), quoting("$d", "$f4")], [ + target("$f1", ALICE_ID, "", withFallback("> <@bob:gamecult.test> what was it\n> second line\n\nthe real answer")), + target("$f2", ALICE_ID, "", withFallback("> <@bob:gamecult.test> only a quote\n\n")), + target("$f3", ALICE_ID, "", { content: { msgtype: "m.text", body: "> a literal quote with no reply\n\nkept" } }), + target("$f4", ALICE_ID, "", withFallback(`> <@bob:gamecult.test> ${"q".repeat(500)}\n\n${"w".repeat(300)}`)), + ]); + assert.deepEqual(made.map(d => d.replyTo.excerpt), ["the real answer", null, "> a literal quote with no reply kept", "w".repeat(240)]); +}); + +test("reply link: the excerpt is safe in a prompt: control characters, bidi marks and line breaks are gone before the cap", async () => { + const probe = "a\u0000b\u202ec\r\nd\u001b[31m"; + const { made } = await linked([quoting("$p", "$t1"), quoting("$q", "$t2"), quoting("$r", "$t3")], [ + target("$t1", ALICE_ID, probe), + target("$t2", ALICE_ID, " x\t\t y\n\n\nz\u200f\u2066w\u2069\u0085\u007f ok "), + target("$t3", ALICE_ID, `\u202e${"\u0000".repeat(500)}${"k".repeat(300)}`), + ]); + assert.deepEqual(made.map(d => d.replyTo.excerpt), ["abc d[31m", "x y zw ok", "k".repeat(240)], "controls removed first, so they do not eat the 240"); +}); + +test("reply link: a mouth built without the option has it off, and only the exact env value \"1\" turns it on", async () => { + const hs = await fakeHomeserver({ preload: { [ROOM]: [target("$t", ALICE_ID, "words")] }, sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events: [quoting("$q", "$t")] } } }) } }); + try { + const mouth = createMouth({ persona: "eureka", homeserver: hs.url, rooms: Object.keys(ROOMS), token: TOKEN, state: memoryState(), fetch: hs.fetch, serverName: HS_NAME, brake: async () => true, log: () => {}, syncTimeoutMs: 1 }); + await mouth.syncOnce(); + assert.equal((await mouth.syncOnce())[0].replyTo, null); + } finally { await hs.close(); } + assert.deepEqual(["1", "true", "yes", "0", "", " 1", undefined].map(v => replyLinkFromEnv({ CHAT_PENDING_V4: v })), [true, false, false, false, false, false, false]); + assert.equal(replyLinkFromEnv({}), false); +}); diff --git a/tools/persona-chat-documents.mjs b/tools/persona-chat-documents.mjs index 7dc790d..f6e6210 100644 --- a/tools/persona-chat-documents.mjs +++ b/tools/persona-chat-documents.mjs @@ -11,6 +11,8 @@ export const kinds = Object.freeze({ pending: "gamecult.persona.chat_pending.v2", pendingRequestV3: "gamecult.persona.chat_pending_request.v2", // the same tuple as v1; asking by this kind is asking for the v3 answer pendingV3: "gamecult.persona.chat_pending.v3", + pendingRequestV4: "gamecult.persona.chat_pending_request.v3", // the same tuple again; served only when the mouth's reply-link flag is on, else an unknown kind + pendingV4: "gamecult.persona.chat_pending.v4", ack: "gamecult.persona.chat_ack.v1", read: "gamecult.persona.chat_read.v1", sendRequest: "gamecult.persona.chat_send_request.v1", @@ -39,7 +41,7 @@ const maxContext = 20; // Per-delivery bounds, applied where the mouth reads Matrix: the body is bounded like a send, // a context line and a display name are smaller, and a reply is paged to a byte budget. -export const limits = Object.freeze({ textBytes: maxTextBytes, contextBytes: 1000, displayBytes: 256, mentions: 64, replyBytes: 256 * 1024, tailPerRoom: 20, tailTotal: 60 }); +export const limits = Object.freeze({ textBytes: maxTextBytes, contextBytes: 1000, displayBytes: 256, mentions: 64, replyBytes: 256 * 1024, tailPerRoom: 20, tailTotal: 60, replyExcerpt: 240 }); // `value` cut to at most `maxBytes` of UTF-8 without splitting a character. export function clip(value, maxBytes) { @@ -215,6 +217,28 @@ function form(v) { return v; } +// The v4 delivery: the 14 fields of v3, then reply_to: nil, or [event_id, sender, excerpt] for the event this one replies to. +// sender is the quoted author's Matrix id. excerpt is nil for a quoted event whose text must not travel (redacted, not text); +// otherwise its first replyExcerpt (240) Unicode code points. A quote the mouth could not establish is reply_to nil, never a guess. +export function deliveryV4Tuple(d) { + return [...deliveryV3Tuple(d), replyTo(d.replyTo ?? null)]; +} +export function parseDeliveryV4(t) { + const fields = tuple(t, 15, "delivery_v4"); + return { ...parseDeliveryV3(fields.slice(0, 14)), replyTo: parseReplyTo(fields[14]) }; +} +function replyTo(r) { + if (r === null) return null; + const { eventId, sender, excerpt = null } = r; + if (excerpt !== null && (typeof excerpt !== "string" || [...excerpt].length > limits.replyExcerpt)) throw new Error(`reply_to excerpt must be a string of at most ${limits.replyExcerpt} code points, or nil`); + return [text(eventId, "reply_to event_id"), text(sender, "reply_to sender"), excerpt]; +} +function parseReplyTo(t) { + if (t === null) return null; + const [eventId, sender, excerpt] = tuple(t, 3, "reply_to"); + return { eventId: text(eventId, "reply_to event_id"), sender: text(sender, "reply_to sender"), excerpt: replyTo({ eventId, sender, excerpt })[2] }; +} + export function parseDelivery(t) { const [s, roomAlias, roomId, eventId, sender, senderDisplay, body, atUnixMs, context, mentions, isDirect, image] = tuple(t, 12, "delivery"); if (!Array.isArray(context)) throw new Error("delivery context must be a list"); @@ -263,6 +287,18 @@ export function parsePendingV3(t) { return { persona: text(persona, "persona"), deliveries: deliveries.map(parseDeliveryV3), tail: tail.map(parseTailLine), tailGaps: gaps }; } +// The v4 answer: the v3 answer with v4 deliveries. +export function pendingV4Tuple({ persona, deliveries, tail = [], tailGaps = [] }) { + const [p, , t, g] = pendingV3Tuple({ persona, deliveries: [], tail, tailGaps }); + return [p, deliveries.map(deliveryV4Tuple), t, g]; +} +export function parsePendingV4(t) { + const [persona, deliveries, tail, tailGaps] = tuple(t, 4, "chat_pending_v4"); + if (!Array.isArray(deliveries)) throw new Error("chat_pending deliveries must be a list"); + const rest = parsePendingV3([persona, [], tail, tailGaps]); + return { ...rest, deliveries: deliveries.map(parseDeliveryV4) }; +} + // The CultNet document_put_raw envelope every chat_* document travels in. export function envelope({ kind, recordKey, payload, sourceRuntimeId, sourceAgentId, sourceRole }) { return { diff --git a/tools/persona-matrix-mouth.mjs b/tools/persona-matrix-mouth.mjs index c9767c3..ffc042f 100644 --- a/tools/persona-matrix-mouth.mjs +++ b/tools/persona-matrix-mouth.mjs @@ -122,6 +122,7 @@ export function createMouth({ persona, homeserver, serverName, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake, now = Date.now, log = () => {}, syncTimeoutMs = 30000, maxPending = MAX_PENDING, gate = createRoomGate(), sendTimeoutMs = SEND_TIMEOUT_MS, presenceGraceMs = PRESENCE_GRACE_MS, + replyLink = false, // resolve each reply's quoted event into delivery.replyTo (the v4 answer; one more event read per reply to a non-Mind) images = null, // this Persona's image store (createImageStore), or null: images stay the typed text line isServed = async () => false, // (async) whether a Matrix user id is an account this process speaks for: a Mind is not a person to open a DM with }) { @@ -310,22 +311,62 @@ export function createMouth({ } // A pill is an anchor whose href is exactly her matrix.to link; a longer id, an attribute that merely contains one and a quoted reply do not count. const pillFor = html => typeof html === "string" && [...html.replace(/[\s\S]*?<\/mx-reply>/gi, "").matchAll(/]*?href\s*=\s*(["'])(.*?)\1/gi)].some(([, , href]) => [selfId, encodeURIComponent(selfId)].some(id => href === `https://matrix.to/#/${id}`)); - const authors = new Map(); // `${room id} ${event id}` -> { sender, at }: read results, a failure kept for NEGATIVE_MS, bounded; the key holds the room so another room's reply cannot borrow a hit - const NEGATIVE_MS = 60 * 1000; - async function authorOf(roomId, eventId) { + const authors = new Map(); // `${room id} ${event id}` -> { sender, excerpt, at }: read results, kept TARGET_TTL_MS, bounded; the key holds the room so another room's reply cannot borrow a hit + const TARGET_TTL_MS = 60 * 1000; // how long a read of a target (hit or failure) is kept + // The quoted text, as a prompt-safe line: nil when the event is redacted (the homeserver strips its content), is not text, or is edited + // (an edit target, or one with an m.replace bundled: the original body is the pre-edit text, which its author replaced), or is nothing but + // its own reply fallback. Else: the legacy rich-reply fallback (leading "> " lines and the blank line after) is dropped, CR/LF/tab runs + // become one space, control characters (C0, DEL, C1) and bidi marks/overrides/isolates are removed, the result is trimmed, then cut to the + // first 240 code points (not bytes, so no character is split; a combining mark is its own code point) and trimmed again. Computed + // only when replyLink is on. + const FALLBACK_LINE = /^>( |$)/; + const excerptOf = target => { + const c = target?.content; + if (!replyLink || target?.unsigned?.redacted_because !== undefined || target?.type !== "m.room.message" || typeof c?.body !== "string") return null; + if (c["m.relates_to"]?.rel_type === "m.replace" || target.unsigned?.["m.relations"]?.["m.replace"] !== undefined) return null; + let body = c.body; + if (c["m.relates_to"]?.["m.in_reply_to"] !== undefined) { + const lines = body.split("\n"); + while (lines.length > 0 && FALLBACK_LINE.test(lines[0])) lines.shift(); + if (lines.length > 0 && lines[0] === "") lines.shift(); + body = lines.join("\n"); + } + const line = bodyText({ ...c, body }, docs.limits.contextBytes) + .replace(/[\r\n\t\u2028\u2029]+/g, " ") + .replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "") + .trim(); + const cut = [...line].slice(0, docs.limits.replyExcerpt).join("").trimEnd(); + return cut === "" ? null : cut; + }; + async function targetOf(roomId, eventId) { const key = `${roomId} ${eventId}`; const known = authors.get(key); - if (known && (known.sender !== null || now() - known.at < NEGATIVE_MS)) return known.sender; - let sender = null; + if (known && now() - known.at < TARGET_TTL_MS) return known; // short for hits too: a later redaction or edit of the target must show at a re-read + let sender = null, excerpt = null; try { const target = await matrix("GET", `/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`, undefined, API, AbortSignal.timeout(sendTimeoutMs)); - if (target?.room_id === undefined || target.room_id === roomId) sender = typeof target?.sender === "string" ? target.sender : null; // an id from another room is not an answer about this one + if (target?.room_id === undefined || target.room_id === roomId) { // an id from another room is not an answer about this one: no sender, no text + sender = typeof target?.sender === "string" && target.sender !== "" ? target.sender : null; + if (sender !== null) excerpt = excerptOf(target); + } } catch (error) { say(`reply target ${eventId} not read: ${error.message}`); } - authors.set(key, { sender, at: now() }); + const entry = { sender, excerpt, at: now() }; + authors.set(key, entry); if (authors.size > DELIVERED_INDEX) authors.delete(authors.keys().next().value); - return sender; + return entry; + } + const authorOf = async (roomId, eventId) => (await targetOf(roomId, eventId)).sender; + // The event a message replies to: its own m.in_reply_to, unless that is a thread's fallback pointer (is_falling_back), which + // names no one. nil when the target cannot be read or sits in another room. + async function replyToOf(roomId, event) { + if (!replyLink || formOf(event.content) !== "message") return null; + const r = event.content["m.relates_to"]?.["m.in_reply_to"]; + const id = r?.event_id; + if (typeof id !== "string" || id === "" || event.content["m.relates_to"]["is_falling_back"] === true) return null; + const { sender, excerpt } = await targetOf(roomId, id); + return sender === null ? null : { eventId: id, sender, excerpt }; } async function repliesToMe(roomId, relates) { const targets = new Set([relates?.["m.in_reply_to"]?.event_id, relates?.rel_type === "m.thread" ? relates.event_id : undefined].filter(id => typeof id === "string" && id !== "")); @@ -402,12 +443,13 @@ export function createMouth({ if (refused !== null) say(`image refused: ${refused}`); const mentioned = event.content["m.mentions"]?.user_ids; const basis = await basisOf(roomId, event); + const replyTo = await replyToOf(roomId, event); made.push({ seq: ++seqCounter, roomAlias: alias, roomId, eventId: event.event_id, sender: event.sender, senderDisplay: displayNames.get(event.sender) ?? localpart(event.sender), text: bodyText(event.content, docs.limits.textBytes, refused), atUnixMs, context, image, mentions: Array.isArray(mentioned) ? mentioned.filter(id => typeof id === "string" && id !== "" && id.length <= 255).slice(0, docs.limits.mentions) : [], - isDirect: directRooms.has(roomId), basis, form: formOf(event.content), + isDirect: directRooms.has(roomId), basis, form: formOf(event.content), replyTo, }); if (image) images.hold(image.path, seqCounter); } @@ -730,15 +772,16 @@ export function connectionIdFor(connectionKey) { // The reply to a chat_pending_request: deliveries oldest first, each encoded on its own so one that // will not encode is skipped and logged without silencing the rest, up to the reply byte budget. // The caller acks what it has read and asks again. +// A v4 reply (v4 true, with v3) is the v3 reply with each delivery's reply_to counted in its size, like any field: one oversize delivery is still alone on its page. // A v3 reply also carries `tail` (room lines, oldest first), trimmed oldest first to what the byte budget has left after the deliveries, // and `tail_gaps`: the rooms of `tail.rooms`, in that order, with lines missing from the reply: those `tail.gaps` names plus every room // whose line the byte budget or an unencodable line dropped here. -export function pageOf(persona, deliveries, msgpack, log = () => {}, v3 = false, tail = { lines: [], gaps: [], rooms: [] }) { +export function pageOf(persona, deliveries, msgpack, log = () => {}, v3 = false, tail = { lines: [], gaps: [], rooms: [] }, v4 = false) { const page = []; let bytes = 0; for (const delivery of deliveries) { let tuple, size; - try { tuple = (v3 ? docs.deliveryV3Tuple : docs.deliveryTuple)(delivery); size = msgpack.encode(tuple).length; } catch (error) { + try { tuple = (v4 ? docs.deliveryV4Tuple : v3 ? docs.deliveryV3Tuple : docs.deliveryTuple)(delivery); size = msgpack.encode(tuple).length; } catch (error) { log(`delivery ${delivery?.seq} skipped: ${error?.message ?? error}`); continue; } @@ -769,7 +812,7 @@ export function pageOf(persona, deliveries, msgpack, log = () => {}, v3 = false, // session, and a request is answered on the session it arrived on. Requests arrive as // document_put_raw messages. A request naming a Persona this // process does not serve is answered with a typed UnknownPersona refusal. -export async function startChatEndpoint({ mouths, runtime, port, connectionKey, runtimeId, socketFactory = createSocket, host = "127.0.0.1", log = () => {} }) { +export async function startChatEndpoint({ mouths, runtime, port, connectionKey, runtimeId, socketFactory = createSocket, host = "127.0.0.1", log = () => {}, replyLink = false }) { if (!/^127\.\d+\.\d+\.\d+$/.test(host)) throw new Error("the chat endpoint listens on loopback only"); const { msgpack, cultnet } = runtime; const socket = socketFactory("udp4"); @@ -781,7 +824,7 @@ export async function startChatEndpoint({ mouths, runtime, port, connectionKey, const respond = (peer, persona, kind, recordKey, tuple) => server.send(peer, "schema", msgpack.encode(cultnet.encodeCultNetMessageForWire( docs.envelope({ kind, recordKey, payload: msgpack.encode(tuple), sourceRuntimeId: runtimeId, sourceAgentId: persona, sourceRole: "bifrost-persona-matrix-mouth" }), "cultnet.schema.v0"))); const refuseUnknown = (peer, persona) => respond(peer, persona, docs.kinds.refusal, persona, docs.refusalTuple({ persona, refusal: "UnknownPersona" })); - const served = [docs.kinds.pendingRequest, docs.kinds.pendingRequestV3, docs.kinds.ack, docs.kinds.read, docs.kinds.sendRequest, docs.kinds.sendRequestV2, docs.kinds.openDirectRequest]; + const served = [docs.kinds.pendingRequest, docs.kinds.pendingRequestV3, ...(replyLink ? [docs.kinds.pendingRequestV4] : []), docs.kinds.ack, docs.kinds.read, docs.kinds.sendRequest, docs.kinds.sendRequestV2, docs.kinds.openDirectRequest]; const handle = async ({ peer, channelId, payload }) => { try { if (channelId !== "schema") throw new Error("chat request arrived on a foreign channel"); @@ -798,13 +841,14 @@ export async function startChatEndpoint({ mouths, runtime, port, connectionKey, const mouth = mouths.get(request.persona); const receipt = mouth ? await mouth.openDirect(request) : { requestId: request.requestId, outcome: "UnknownPersona", roomId: null, eventId: null }; respond(peer, request.persona, docs.kinds.openDirectReceipt, receipt.requestId, docs.openDirectReceiptTuple(receipt)); - } else if (document.schemaId === docs.kinds.pendingRequest || document.schemaId === docs.kinds.pendingRequestV3) { - const v3 = document.schemaId === docs.kinds.pendingRequestV3; // the kind asked by names the answer: v1 gets the 12-field v2 answer, as before + } else if (document.schemaId === docs.kinds.pendingRequest || document.schemaId === docs.kinds.pendingRequestV3 || document.schemaId === docs.kinds.pendingRequestV4) { + const v4 = document.schemaId === docs.kinds.pendingRequestV4; // only reachable with replyLink: `served` drops it otherwise + const v3 = v4 || document.schemaId === docs.kinds.pendingRequestV3; // the kind asked by names the answer: v1 gets the 12-field v2 answer, as before const request = docs.parsePendingRequest(tuple); const mouth = mouths.get(request.persona); if (!mouth) return refuseUnknown(peer, request.persona); const tail = v3 ? await mouth.tail().catch(error => { log(`tail not read: ${error?.message ?? error}`); const rooms = mouth.speakableRooms(); return { lines: [], gaps: rooms, rooms }; }) : undefined; - respond(peer, request.persona, v3 ? docs.kinds.pendingV3 : docs.kinds.pending, request.persona, pageOf(request.persona, mouth.pendingAfter(request.afterSeq), msgpack, log, v3, tail)); + respond(peer, request.persona, v4 ? docs.kinds.pendingV4 : v3 ? docs.kinds.pendingV3 : docs.kinds.pending, request.persona, pageOf(request.persona, mouth.pendingAfter(request.afterSeq), msgpack, log, v3, tail, v4)); } else if (document.schemaId === docs.kinds.ack) { const request = docs.parseAck(tuple); const mouth = mouths.get(request.persona); @@ -856,6 +900,9 @@ export function loadRuntime(cultRoot) { return { CultCache: cc.CultCache, SingleFileMessagePackBackingStore: cc.SingleFileMessagePackBackingStore, defineDocumentType: cc.defineDocumentType, readSingleFileStore: cc.readSingleFileStore, cultnet: cn, msgpack: mp }; } +// The v4 flag: on only for exactly "1"; unset, "0", "true", "yes" and anything else are off. +export const replyLinkFromEnv = env => env.CHAT_PENDING_V4 === "1"; + function required(env, name) { if (!env[name]) throw new Error(`${name} is required`); return env[name]; @@ -903,10 +950,11 @@ export async function main(env = process.env) { const controlRoot = env.CONTROL_ROOT ?? "/etc/gamecult/minds"; const imageRoot = env.IMAGE_STORE_ROOT ? resolve(env.IMAGE_STORE_ROOT) : null; // unset: chat images stay the typed text line const gate = createRoomGate(); // one for every Persona: two Minds in one room take turns + const replyLink = replyLinkFromEnv(env); // default off: Huginn pins the v3 tuple bytes until it reads v4 const mouths = new Map(personas.map(persona => { mkdirSync(join(stateRoot, persona), { recursive: true }); return [persona, createMouth({ - persona, homeserver, serverName, rooms: roomsByPersona.get(persona), token: tokens.get(persona), gate, isServed: async id => { await Promise.all([...mouths.values()].map(m => m.start().catch(() => {}))); return [...mouths.values()].some(m => m.userId === id); }, log: line => log(`${persona}: ${line}`), + persona, homeserver, serverName, rooms: roomsByPersona.get(persona), token: tokens.get(persona), gate, replyLink, isServed: async id => { await Promise.all([...mouths.values()].map(m => m.start().catch(() => {}))); return [...mouths.values()].some(m => m.userId === id); }, log: line => log(`${persona}: ${line}`), images: imageRoot ? createImageStore({ root: join(imageRoot, persona), log: line => log(`${persona}: ${line}`) }) : null, state: cultcacheState({ runtime, path: join(stateRoot, persona, "cursor.cc") }), brake: createBrakeReader({ controlPath: join(controlRoot, persona, "control.cc"), readStore: runtime.readSingleFileStore, decode: runtime.msgpack.decode }), @@ -914,7 +962,7 @@ export async function main(env = process.env) { })); const listen = required(env, "CHAT_RUDP_LISTEN"); // host:port, loopback const colon = listen.lastIndexOf(":"); - await startChatEndpoint({ mouths, runtime, host: listen.slice(0, colon), port: Number(listen.slice(colon + 1)), connectionKey: chatKey, runtimeId: "bifrost-persona-mouth", log }); + await startChatEndpoint({ mouths, runtime, host: listen.slice(0, colon), port: Number(listen.slice(colon + 1)), connectionKey: chatKey, runtimeId: "bifrost-persona-mouth", log, replyLink }); await Promise.all([...mouths.values()].map(mouth => mouth.run())); }); } From e12b8e22985ee044ba6df53edf714327de6fc6f1 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 16:17:09 +0000 Subject: [PATCH 008/102] Mouth: v4 fixes after Soul pass (target cache, scrub, read cap) (#64) --- docs/bridge.md | 2 +- tests/persona-matrix-mouth.test.mjs | 106 ++++++++++++++++++++++++++++ tools/persona-matrix-mouth.mjs | 33 ++++++--- 3 files changed, 132 insertions(+), 9 deletions(-) diff --git a/docs/bridge.md b/docs/bridge.md index a68d186..802d667 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) and bidi marks, overrides and isolates (U+200E/F, U+202A-202E, U+2066-2069) 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, and the excerpt is untrusted text for a prompt, never 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 for 60 seconds, hit or failure, so a redaction or edit of the target shows at a re-read within a minute and a cached excerpt never outlives that), so a reply costs at most one read per target, shared with the `basis` check. 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 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. 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/persona-matrix-mouth.test.mjs b/tests/persona-matrix-mouth.test.mjs index 49245ff..38f3c55 100644 --- a/tests/persona-matrix-mouth.test.mjs +++ b/tests/persona-matrix-mouth.test.mjs @@ -1852,3 +1852,109 @@ test("reply link: a mouth built without the option has it off, and only the exac assert.deepEqual(["1", "true", "yes", "0", "", " 1", undefined].map(v => replyLinkFromEnv({ CHAT_PENDING_V4: v })), [true, false, false, false, false, false, false]); assert.equal(replyLinkFromEnv({}), false); }); + +// ---- Fixes after PR #63: the target cache, the excerpt scrub, the per-round read cap. +const eventGets = (hs, id) => hs.seen.filter(r => r.method === "GET" && r.path.endsWith(`/event/${id}`)).length; +async function replyAcrossTtl(replyLink, failure = 500) { + const clock = { t: 1_000_000 }; + let down = false, attempts = 0; + const hs = await fakeHomeserver({ preload: { [ROOM]: [target("$hers", SELF, "my words")] }, sync: since => since === null ? { next_batch: "b0" } : { next_batch: `b${since}x`, ...joined({ [ROOM]: { timeline: { events: [quoting(`$q${since}`, "$hers")] } } }) } }); + try { + const fetch = (u, o) => (String(u).includes("/event/%24hers") && attempts++, down && String(u).includes("/event/") ? (failure === "timeout" ? Promise.reject(new Error("timed out")) : Promise.resolve(new Response("{}", { status: failure }))) : hs.fetch(u, o)); + const mouth = mouthFor(hs, { replyLink, now: () => clock.t, fetch }); + await mouth.syncOnce(); + const first = await mouth.syncOnce(); + clock.t += 61_000; down = true; + const later = await mouth.syncOnce(); + clock.t += 10_000; + const again = await mouth.syncOnce(); // inside 60 s of the failed re-read's renewal + return { first: first[0], later: later[0], again: again[0], gets: attempts }; + } finally { await hs.close(); } +} + +test("target cache: flag off, a hit is kept as before v4: no read after 60 s, basis stays reply", async () => { + const r = await replyAcrossTtl(false); + assert.deepEqual([r.first.basis, r.later.basis, r.again.basis, r.gets], ["reply", "reply", "reply", 1]); +}); + +test("target cache: flag on, a failed re-read after 60 s keeps the earlier hit (basis and reply_to) and renews only its age", async () => { + const r = await replyAcrossTtl(true); + assert.deepEqual([r.first.basis, r.later.basis, r.again.basis, r.gets], ["reply", "reply", "reply", 2]); + assert.deepEqual(r.later.replyTo, { eventId: "$hers", sender: SELF, excerpt: "my words" }); +}); + +test("reply link: the excerpt drops Cf, Co, tag characters, U+061C, zero-width and word-joiner characters and unpaired surrogates", async () => { + const { made } = await linked([quoting("$a", "$t1"), quoting("$b", "$t2"), quoting("$c", "$t3"), quoting("$d", "$t4"), quoting("$e", "$t5")], [ + target("$t1", ALICE_ID, "ig\u{E0049}\u{E0067}nore"), + target("$t2", ALICE_ID, "a؜b�c�d­ef"), + target("$t3", ALICE_ID, "x​y‌z�w"), + target("$t4", ALICE_ID, "p\uD800q\uDC00r\u{F0000}st"), + target("$t5", ALICE_ID, "keep \u{1F600} é 中"), + ]); + assert.deepEqual(made.map(d => d.replyTo.excerpt), ["ignore", "abcdef", "xyzw", "pqrst", "keep \u{1F600} é 中"]); +}); + +test("reply link: at most 8 uncached targets are read for reply_to per sync round; the rest are nil, and reads the basis already made are free", async () => { + const ids = Array.from({ length: 10 }, (_, i) => `$t${i}`); + const tagged = (id, to) => quoting(id, to, {}); // below: mentions her, so basis does no read of its own + const events = ids.map((t, i) => { const e = tagged(`$q${i}`, t); e.content["m.mentions"] = { user_ids: [SELF] }; return e; }); + const { made, hs } = await (async () => { + const hs = await fakeHomeserver({ preload: { [ROOM]: ids.map(t => target(t, ALICE_ID, `text ${t}`)) }, sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events } } }) } }); + const mouth = mouthFor(hs, { replyLink: true }); + await mouth.syncOnce(); + const made = await mouth.syncOnce(); + return { made, hs }; + })(); + try { + assert.equal(ids.filter(t => eventGets(hs, t) > 0).length, 8); + assert.deepEqual(made.map(d => d.replyTo === null), [false, false, false, false, false, false, false, false, true, true]); + } finally { await hs.close(); } +}); + +test("target cache: flag on, 500, 429 and a timeout keep the earlier hit at the re-read; 403 and 404 drop it (reply_to nil, basis as the failure case)", async () => { + for (const failure of [500, 429, "timeout"]) { + const r = await replyAcrossTtl(true, failure); + assert.deepEqual([r.later.basis, r.later.replyTo?.excerpt], ["reply", "my words"], String(failure)); + } + for (const failure of [403, 404]) { + const r = await replyAcrossTtl(true, failure); + assert.deepEqual([r.first.basis, r.later.basis, r.later.replyTo, r.again.replyTo], ["reply", null, null, null], String(failure)); + } +}); + +test("target cache: transient failures keep a hit for 10 minutes from the last good read, then drop it; a good read resets", async () => { + const clock = { t: 1_000_000 }; + let down = false; + const hs = await fakeHomeserver({ preload: { [ROOM]: [target("$hers", SELF, "my words")] }, sync: since => since === null ? { next_batch: "b0" } : { next_batch: `b${since}x`, ...joined({ [ROOM]: { timeline: { events: [quoting(`$q${since}`, "$hers")] } } }) } }); + try { + const fetch = (u, o) => (down && String(u).includes("/event/") ? Promise.resolve(new Response("{}", { status: 503 })) : hs.fetch(u, o)); + const mouth = mouthFor(hs, { replyLink: true, now: () => clock.t, fetch }); + await mouth.syncOnce(); + await mouth.syncOnce(); // good read at t0 + const step = async ms => { clock.t += ms; return (await mouth.syncOnce())[0]; }; + down = true; + const seen = []; + for (const ms of [200_000, 200_000, 140_000]) seen.push((await step(ms)).replyTo?.excerpt); // 3.3, 6.7, 9 min after the good read + seen.push((await step(120_000)).replyTo); // 11 min + down = false; + seen.push((await step(61_000)).replyTo?.excerpt); // a good read resets + down = true; + seen.push((await step(420_000)).replyTo?.excerpt); // 7 min after the new good read: kept again + assert.deepEqual(seen, ["my words", "my words", "my words", null, "my words", "my words"]); + } finally { await hs.close(); } +}); + +test("reply link: the per-round read count resets each sync round", async () => { + const ids = Array.from({ length: 18 }, (_, i) => `$t${i}`); + const tagged = (i, to) => { const e = quoting(`$q${i}`, to); e.content["m.mentions"] = { user_ids: [SELF] }; return e; }; + const hs = await fakeHomeserver({ preload: { [ROOM]: ids.map(t => target(t, ALICE_ID, `text ${t}`)) }, sync: since => since === null ? { next_batch: "b0" } : { next_batch: `b${since}x`, ...joined({ [ROOM]: { timeline: { events: since === "b0" ? ids.slice(0, 9).map((t, i) => tagged(i, t)) : ids.slice(9).map((t, i) => tagged(9 + i, t)) } } }) } }); + try { + const mouth = mouthFor(hs, { replyLink: true }); + await mouth.syncOnce(); + const one = await mouth.syncOnce(); + const two = await mouth.syncOnce(); + assert.deepEqual([one, two].map(m => m.filter(d => d.replyTo !== null).length), [8, 8]); + assert.deepEqual([one, two].map(m => m.at(-1).replyTo), [null, null]); + assert.equal(ids.filter(t => eventGets(hs, t) > 0).length, 16); + } finally { await hs.close(); } +}); diff --git a/tools/persona-matrix-mouth.mjs b/tools/persona-matrix-mouth.mjs index ffc042f..3ceb357 100644 --- a/tools/persona-matrix-mouth.mjs +++ b/tools/persona-matrix-mouth.mjs @@ -166,7 +166,7 @@ export function createMouth({ } catch (error) { throw new Error(`homeserver unreachable: ${redact(error?.message ?? String(error), token)}`); } - if (!response.ok) throw new Error(`homeserver answered ${response.status} for ${method} ${path.split("?")[0]}`); + if (!response.ok) throw Object.assign(new Error(`homeserver answered ${response.status} for ${method} ${path.split("?")[0]}`), { status: response.status }); return response.json(); } @@ -312,11 +312,18 @@ export function createMouth({ // A pill is an anchor whose href is exactly her matrix.to link; a longer id, an attribute that merely contains one and a quoted reply do not count. const pillFor = html => typeof html === "string" && [...html.replace(/[\s\S]*?<\/mx-reply>/gi, "").matchAll(/]*?href\s*=\s*(["'])(.*?)\1/gi)].some(([, , href]) => [selfId, encodeURIComponent(selfId)].some(id => href === `https://matrix.to/#/${id}`)); const authors = new Map(); // `${room id} ${event id}` -> { sender, excerpt, at }: read results, kept TARGET_TTL_MS, bounded; the key holds the room so another room's reply cannot borrow a hit - const TARGET_TTL_MS = 60 * 1000; // how long a read of a target (hit or failure) is kept - // The quoted text, as a prompt-safe line: nil when the event is redacted (the homeserver strips its content), is not text, or is edited + const TARGET_TTL_MS = 60 * 1000; // how long a read of a target is kept: a failure always; a hit only with replyLink (without it a hit is kept until bounded out, as before v4) + const REPLY_READS_PER_ROUND = 8; // distinct uncached targets replyToOf may read in one sync round; past it reply_to is nil (the tuple needs a sender, which only a read gives) + const KEEP_HIT_MS = 10 * 60 * 1000; // how long transient failures may keep a hit alive after its last good read + let replyReads = 0; + const cachedTarget = key => { + const known = authors.get(key); + return known && (now() - known.at < TARGET_TTL_MS || (!replyLink && known.sender !== null)) ? known : null; + }; + // The quoted text, as a single line (invisible and format characters removed; still untrusted text): nil when the event is redacted (the homeserver strips its content), is not text, or is edited // (an edit target, or one with an m.replace bundled: the original body is the pre-edit text, which its author replaced), or is nothing but // its own reply fallback. Else: the legacy rich-reply fallback (leading "> " lines and the blank line after) is dropped, CR/LF/tab runs - // become one space, control characters (C0, DEL, C1) and bidi marks/overrides/isolates are removed, the result is trimmed, then cut to the + // become one space, control characters (C0, DEL, C1), format characters (Cf, incl. bidi marks, U+061C, U+200B-200F, U+2060-2064), private use (Co), the tag block U+E0000-E007F and unpaired surrogates are removed, the result is trimmed, then cut to the // first 240 code points (not bytes, so no character is split; a combining mark is its own code point) and trimmed again. Computed // only when replyLink is on. const FALLBACK_LINE = /^>( |$)/; @@ -333,16 +340,17 @@ export function createMouth({ } const line = bodyText({ ...c, body }, docs.limits.contextBytes) .replace(/[\r\n\t\u2028\u2029]+/g, " ") - .replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "") + .replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069]|[\p{Cf}\p{Co}\u{E0000}-\u{E007F}]|[\uD800-\uDFFF]/gu, "") .trim(); const cut = [...line].slice(0, docs.limits.replyExcerpt).join("").trimEnd(); return cut === "" ? null : cut; }; async function targetOf(roomId, eventId) { const key = `${roomId} ${eventId}`; + const cached = cachedTarget(key); + if (cached) return cached; // with replyLink a hit is short too: a later redaction or edit of the target must show at a re-read const known = authors.get(key); - if (known && now() - known.at < TARGET_TTL_MS) return known; // short for hits too: a later redaction or edit of the target must show at a re-read - let sender = null, excerpt = null; + let sender = null, excerpt = null, transient = false; try { const target = await matrix("GET", `/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`, undefined, API, AbortSignal.timeout(sendTimeoutMs)); if (target?.room_id === undefined || target.room_id === roomId) { // an id from another room is not an answer about this one: no sender, no text @@ -350,9 +358,13 @@ export function createMouth({ if (sender !== null) excerpt = excerptOf(target); } } catch (error) { + transient = error.status === undefined || error.status >= 500 || error.status === 429; // network, timeout, 5xx, 429: the answer is unknown; 403, 404 and any other refusal are an answer say(`reply target ${eventId} not read: ${error.message}`); } - const entry = { sender, excerpt, at: now() }; + const t = now(); + const entry = sender !== null ? { sender, excerpt, at: t, good: t } + : transient && known?.sender != null && t - known.good <= KEEP_HIT_MS ? { ...known, at: t } // a transient failure (network, timeout, 5xx, 429) keeps the earlier hit and renews only its age, for at most KEEP_HIT_MS after the last good read + : { sender: null, excerpt: null, at: t }; // any other failure (403, 404, wrong room, no sender), or a hit kept too long, is the failure entry authors.set(key, entry); if (authors.size > DELIVERED_INDEX) authors.delete(authors.keys().next().value); return entry; @@ -365,6 +377,10 @@ export function createMouth({ const r = event.content["m.relates_to"]?.["m.in_reply_to"]; const id = r?.event_id; if (typeof id !== "string" || id === "" || event.content["m.relates_to"]["is_falling_back"] === true) return null; + if (!cachedTarget(`${roomId} ${id}`)) { + if (replyReads >= REPLY_READS_PER_ROUND) { say(`reply target ${id} not read: round cap`); return null; } + replyReads++; + } const { sender, excerpt } = await targetOf(roomId, id); return sender === null ? null : { eventId: id, sender, excerpt }; } @@ -405,6 +421,7 @@ export function createMouth({ // One /sync round. Returns the deliveries it made. async function syncOnce() { if (!started || unjoined()) await start(); + replyReads = 0; const free = await released(); // the round's brake read for presence, taken before any early return await setPresence(free); if (pending.length >= maxPending) return []; // unanswered mail is not outrun: the position stays where it is From 1da912d5e95f89fd3b4e2ca1fdaf9aeb7eb44366 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 16:39:48 +0000 Subject: [PATCH 009/102] Members: approve refuses no-membership/unknown accounts; neutral text for legacy suspensions (#65) --- docs/registration-gates.md | 2 +- .../Pages/App/Members/Index.cshtml | 4 +- .../Pages/App/Members/Index.cshtml.cs | 29 +++- .../MembersPageNoticeTests.cs | 131 ++++++++++++++++++ .../SuspensionReasonExposureTests.cs | 2 +- 5 files changed, 160 insertions(+), 8 deletions(-) diff --git a/docs/registration-gates.md b/docs/registration-gates.md index e9896f7..8bfb15e 100644 --- a/docs/registration-gates.md +++ b/docs/registration-gates.md @@ -11,7 +11,7 @@ This page scopes the three that did not exist, says where each lives, and record | Rate limits and brake | `OidcGuardMiddleware` (`docs/oidc-issuer.md`) | Merged. Covers `/connect/*` only, not the register endpoint. | | Reserved names | `BifrostIdentityService.IsReserved`, 28 names | Merged, at creation and at authorize. | | Chat tier, no vote | `Authenticated` / `PendingApproval` accounts chat and open no member or governance surface | Merged. | -| Suspension | `MembershipSuspensionService` | Merged. The reason is `Membership.SuspensionReason`, written with the status change and its receipt, cleared on unsuspend, projected only to admins on the members page; the receipt (`AuditEvent.Detail`) holds no reason text. Receipts written before the column existed still hold their reasons as history; the member dashboard keeps filtering suspension receipts out. An unsuspend still requires a reason, but nothing stores it. | +| Suspension | `MembershipSuspensionService` | Merged. The reason is `Membership.SuspensionReason`, written with the status change and its receipt, cleared on unsuspend, projected only to admins on the members page; the receipt (`AuditEvent.Detail`) holds no reason text. Receipts written before the column existed still hold their reasons as history; the member dashboard keeps filtering suspension receipts out. A Suspended row with no stored reason (suspended before the column existed) shows admins the fixed text "Suspended before reasons were recorded"; the old text is never read out of the receipt. An unsuspend still requires a reason, but nothing stores it. | | CAPTCHA | `Features/Registration/RegistrationChallengeService` | Built here. | | Consent receipt | `ConsentReceipt`, `TrainingNotice` | Built here. | | Email verification | nowhere | Scoped below, not built. | diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml b/src/Bifrost.Web/Pages/App/Members/Index.cshtml index 94e791d..6521c2b 100644 --- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml +++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml @@ -191,9 +191,9 @@ @if (Model.Actor.CanManageMembership) { - @if (user.Status == MembershipStatus.Suspended && !string.IsNullOrEmpty(user.SuspensionReason)) + @if (user.Status == MembershipStatus.Suspended) { -
Suspended: @user.SuspensionReason
+
@(string.IsNullOrWhiteSpace(user.SuspensionReason) ? Bifrost.Web.Pages.App.Members.IndexModel.LegacySuspensionText : "Suspended: " + user.SuspensionReason)
}
diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs index 55fa91f..a4bb901 100644 --- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs @@ -35,6 +35,8 @@ public sealed class IndexModel( }; internal const string ApproveSuspendedNotice = "Nothing changed: a suspended account can only be lifted with Unsuspend."; + /// Admin-only text for a Suspended row whose reason predates the SuspensionReason column; the old text is never read back from the audit trail. + internal const string LegacySuspensionText = "Suspended before reasons were recorded"; internal const string NoMembershipNotice = "Nothing changed: that account has no membership yet."; internal const string OwnAdminRoleNotice = "Nothing changed: you cannot remove or change your own platform admin role."; internal const string RoleOnSuspendedNotice = "Nothing changed: a suspended account's roles cannot be changed until it is unsuspended."; @@ -119,9 +121,16 @@ public sealed class IndexModel( var user = await dbContext.UserAccounts .Include(x => x.Membership!) .ThenInclude(x => x.RoleAssignments) - .SingleAsync(x => x.Id == userAccountId, cancellationToken); + .SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken); - if (user.Membership!.Status == MembershipStatus.Suspended) + // The chat tier holds no membership by design (docs/oidc-issuer.md); approval refuses rather than inventing one. + if (user is null || user.Membership is null) + { + Notice = user is null ? RefusalNotices[SuspensionOutcome.NoSuchAccount] : NoMembershipNotice; + return RedirectToPage(); + } + + if (user.Membership.Status == MembershipStatus.Suspended) { Notice = ApproveSuspendedNotice; // only Unsuspend lifts a suspension return RedirectToPage(); @@ -181,7 +190,13 @@ public sealed class IndexModel( var user = await dbContext.UserAccounts .Include(x => x.Membership!) .ThenInclude(x => x.RoleAssignments) - .SingleAsync(x => x.Id == userAccountId, cancellationToken); + .SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken); + + if (user is null) + { + Notice = RefusalNotices[SuspensionOutcome.NoSuchAccount]; + return RedirectToPage(); + } if (user.Membership is null) { @@ -259,7 +274,13 @@ public sealed class IndexModel( var user = await dbContext.UserAccounts .Include(x => x.Membership!) .ThenInclude(x => x.TierSnapshots) - .SingleAsync(x => x.Id == userAccountId, cancellationToken); + .SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken); + + if (user is null) + { + Notice = RefusalNotices[SuspensionOutcome.NoSuchAccount]; + return RedirectToPage(); + } if (user.Membership is null) { diff --git a/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs b/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs index b4261d8..ec87c6d 100644 --- a/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs +++ b/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs @@ -174,6 +174,137 @@ public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) : Assert.Contains("has no membership yet", Alert.Match(html).Groups[1].Value); } + private async Task<(int Memberships, int Roles, int ApprovalReceipts)> RowCountsAsync() + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return (await db.Memberships.CountAsync(), await db.RoleAssignments.CountAsync(), await db.AuditEvents.CountAsync(x => x.Action == "membership.approved")); + } + + [Fact] + public async Task Approving_an_account_without_a_membership_is_refused_with_a_notice_and_writes_nothing() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var account = await SeedMemberAsync(MembershipStatus.Active, withMembership: false); + var before = await RowCountsAsync(); + + var html = await PostThenFollowAsync(client, "Approve", account, ""); + + Assert.Contains("has no membership yet", Alert.Match(html).Groups[1].Value); + Assert.Equal(before, await RowCountsAsync()); + using var scope = factory.Services.CreateScope(); + Assert.False(await scope.ServiceProvider.GetRequiredService().Memberships.AnyAsync(x => x.UserAccountId == account)); + } + + [Fact] + public async Task Approving_an_unknown_account_is_refused_with_a_notice_and_writes_nothing() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var before = await RowCountsAsync(); + + var html = await PostThenFollowAsync(client, "Approve", Guid.NewGuid(), ""); + + Assert.Contains("does not exist", Alert.Match(html).Groups[1].Value); + Assert.Equal(before, await RowCountsAsync()); + } + + [Fact] + public async Task An_admin_sees_the_neutral_text_for_a_legacy_suspended_row_and_the_stored_reason_otherwise() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var legacy = await SeedMemberAsync(MembershipStatus.Suspended); + var withReason = await SeedMemberAsync(MembershipStatus.Suspended); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Memberships.SingleAsync(x => x.UserAccountId == withReason)).SuspensionReason = "spam-ring-5521"; + await db.SaveChangesAsync(); + } + + var html = await client.GetStringAsync("/App/Members"); + int legacyRows; + using (var scope = factory.Services.CreateScope()) + { + legacyRows = await scope.ServiceProvider.GetRequiredService().Memberships + .CountAsync(x => x.Status == MembershipStatus.Suspended && (x.SuspensionReason == null || x.SuspensionReason.Trim() == "")); + } + + Assert.Contains("Suspended before reasons were recorded", html); + Assert.Contains("Suspended: spam-ring-5521", html); + Assert.Equal(legacyRows, Regex.Matches(html, "Suspended before reasons were recorded").Count); // the reasoned row shows its reason, not the neutral text + } + + [Fact] + public async Task A_non_admin_sees_neither_the_neutral_text_nor_a_reason() + { + using var client = Client(); + var legacy = await SeedMemberAsync(MembershipStatus.Suspended); + var viewer = $"plain-{Guid.NewGuid():N}"[..20]; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.UserAccounts.Add(new UserAccount { DisplayName = viewer, BifrostIdentity = viewer, NormalizedBifrostIdentity = viewer, Membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active, ApprovedAtUtc = DateTimeOffset.UtcNow } }); + await db.SaveChangesAsync(); + } + + using var request = new HttpRequestMessage(HttpMethod.Get, "/App/Members"); + request.Headers.Add("X-Test-Bifrost-Identity", viewer); + using var response = await client.SendAsync(request); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.DoesNotContain("before reasons were recorded", html); + } + + [Theory] + [InlineData("ToggleRole")] + [InlineData("SetTier")] + public async Task ToggleRole_and_SetTier_on_an_unknown_account_are_refused_with_a_notice_and_write_nothing(string handler) + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var before = await RowCountsAsync(); + int receipts; + using (var scope = factory.Services.CreateScope()) + { + receipts = await scope.ServiceProvider.GetRequiredService().AuditEvents.CountAsync(); + } + + var html = await PostThenFollowAsync(client, handler, Guid.NewGuid(), "", role: "Producer"); + + Assert.Contains("does not exist", Alert.Match(html).Groups[1].Value); + Assert.Equal(before, await RowCountsAsync()); + using var after = factory.Services.CreateScope(); + Assert.Equal(receipts, await after.ServiceProvider.GetRequiredService().AuditEvents.CountAsync()); + } + + [Fact] + public async Task A_whitespace_only_stored_reason_shows_the_neutral_text_to_an_admin() + { + using var client = Client(); + _ = await SignedInAdminIdAsync(client); + var member = await SeedMemberAsync(MembershipStatus.Suspended); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Memberships.SingleAsync(x => x.UserAccountId == member)).SuspensionReason = " "; + await db.SaveChangesAsync(); + } + + var html = await client.GetStringAsync("/App/Members"); + + Assert.DoesNotContain("Suspended: <", html); + using var check = factory.Services.CreateScope(); + var db2 = check.ServiceProvider.GetRequiredService(); + var whitespaceRows = await db2.Memberships.CountAsync(x => x.Status == MembershipStatus.Suspended && x.SuspensionReason != null && x.SuspensionReason.Trim() == ""); + Assert.True(whitespaceRows >= 1); + Assert.Equal(await db2.Memberships.CountAsync(x => x.Status == MembershipStatus.Suspended && (x.SuspensionReason == null || x.SuspensionReason.Trim() == "")), + Regex.Matches(html, "Suspended before reasons were recorded").Count); + } + [Fact] public async Task An_admin_toggling_their_own_platform_admin_role_is_refused_and_keeps_the_role() { diff --git a/tests/Bifrost.Web.Tests/SuspensionReasonExposureTests.cs b/tests/Bifrost.Web.Tests/SuspensionReasonExposureTests.cs index 0921036..0a02cb0 100644 --- a/tests/Bifrost.Web.Tests/SuspensionReasonExposureTests.cs +++ b/tests/Bifrost.Web.Tests/SuspensionReasonExposureTests.cs @@ -65,7 +65,7 @@ public sealed class SuspensionReasonExposureTests return model.Users; } - Assert.All(await UsersSeenBy(admin: false), u => Assert.DoesNotContain(Canary, u.SuspensionReason)); + Assert.All(await UsersSeenBy(admin: false), u => Assert.Equal(string.Empty, u.SuspensionReason)); // not even the legacy-row marker leaves the projection for a non-admin Assert.Contains(await UsersSeenBy(admin: true), u => u.SuspensionReason == Canary); } From e8e8fcc1780d30ab22d208e407eeeb69549ed498 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 16:48:27 +0000 Subject: [PATCH 010/102] 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 011/102] 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 012/102] 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 013/102] 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_qbaJCW zXQ|ZCERCH$U<69nTE1w(a|wC#U(`vqwbh`?nAS4iPTKZ5Efj@mA@f+&Z1=|+P$)?7 Pv(9l|=vPcBWI3=O&wrg* literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.frame.hex new file mode 100644 index 0000000..df49d00 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.frame.hex @@ -0,0 +1 @@ +0000019289a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5141726e4a6c593239795a47566b58324e7664573530414b566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.payload.hex new file mode 100644 index 0000000..3a4ef1e --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.payload.hex @@ -0,0 +1 @@ +81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7400ae7265636f726465645f636f756e7400a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.payload.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..d6dd07be262108c9fdaa52ba0f1fa2e152d7b6c4 GIT binary patch literal 132 zcmZo%>Xu)WUEH)JC$qSueQj}aMrv+iSZYymW`5q*^u*lMX2J&+2$GJ_?# znRzM8QcH_cvlEvVmn7yEEGsrNFtjvSm0ysW7oVJ8npeWGt|&D*zbGX&1b%s7lKAA(qT>9b14|Q&5DWAlEi&Vmk@3OGz%kJwOOm2qCdx#YMOU zN1&hzSkFhAH(OiVceg7et!1eU#l6|tU6qxrnaj%J;j*lieXLlcOC~-(QX zQ!}g=^o(k8-q#EU1^1GSnIsoTsS%#`=!wXY2_3>d7)1aK+q4Pj^A&~;ypvWq~UPC zS)L`9;cg{P!h@I1a=+lE$cprJ!>M9i?aY{!d%fq4CE{~w*xP)OW<}8v(yEJEUM-|` lo$Kzi{ZSKH+cnj9zR<3auJg>kDIwo+1DM literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.frame.hex new file mode 100644 index 0000000..d773535 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.frame.hex @@ -0,0 +1 @@ +000001a287a96d6573736167654964a36d2d35a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4f5755345a44646a4e6d49744e5745304f5330304d7a67794c574978595441745a6a6c6c4f475133597a5a694e5745307147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.payload.hex new file mode 100644 index 0000000..5e185ed --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.payload.hex @@ -0,0 +1 @@ +81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92439653864376336622d356134392d343338322d623161302d663965386437633662356134a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.payload.msgpack new file mode 100644 index 0000000..9f58dab --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.payload.msgpack @@ -0,0 +1 @@ +�¥Forks†¤mind¦eureka§subjectÙ$9e8d7c6b-5a49-4382-b1a0-f9e8d7c6b5a4¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursorÀ¥limit2 \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..f2479d544ad0275f44632f9291a560f0725df468 GIT binary patch literal 426 zcmb7>O-{ow5JurZtx~5{>Vgd=3Jr}>o7AbZL9S!+V>?M>rzN=n2SI=;R3Wio#YMOW z2cSX~ux0(dnbExO=;-+IZY!BmBxd92UUtB?C|N~xTI3HmMa9)qNg7d*=;Jdc$X=RG zMf-#=15t;Up@}(lLojc#X8_!eZzujtLO<5nKjUy@oP>TNA+P|> z(EhF`sX0rc3)GhOa1#atiA~f{Fl-9o8tw`y67<|$~#M< z*r2~VARbMaByUL_r+MCunBvzJTh&bI?z*>A-zj31+M9i+wa_`!-5=M#sIgV7TBmeh I$Mc_EU&}n7X8-^I literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.frame.hex new file mode 100644 index 0000000..466d859 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.frame.hex @@ -0,0 +1 @@ +000001a687a96d6573736167654964a36d2d32a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f46761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d59335679633239796f324d304d715673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.payload.hex new file mode 100644 index 0000000..4004719 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.payload.hex @@ -0,0 +1 @@ +81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72a3633432a56c696d697432 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.payload.msgpack new file mode 100644 index 0000000..c0349ca --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.payload.msgpack @@ -0,0 +1 @@ +�¥Forks†¤mind¦eureka§subjectÙ$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursor£c42¥limit2 \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..b720fa2151bada8bca887a9336d529339d6bd2f7 GIT binary patch literal 3803 zcmd5Ma&>$!t;vorkFzaymal>(ABK+eo zufq{uhZo>1J$ia(+ID}m@A!j|0q2MnzsH)BdB{A5t}G}k=dv%AwR!ECX`nf+?_fp?vm$7WFj8CWnj%?jJDgAYpqCjb ztUj`3WUQ>z_w-DP75kO7;wx(}_UpnwRR|wx-aZBH4jY;y(%h+iTO2}HAHs#6gxcen zdJc~X&M_LpnTMbko4_-Lz?F-&tBRqPz#dG90!IiutHH$b0`0q)xT?I=E_sT5dwPlQ z(>P2Ck_GG~0@|t5rN;d?ao`y-_)hR_x{vHB#47Hwrw>bQ(vd|L!dUqs1MM;PJ+tQLUOtFmvyCq`Ul=ju zy9Zwq*~RNiK#U^9 zAZNbE^ER&Ue%CJdYZs75j){!QGe$HgA;l-9(s1LuPs#!OL4FxlClx7IS%5gcEFIP) z{7lF;Z&-V1spRaTHa+-Z8~BFZhkK1O0vH2-30<+?ciDhl>&f?Q!AOtDog=IDHrlO=Vz%4A&4gE8Ihm0G@2^l|GPO>4Tg7 zrH_kCUx||nn;F#Hfp2ZH_Ds=-n0c-7t~!UVGhAXxD~Qdk*Dl95)EMyQdw_qy3G{&c z)nZ<&DUqBmyl{!yQ7L4UK@2OsGXfcKWlB&#{_bk0Y=Fz;AwKRAVWl5&Gb^FDcrDU!!TZHLV$D;& zTCP_ZczII!$o+XgXC$8pn8?o@1GsoXHBzw6!Q zUAy7$Z||OM=8Jd2(&~SOrSdU%5b!GJ<2^wE$L;?&o`P_SC7p4dJKXy(=QD#Uw7{1} z>bJ)30Uq_y*wL{ue}rcm9AJ%)oGN^SZH-LO9M(e3I}c$oTmbLURr!ohZ>=1DqVd?g zYV+yL3eW_4HIM73g+OS zO{DM5H13VT$;~-t22yilXPkW30UQ7958F|dnHZ7tIso5XwTjQDho9@$2KZ-{!95_uboTWz zL*d@SHH*&Ph7GPqZ`305YWpi&p0jG!-5(7%C5Yo&74!9$aLu%9+R+^&BQ1uesU z5&z|OS*!w@eIP*-jYnT@CN@o?JoszU#rvZ87)DL@+d68hvS^}@NAXK@`Rqh>19!{6 z(T=r6)P8)vZN9J-J~vT)+*7!b{`tjjKQv`m)5z+IcAvwyFV}A$qOOjz;15k3v|aP3 KfYPXHqwrr1p(~;Q literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.frame.hex new file mode 100644 index 0000000..8f1e23e --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.frame.hex @@ -0,0 +1 @@ +00000ed789a96d6573736167654964a36d2d35a96f7065726174696f6ea5666f726b73a77061796c6f6164da0df46761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5142726e4a6c593239795a47566b58324e7664573530414b566d62334a726335474472584e6a6147567459565a6c636e4e70623236325a3246745a574e31624851755a6d39796179356c626e5279655335324d4b526a59584a6b3367415372584e6a6147567459565a6c636e4e70623236315a3246745a574e31624851755a6d39796179356a59584a6b4c6e597770325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c3065615630615852735a625258614738676257463549477870633351675953426d62334a725036646a623235305a5868303254464249475a76636d736761584d67636d56685a434269655342745a5731695a584a7a4947397562486b676232356a5a534270644342706379427361584e305a5751757032397764476c76626e4f5367714a705a4b746863327470626d637462576c755a4b52305a586830325352556147556759584e726157356e49453170626d516762476c7a64484d6761475679494739336269426d62334a72637936436f6d6c6b714739775a584a68644739797048526c65485333515734676233426c636d46306233496762476c7a64484d676447686c62533675636d566a623231745a57356b595852706232367259584e726157356e4c573170626d536e5a4756775a57356b6335436c62475668626e504170475a735957664171584a6861584e6c5a463970626f4b6b61326c755a4b524a5a4756686f6d6c6b32534e6c64584a6c6132457463335669633352795958526c4f6d6c6b5a5745365a47567a6179316b623239794c573168634b6c7959576c7a5a575266595853304d6a41794e6930784d5330784d5651784e6a6f304d446f784d6c716f5a47566a6157526c636e4f517257526c59326c6b5a584a6659323931626e5143706e4e30595852316336527663475675706e4a3162476c755a384371636e56735a5752665a6e4a76625a436c62334a6b5a5849707247397764476c76626e4e666147467a614e6c414e3251354e575a6a4f44646b4e544d794e4449774e6a45785a6d55304d54566a4d57526c5a6a493359544d304e4463324d546469596a59344d6a4d315a6a51325a544e685a6a457a4d47466c59544d324d574d304f4b5a795a574e76636d534772584e6a6147567459565a6c636e4e70623236335a3246745a574e31624851755a6d3979617935795a574e76636d5175646a436e5a6d3979613139705a4e6b775a5856795a5774684c584e31596e4e30636d46305a5470786457567a64476c76626a706b5a584e724c57787063335270626d63745958563061473979615852357247397764476c76626e4e666147467a614e6c414e3251354e575a6a4f44646b4e544d794e4449774e6a45785a6d55304d54566a4d57526c5a6a493359544d304e4463324d546469596a59344d6a4d315a6a51325a544e685a6a457a4d47466c59544d324d574d304f4c466a623356756447566b583246756333646c636c39705a4b4a754d613568626e4e335a584a6c636c396a6233567564414f6e5957357a64325679633554654142437463324e6f5a573168566d567963326c766272646e5957316c593356736443356b5a584e72583246756333646c636935324d4b6c68626e4e335a584a6661575369626a476e5a6d3979613139705a4e6b775a5856795a5774684c584e31596e4e30636d46305a5470786457567a64476c76626a706b5a584e724c57787063335270626d63745958563061473979615852357032393164474e766257576e51323931626e526c5a4b4a765a73436f636d5630636d466a6448504170474e765a4758417247397764476c76626e4e666147467a614e6c414e3251354e575a6a4f44646b4e544d794e4449774e6a45785a6d55304d54566a4d57526c5a6a493359544d304e4463324d546469596a59344d6a4d315a6a51325a544e685a6a457a4d47466c59544d324d574d304f4b5a6a614739705932577259584e726157356e4c573170626d536b626d39305a6235556147556759584e725a58496762476c7a64484d6761475679494739336269426d62334a72637936725957357a643256795a575266595853304d6a41794e6930784d5330784d5651784e7a6f774d446f774d467172636d566a5a576c325a575266595853304d6a41794e6930784d5330784d5651784e7a6f774d446f774d4671765932786861573166615735665a6e563064584a6c77716868626e4e335a584a6c63734370595856306147397961585235706b316c62574a6c637170795a5846315a584e3058326c6b325351774d4441774d4441774d4330774d4441774c5451774d4441744f4441774d4330774d4441774d4441774d4441774d4448654142437463324e6f5a573168566d567963326c766272646e5957316c593356736443356b5a584e72583246756333646c636935324d4b6c68626e4e335a584a6661575369626a4b6e5a6d3979613139705a4e6b775a5856795a5774684c584e31596e4e30636d46305a5470786457567a64476c76626a706b5a584e724c57787063335270626d63745958563061473979615852357032393164474e766257576f556d563261584e706232366962326169626a476f636d5630636d466a6448504170474e765a4758417247397764476c76626e4e666147467a614e6c414e3251354e575a6a4f44646b4e544d794e4449774e6a45785a6d55304d54566a4d57526c5a6a493359544d304e4463324d546469596a59344d6a4d315a6a51325a544e685a6a457a4d47466c59544d324d574d304f4b5a6a614739705932577259584e726157356e4c573170626d536b626d39305a6164425a334a6c5a575175713246756333646c636d566b58324630744449774d6a59744d5445744d5446554d5463364d4455364d44426171334a6c59325670646d566b58324630744449774d6a59744d5445744d5446554d5463364d4455364d44426172324e7359576c7458326c7558325a31644856795a634b6f5957357a643256795a584c417157463164476876636d6c3065615a4e5a5731695a584b71636d56786457567a644639705a4e6b6b4d4441774d4441774d4441744d4441774d4330304d4441774c5467774d4441744d4441774d4441774d4441774d4441793367415172584e6a6147567459565a6c636e4e70623236335a3246745a574e31624851755a47567a61313968626e4e335a584975646a43705957357a6432567958326c6b6f6d347a70325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656164766458526a6232316c70314a6c5a6e567a5a5753696232624171484a6c64484a685933527a774b526a6232526c72573576644639685832526c59326c6b5a584b7362334230615739756331396f59584e6f325541335a446b315a6d4d344e3251314d7a49304d6a41324d54466d5a5451784e574d785a47566d4d6a64684d7a51304e7a59784e324a694e6a67794d7a566d4e445a6c4d32466d4d544d77595756684d7a5978597a5134706d4e6f62326c6a5a61746863327470626d637462576c755a4b52756233526c7030316c49485276627936725957357a643256795a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e6a6f774d467172636d566a5a576c325a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e6a6f774d4671765932786861573166615735665a6e563064584a6c77716868626e4e335a584a6c63734370595856306147397961585235706b316c62574a6c637170795a5846315a584e3058326c6b325351774d4441774d4441774d4330774d4441774c5451774d4441744f4441774d4330774d4441774d4441774d4441774d4450654142437463324e6f5a573168566d567963326c766272646e5957316c593356736443356b5a584e72583246756333646c636935324d4b6c68626e4e335a584a6661575369626a536e5a6d3979613139705a4e6b775a5856795a5774684c584e31596e4e30636d46305a5470786457567a64476c76626a706b5a584e724c57787063335270626d63745958563061473979615852357032393164474e7662575771556d5630636d466a64476c7662714a765a73436f636d5630636d466a64484f69626a4b6b5932396b5a63437362334230615739756331396f59584e6f325541335a446b315a6d4d344e3251314d7a49304d6a41324d54466d5a5451784e574d785a47566d4d6a64684d7a51304e7a59784e324a694e6a67794d7a566d4e445a6c4d32466d4d544d77595756684d7a5978597a5134706d4e6f62326c6a5a63436b626d39305a6343725957357a643256795a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e7a6f774d467172636d566a5a576c325a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e7a6f774d4671765932786861573166615735665a6e563064584a6c77716868626e4e335a584a6c63734370595856306147397961585235706b316c62574a6c637170795a5846315a584e3058326c6b325351774d4441774d4441774d4330774d4441774c5451774d4441744f4441774d4330774d4441774d4441774d4441774d445372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.payload.hex new file mode 100644 index 0000000..421091f --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.payload.hex @@ -0,0 +1 @@ +81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7401ae7265636f726465645f636f756e7400a5666f726b739183ad736368656d6156657273696f6eb667616d6563756c742e666f726b2e656e7472792e7630a463617264de0012ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e636172642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a57469746c65b457686f206d6179206c697374206120666f726b3fa7636f6e74657874d9314120666f726b2069732072656164206279206d656d62657273206f6e6c79206f6e6365206974206973206c69737465642ea76f7074696f6e739282a26964ab61736b696e672d6d696e64a474657874d9245468652061736b696e67204d696e64206c6973747320686572206f776e20666f726b732e82a26964a86f70657261746f72a474657874b7416e206f70657261746f72206c69737473207468656d2eae7265636f6d6d656e646174696f6eab61736b696e672d6d696e64a7646570656e647390a56c65616e73c0a4666c6167c0a97261697365645f696e82a46b696e64a449646561a26964d923657572656b612d7375627374726174653a696465613a6465736b2d646f6f722d6d6170a97261697365645f6174b4323032362d31312d31315431363a34303a31325aa8646563696465727390ad646563696465725f636f756e7402a6737461747573a46f70656ea672756c696e67c0aa72756c65645f66726f6d90a56f7264657229ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a67265636f726486ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034ab6e6578745f637572736f72c0a561735f6f66cd27ce diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.payload.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..17b0abbefc92b0352bcbd50c616927935e313617 GIT binary patch literal 2679 zcmc&$Pfrs;6i596#!s+`B*sIV-R-u8UX+-ac;G^aCS027%v(C4JA*T`V2@&=S2TVC z(sm07#suYNHs1Blh0o%f-9;&4Nhs)MGuxSYe`enA_kQWi{$r_D^hU44wRy9rX&WN) z6qM#td|oCI(70pDA=;F!MS0EbMO?4}#0pkOUmFsw4z%Zbv+j;u1<|B37Ussj0xA^) zmd~g9HiCZpYBa%@(F7oj>e>vHD0Vz`XVm!2X{-&}CN^-Bvm-Y@QAx)wZ4@!kcpif` zc#Q@Fy;8EL=VG};V$+t2n{MB5(}9C$ZRtd$>)3sVI5ymagi2xHg*o!(vIU20M*(3@ ztLsD%wXi&n6rC;(6dZ2szV!!IPUNc1so!mEakfkJip^;&*2@A;FSfzSSDkrW&D^xp z2Ia_gktNe*>pH|@;w_}g{0`@YbFxsl7zC=El{tz)Fk-Ww&X+JiIGEn-cYp|;_QDQX zPPY}|8d2e5qlZUhy(bI^raQX(NBKDJu18=k*3 zWI)kL>CL_3R}TKo0kXuzx@Y+@P;m!KlJ4La&k-saZT2lw)cp@d>6&e#+eZ)UY_=NG znL4Xh{J?i>o)<#kRjJ1y^y{P&1ofI%Z?$SOex(`)HK>r#tGEP6rRLFKW{^|x^(c!D zM=6)vOvIxrKK7@qfv_|P+Bztt_LlJW^9fF~-CIdM*YA&~ZvX9mmi0vtyx;ceaF=hI zM8<~72$Dy(&4F#nFdZtuhNA{KMkRyviVHHJZOJK|=5|j=17C|)!q&09ceaUki;`g> zN9kH`xGug+yNEsTHCyTP4L;Hi;Sshh&15hg*F#q8#tL0#aGjt8y8{16HEFE0RB4~+rVPK|`|%T<+wzA1 literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.request.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.request.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..8ea061150eb88d3298d6b77e3bd2fe625f430c60 GIT binary patch literal 422 zcmb7>Pfo%>6o>IZkamhBEZm4qrN9_ynKIp&VLJBD%(SJ`Qrio74~;Rz5EB=!yok5( z2ogiot?S?S-pl)bozBVq-A*#4NXW*)gY4?NEN2BN<1Br=$qKHXa?*&51RtLvLDt%^ z3)&}q?TN~t_##-@f&eR}28xM9NdP&A9ide{efPWH{P}&6)OD~+%=jZ^6b+VnXox;GlO{ssz^R&gh1W$$oWqk> zGO7xud38J;7SeuIT&qa4+c=x%Ar&oOauNHM2#_Hn%}GOlT15T)g5CUbEJhbjhmQ=6 z)UdQCr_vJeyW@tJ`-q%lI#k9>@3wC=G-fBCWP#x|%;O>|Nfn|8#F5@`O4M*+x4am2 zK{pg(>2%3vi15G@%_<`|m7gBv_8n-;{ z*k*Ez@B4>13(-*FeySi>pO}X9U+)4R@4$D;r;~lMnLw=Sp8HmB8lyH`_TlKqC!_f&3xGd@*u*^ zLkn?0j4H%1%$YCq_HcUkyQ0$Xri467OytpUDni3c!}9xaW$>EO$MF&TL4F0-M^$)O z<^huUax7Sr$}^!2dBWPfi8OBywduf*Si?6?=g&3DL%p|EbL!kI}0 ze!G`mIPyL=bS3uJxy*BTRlmZ4a zZqMR;?Ro|4cN(88{CW3vx4qh)m1$H?pCyC;sDRY-3mEtWXi0x#L0;U(?AcuDe#`nj*kmVNbX~r(_esFL*$6d@>k1s0hl@m zl!znJ`&;jKY6F~=SH|!h8}ttz@Qi85Z<4blzdFE^sfhNIbBqSNn?7i`h4uFu6tZ7* zrJ0!*oUsPGb9nSEcB2P88^bdRj+)dYuZmw`dqM%y>YZWhw!lj^ppuf=X z;z@sQ8fDGK7dZ3nS-H2r4Uzv(m4ST!^zOw5$Aj^F`M>%U_zpFX&*ztYdZbUe6C=q72XH#dDTfWJHS)A-6z^AzZnh6m|}`@Fi%8At2P zkKijvp_hQuDN&+#(;Vz_9!+cDLeSh%8>yoM=*al9ysR|}lQDei5B^z2HIwI|uG!@XCK85bDo1$Oa;Q4eg`mG$ASK_z+oAH}!3CJPn|CjmgM1fm?+kN0V z1{ml5^8W~LsxTMMIqY7Ex9k_-?QZ)jys@6780H1&t%e{Iali$4Q{XZIvyItciaS0d zLe4vKzWGLY>%S7-ly8N%qj=FzIs5)hQ$fqS${=TM_~R4I*Z29IJO)zJh!_Kx4GHDh z8s<0R;*NlB!EAQr#a6+tmtmMsd1k^4i4`)7fbZfx%h&*C80J+Q$=Q=<^Bx5# zQPxWicdAjvkPmaV)KP!@I(?KH`C5Iv+rCa8f9ah3ZS;{Wn^xY%VD?twu6d5?sQ_(x zggJ;FKIGFQY@eFL(cVx>#31l)njsI(Zm;crw{UX;K9LOxxYQEZ@dSo&V{S z)YYM^lecH}Lw)<~C8rwh;s1(S(UeK^_T^^&iLLU!PEO~21h@CUf3n+8^`SjQiPM(N ezJPBZ?%&=d?J3EFAL}M)+WK!n6eU%Y#Qy;>hm3px literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.frame.hex new file mode 100644 index 0000000..b1fa1db --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.frame.hex @@ -0,0 +1 @@ +0000100389a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164da0f206761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5142726e4a6c593239795a47566b58324e7664573530414b566d62334a726335474472584e6a6147567459565a6c636e4e70623236325a3246745a574e31624851755a6d39796179356c626e5279655335324d4b526a59584a6b3367415372584e6a6147567459565a6c636e4e70623236315a3246745a574e31624851755a6d39796179356a59584a6b4c6e597770325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c3065615630615852735a625258614738676257463549477870633351675953426d62334a725036646a623235305a5868303254464249475a76636d736761584d67636d56685a434269655342745a5731695a584a7a4947397562486b676232356a5a534270644342706379427361584e305a5751757032397764476c76626e4f5367714a705a4b746863327470626d637462576c755a4b52305a586830325352556147556759584e726157356e49453170626d516762476c7a64484d6761475679494739336269426d62334a72637936436f6d6c6b714739775a584a68644739797048526c65485333515734676233426c636d46306233496762476c7a64484d676447686c62533675636d566a623231745a57356b595852706232367259584e726157356e4c573170626d536e5a4756775a57356b6335436c62475668626e504170475a735957664171584a6861584e6c5a463970626f4b6b61326c755a4b524a5a4756686f6d6c6b32534e6c64584a6c6132457463335669633352795958526c4f6d6c6b5a5745365a47567a6179316b623239794c573168634b6c7959576c7a5a575266595853304d6a41794e6930784d5330784d5651784e6a6f304d446f784d6c716f5a47566a6157526c636e4f53325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d625a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b316b5a574e705a47567958324e766457353041715a7a6447463064584f6b6233426c62715a7964577870626d6641716e4a316247566b58325a7962323251705739795a4756794b617876634852706232357a5832686863326a5a5144646b4f54566d597a67335a44557a4d6a51794d4459784d575a6c4e444531597a466b5a5759794e32457a4e4451334e6a4533596d49324f44497a4e5759304e6d557a595759784d7a42685a57457a4e6a466a4e44696d636d566a62334a6b6871317a5932686c625746575a584a7a61573975743264686257566a645778304c6d5a76636d7375636d566a62334a6b4c6e597770325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c3065617876634852706232357a5832686863326a5a5144646b4f54566d597a67335a44557a4d6a51794d4459784d575a6c4e444531597a466b5a5759794e32457a4e4451334e6a4533596d49324f44497a4e5759304e6d557a595759784d7a42685a57457a4e6a466a4e44697859323931626e526c5a463968626e4e335a584a6661575369626a47755957357a643256795a584a6659323931626e5144703246756333646c636e4f553367415172584e6a6147567459565a6c636e4e70623236335a3246745a574e31624851755a47567a61313968626e4e335a584975646a43705957357a6432567958326c6b6f6d347870325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656164766458526a6232316c70304e76645735305a5753696232624171484a6c64484a685933527a774b526a6232526c774b7876634852706232357a5832686863326a5a5144646b4f54566d597a67335a44557a4d6a51794d4459784d575a6c4e444531597a466b5a5759794e32457a4e4451334e6a4533596d49324f44497a4e5759304e6d557a595759784d7a42685a57457a4e6a466a4e44696d5932687661574e6c7132467a61326c755a7931746157356b704735766447572b5647686c4947467a61325679494778706333527a4947686c63694276643234675a6d397961334d75713246756333646c636d566b58324630744449774d6a59744d5445744d5446554d5463364d4441364d44426171334a6c59325670646d566b58324630744449774d6a59744d5445744d5446554d5463364d4441364d44426172324e7359576c7458326c7558325a31644856795a634b6f5957357a643256795a584c5a4a44566d4d474d79596a466c4c5468684e4751744e474d7a596930355a54646d4c5446684d6d497a597a526b4e5755325a716c686458526f62334a7064486d6d54575674596d5679716e4a6c6358566c633352666157545a4a4441774d4441774d4441774c5441774d4441744e4441774d4330344d4441774c5441774d4441774d4441774d4441774d643441454b317a5932686c625746575a584a7a61573975743264686257566a645778304c6d526c633274665957357a643256794c6e5977715746756333646c636c39705a4b4a754d71646d62334a7258326c6b3254426c64584a6c6132457463335669633352795958526c4f6e46315a584e30615739754f6d526c6332737462476c7a64476c755a7931686458526f62334a7064486d6e62335630593239745a6168535a585a7063326c7662714a765a714a754d6168795a58527959574e306338436b5932396b5a63437362334230615739756331396f59584e6f325541335a446b315a6d4d344e3251314d7a49304d6a41324d54466d5a5451784e574d785a47566d4d6a64684d7a51304e7a59784e324a694e6a67794d7a566d4e445a6c4d32466d4d544d77595756684d7a5978597a5134706d4e6f62326c6a5a61746863327470626d637462576c755a4b52756233526c7030466e636d566c5a4336725957357a643256795a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e546f774d467172636d566a5a576c325a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e546f774d4671765932786861573166615735665a6e563064584a6c77716868626e4e335a584a6c63746b6b4d4749355a44646c4d3245744e6d4d794d5330305a6a55344c5745775a4451744f5755345a6a64684e6d4931597a526b7157463164476876636d6c3065615a4e5a5731695a584b71636d56786457567a644639705a4e6b6b4d4441774d4441774d4441744d4441774d4330304d4441774c5467774d4441744d4441774d4441774d4441774d4441793367415172584e6a6147567459565a6c636e4e70623236335a3246745a574e31624851755a47567a61313968626e4e335a584975646a43705957357a6432567958326c6b6f6d347a70325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656164766458526a6232316c70314a6c5a6e567a5a5753696232624171484a6c64484a685933527a774b526a6232526c72573576644639685832526c59326c6b5a584b7362334230615739756331396f59584e6f325541335a446b315a6d4d344e3251314d7a49304d6a41324d54466d5a5451784e574d785a47566d4d6a64684d7a51304e7a59784e324a694e6a67794d7a566d4e445a6c4d32466d4d544d77595756684d7a5978597a5134706d4e6f62326c6a5a61746863327470626d637462576c755a4b52756233526c7030316c49485276627936725957357a643256795a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e6a6f774d467172636d566a5a576c325a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e6a6f774d4671765932786861573166615735665a6e563064584a6c77716868626e4e335a584a6c63746b6b597a4e684d5759795a5451744e3249325a4330305a54686d4c546c684d4749744d574d795a444e6c4e47593159545a697157463164476876636d6c3065615a4e5a5731695a584b71636d56786457567a644639705a4e6b6b4d4441774d4441774d4441744d4441774d4330304d4441774c5467774d4441744d4441774d4441774d4441774d44417a3367415172584e6a6147567459565a6c636e4e70623236335a3246745a574e31624851755a47567a61313968626e4e335a584975646a43705957357a6432567958326c6b6f6d343070325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656164766458526a6232316c716c4a6c64484a6859335270623236696232624171484a6c64484a685933527a6f6d347970474e765a4758417247397764476c76626e4e666147467a614e6c414e3251354e575a6a4f44646b4e544d794e4449774e6a45785a6d55304d54566a4d57526c5a6a493359544d304e4463324d546469596a59344d6a4d315a6a51325a544e685a6a457a4d47466c59544d324d574d304f4b5a6a61473970593258417047357664475841713246756333646c636d566b58324630744449774d6a59744d5445744d5446554d5463364d4463364d44426171334a6c59325670646d566b58324630744449774d6a59744d5445744d5446554d5463364d4463364d44426172324e7359576c7458326c7558325a31644856795a634b6f5957357a643256795a584c5a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b6c686458526f62334a7064486d6d54575674596d5679716e4a6c6358566c633352666157545a4a4441774d4441774d4441774c5441774d4441744e4441774d4330344d4441774c5441774d4441774d4441774d4441774e4b74755a58683058324e31636e4e766373436c59584e666232624e4a38343daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.payload.hex new file mode 100644 index 0000000..592cd84 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.payload.hex @@ -0,0 +1 @@ +81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7401ae7265636f726465645f636f756e7400a5666f726b739183ad736368656d6156657273696f6eb667616d6563756c742e666f726b2e656e7472792e7630a463617264de0012ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e636172642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a57469746c65b457686f206d6179206c697374206120666f726b3fa7636f6e74657874d9314120666f726b2069732072656164206279206d656d62657273206f6e6c79206f6e6365206974206973206c69737465642ea76f7074696f6e739282a26964ab61736b696e672d6d696e64a474657874d9245468652061736b696e67204d696e64206c6973747320686572206f776e20666f726b732e82a26964a86f70657261746f72a474657874b7416e206f70657261746f72206c69737473207468656d2eae7265636f6d6d656e646174696f6eab61736b696e672d6d696e64a7646570656e647390a56c65616e73c0a4666c6167c0a97261697365645f696e82a46b696e64a449646561a26964d923657572656b612d7375627374726174653a696465613a6465736b2d646f6f722d6d6170a97261697365645f6174b4323032362d31312d31315431363a34303a31325aa8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464ad646563696465725f636f756e7402a6737461747573a46f70656ea672756c696e67c0aa72756c65645f66726f6d90a56f7264657229ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a67265636f726486ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034ab6e6578745f637572736f72c0a561735f6f66cd27ce diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.payload.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..6dd129a26c58325656d894803158637072cad279 GIT binary patch literal 2903 zcmc&$O>f&a7*4l8Q0x~7Sb(7i8KORIU$$VtfE{uPiWTU^pvaf85KGKRDxHr@f$iEA z{RM$-ImyzYK)c=sWB2y%OMgpWO3D^@om<3RX9yBVz8~cKyw5}bIQ>Sd1AROhW!n6F zq1g~}`W+~p$>P~Q&4HC8(-COXu@;>pZ<1#PpFyeMfKIic`Qc0xkMz7Vc?iXT$+9pv z9xGr{ap28(Yic9tU+%9a_mq3 zHPHXQ1a0sbeGdA-wY$%XeV3MIC{tE+<+SgFtoaGUquIoZ75c^Hm%W4z3?e zM?ed`n1~VGUz{qMX+$L}jwiS^KKYgd#dOQh|53gSSDMmsDV3Yk!%Jssp8B32yM*9# zo5cOl>l6R(oC8BAr5`T0BjGVWCE#{x$lZ_ysoR4@xP z+)zYamwG%z)2>KpoJMGQ(MD|=>600DoGJCh_S#I9BP{RY48M3Lp=ADGYP(L|`n82& zFr<38{3_wSNU(0gqreY+FD67lNFqi!2tT1g7$z}E(lqY+K_tQ$_asQ*QJ_IgSlFF4 z?flbfM?PO|-^OO6p8UYueo~VWOM?w?4;0emq#%!*0S@ccPpf9EAHCYT`ETd53~Gbm z`A)BfyFLLcSsEsDsJ^z60sKUY#ascBml^C=COIrVa6x8lC^H6Ec`_-afj?SWA`LB% z-z4ODTQV%-TY_}Gm zGX=H2S?SyM+MjE`ZfIX!`?ACKN3Mmgzd5?|JvhqjZG!M(_x(=*S3CO(P@dNz8NCb1 z+F%`$@1tiMBk5lY$zXFNt341UULJ4Z=>?K#KnJZDgYRw+%o4mfw@Y|1Sl2QLFA^ l9UHB*uGQ7rU>$NFg^l5PfqxVStW;X6#gyuS6feGb`5H@3F`xhd literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..fec42753c7b3a324eb479afbd5bb33db2e39adad GIT binary patch literal 298 zcmYk1Jx;?g7>3Jb*b*gGK`ace0EvYmk&-G4a_kSsB(aS@J1xBd=Ro{KD5}KLiGy$( zB8X7u=h6E!n;jiL^(`P$0oVMw4dPxKu+&MdUhcH5@y*c3MpOQMAt+CayolL6t3|m? zi`C7#oS$rqEa}QyASAh3n|x!+yot7gED0g!yD9BbG0mkCpHsJ? ztPVKC1n+~bYFQPl?pjveQN=YRtRAi5BQ5SusBZrV!m+-T=+l!z>RtY+etPa j{5VM4q4t(R;+2!_$c8@_fVWW7S9H{Qe7A59%mM!az7cmj literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.frame.hex new file mode 100644 index 0000000..2f7bf26 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.frame.hex @@ -0,0 +1 @@ +0000012689a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d944676164535a575a316332566b676242535a584e776232357a5a56527662307868636d646c677156696558526c63383441415246777057787062576c307a6741424141413daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.payload.hex new file mode 100644 index 0000000..a69cabb --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.payload.hex @@ -0,0 +1 @@ +81a75265667573656481b0526573706f6e7365546f6f4c6172676582a56279746573ce00011170a56c696d6974ce00010000 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.payload.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..26ac65e1da3564ea44689df43ad5d041dd39c28d GIT binary patch literal 50 zcmZo%9+a9^TAZ5FxFIODxFA2TI5i|c-zTvsJ+*0RQe{bM@i_)Y!GfhZnYo!IKqdnN E03+EG#Q*>R literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..e3ca7d59ae2928dab7b6da815f21c0ace603a889 GIT binary patch literal 527 zcmYk3&5qMB6om)2ya=0CandQuVip9blc>#T5+*<1Ajfu-w2l+mPG-_K;5iU56{<#J z!-^N-X|R|I&F)C&yZ2n(b8+$V)#qc&l)@DY=$Cc|j$O|r)=ekAZacYG-+Mgq4%0s` zURRi3mrneVLU%%0@?PfA4#QLzDdI##eQ7Tj$X;obMWFO=2GqI}o-PfG0I^qUEsDDy zMk9jRlLuU@wB2n9R}-=`j`DCSoi*BJXe6!Pnw&AW?`zUhe%*L&>4Y+XDIjesiv%2K z79OY{))d|@NuF}z#O8*@62bIud!mtj$htvBazCJJ0Btljg0pNxYs+ z-d$nXz>hnW7@bibzz`rvxnUbK(^Eg8a+ literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.frame.hex new file mode 100644 index 0000000..d2fc906 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.frame.hex @@ -0,0 +1 @@ +0000020b89a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164da012867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757755a6d39796131393062323966624746795a32576c5a584a7962334c5a6a575a76636d74666447397658327868636d646c4f69426d62334a7249475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c306553426864434276636d526c636941304d5342706379427759584e304948526f5a5342775957646c49474a315a47646c644342696553427064484e6c6247593749474e76626e5270626e566c4948647064476767593356796332397949474d304d513d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.payload.hex new file mode 100644 index 0000000..3a34206 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.payload.hex @@ -0,0 +1 @@ +81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ae666f726b5f746f6f5f6c61726765a56572726f72d98d666f726b5f746f6f5f6c617267653a20666f726b20657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479206174206f7264657220343120697320706173742074686520706167652062756467657420627920697473656c663b20636f6e74696e7565207769746820637572736f7220633431 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.payload.msgpack new file mode 100644 index 0000000..7964e1f --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.payload.msgpack @@ -0,0 +1 @@ +�©NotStoredƒ­schemaVersion¸gamecult.fork.refusal.v0¤code®fork_too_large¥errorÙ�fork_too_large: fork eureka-substrate:question:desk-listing-authority at order 41 is past the page budget by itself; continue with cursor c41 \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..90e6f352f67634e30345df61b07a1be1abc03364 GIT binary patch literal 462 zcmYk3L2jHd7=@Et^b#3iGZU$cZYDFKkO2fZfW4_awmTSXAlO8ixj+xnNY$u{B4yE4 zPtYUuG$qlf?fSj%>Hpt5J3IgN<*0=*bSHfNRbN~lyIu^`%C3F<+zrjQp6*pg`S0_0 zJDPf1HyEm$)Y4+n^g0Jj=L4qvz>DCH6p;h7WI<5XL@CdL1VX`I&e>oWDhD{rrng$^N-Dk=Tk-NmUUdV zyO&d+J!Q?smKt$6e>zz*RtZgiRO3#z?Tic4d>X{vD2$!mgD3N*=3+2Y`#;&JY%A>S U?;iiG&e4eBUb1Pj@Bd}}0eDQP9RL6T literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.frame.hex new file mode 100644 index 0000000..87484da --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.frame.hex @@ -0,0 +1 @@ +000001ca87a96d6573736167654964a36d2d36a96f7065726174696f6ea4666f726ba77061796c6f6164da01186761524762334a72684b52746157356b706d5631636d56725961647a64574a715a574e30325351355a54686b4e324d3259693031595451354c54517a4f444974596a46684d43316d4f5755345a44646a4e6d49315954536f5a47566a6157526c636e4f53325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d625a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b646d62334a7258326c6b3254426c64584a6c6132457463335669633352795958526c4f6e46315a584e30615739754f6d526c6332737462476c7a64476c755a7931686458526f62334a7064486b3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.payload.hex new file mode 100644 index 0000000..6d3fc48 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.payload.hex @@ -0,0 +1 @@ +81a4466f726b84a46d696e64a6657572656b61a77375626a656374d92439653864376336622d356134392d343338322d623161302d663965386437633662356134a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.payload.msgpack new file mode 100644 index 0000000..9657c0a --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.payload.msgpack @@ -0,0 +1 @@ +�¤Fork„¤mind¦eureka§subjectÙ$9e8d7c6b-5a49-4382-b1a0-f9e8d7c6b5a4¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d§fork_idÙ0eureka-substrate:question:desk-listing-authority \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..35089832165ccbec74b1d148992e4cada388703b GIT binary patch literal 3630 zcmd5Ft6A`!RVrdYk1z;Z^%A z{pl_%;$L#F;hD$&`f9%O4AqyG#qMBnKQ)@by{#zHGh&OOl~@f${OA`Xb#0Nml2tN! zm~{55#H!hRIb5agDOdH7CT1|1YEOJ?J@IX4zphy%rz9CSo;c2l9-oL~HN>vMm7|Mp zstYtx1Y+iAW?L;&;T%yO6Gtyr_Bp4Lk^2d)T*neT74~q9VeS|?Q_s9oSmrlVSc`YQ z%3CH+!-YzYG%3H$k$!|Yc`!E#-rnqb|IIX~%@lkq@j_h=7plm8d&y~vcRm`H!Df2& z6P+(qF2HCPYVV&Dd6fFnRPgL^O^2GCB|JVLrS%Z6Nh4OCJ&g1D#n)0XGP0S56jj~1 zccu+C^0f3Eh_jhy#98coiPe{LR_!X&_MW}TV1M4-gD+_@Q>_78v8lhG9;s?Iu45eO z{$UTe2di2gh7bqD2>J38Bkb?(P~H2j{LZh&;2x*ldK+t2331o-wH%Y=9Fo^^4*qhA ztk{7#b_b*Aaf9&0$;fu`Mg zjXRPqk3Nw(U=hAugRwMoW(qy9+jvjS)?pg$5 z(Q_Y-B!ZL*?qBrh>-vJNOFljY-alK+D<$$JsCTu)1YkvCoN}mHZCPV)82YI$LH@eA zi{0cFYJS)<1pG1VctL)G7uaK7=C?MvD z*c?5>_UNK>=-DFVWe9a>2@G<7I)wN6;u#{~R3jx=4T(I|%*Wvd6jVF&yiL~UzUe};qm)cJH zIu|P7OB}fsHg8}kTWr!1o^4@Fy#svXA}eRluq}{Yx;C#mJ#i2b*&KA9YS{xmoz>)E zD^QOiXfziBkLb{U?1`ft0GAj2SNa|y{s#+_s{bzwLif8ecO8B%bN85oz{anGzha5VvbVZ{4$wmU}(gHMq4BeMUG&N`g&% za%~H2+_T%R2YI4rKH?GaKI|ck+LsKT3nE|4LF=E%H+uAZzPZ|dLB4tOk@#lwsrcpq zd~?yNu0K9}s9(3hKUxfZRZOV-`7uM~i)K;zSz6TV(KEH^YWoGTvEfj6cTltX^7Y`m z_tz)YO-eJiPj|mx-(R^$L|V| z`(-OAN_ejQNo!uELG|k40r$vOcv}X=aZllq_uHf0-j`Wj(7>qEYM;Q`yUW_kpe};g S`?;*VsxE)^C=K!|V1EPOJoFC$ literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.frame.hex new file mode 100644 index 0000000..db22dba --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.frame.hex @@ -0,0 +1 @@ +00000e2a89a96d6573736167654964a36d2d36a96f7065726174696f6ea4666f726ba77061796c6f6164da0d486761524762334a726771566c626e527965594f7463324e6f5a573168566d567963326c7662725a6e5957316c593356736443356d62334a724c6d567564484a354c6e597770474e68636d546541424b7463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d4e68636d5175646a436e5a6d3979613139705a4e6b775a5856795a5774684c584e31596e4e30636d46305a5470786457567a64476c76626a706b5a584e724c57787063335270626d6374595856306147397961585235705852706447786c7446646f6279427459586b6762476c7a6443426849475a76636d732f70324e76626e526c6548545a4d5545675a6d397961794270637942795a57466b49474a354947316c62574a6c636e4d676232357365534276626d4e6c49476c3049476c7a494778706333526c5a43366e623342306157397563354b436f6d6c6b7132467a61326c755a7931746157356b7048526c6548545a4a46526f5a53426863327470626d636754576c755a43427361584e306379426f5a5849676233647549475a76636d747a4c6f4b696157536f6233426c636d463062334b6b64475634644c64426269427663475679595852766369427361584e3063794230614756744c7135795a574e766257316c626d526864476c766271746863327470626d637462576c755a4b646b5a58426c626d527a6b4b56735a5746756338436b5a6d78685a384370636d46706332566b58326c75677152726157356b70456c6b5a5747696157545a49325631636d56725953317a64574a7a64484a68644755366157526c5954706b5a584e724c575276623349746257467771584a6861584e6c5a463968644d436f5a47566a6157526c636e4f517257526c59326c6b5a584a6659323931626e5143706e4e30595852316336527663475675706e4a3162476c755a384371636e56735a5752665a6e4a76625a436c62334a6b5a5849707247397764476c76626e4e666147467a614e6c414e3251354e575a6a4f44646b4e544d794e4449774e6a45785a6d55304d54566a4d57526c5a6a493359544d304e4463324d546469596a59344d6a4d315a6a51325a544e685a6a457a4d47466c59544d324d574d304f4b5a795a574e76636d534772584e6a6147567459565a6c636e4e70623236335a3246745a574e31624851755a6d3979617935795a574e76636d5175646a436e5a6d3979613139705a4e6b775a5856795a5774684c584e31596e4e30636d46305a5470786457567a64476c76626a706b5a584e724c57787063335270626d63745958563061473979615852357247397764476c76626e4e666147467a614e6c414e3251354e575a6a4f44646b4e544d794e4449774e6a45785a6d55304d54566a4d57526c5a6a493359544d304e4463324d546469596a59344d6a4d315a6a51325a544e685a6a457a4d47466c59544d324d574d304f4c466a623356756447566b583246756333646c636c39705a4b4a754d613568626e4e335a584a6c636c396a6233567564414f6e5957357a64325679633554654142437463324e6f5a573168566d567963326c766272646e5957316c593356736443356b5a584e72583246756333646c636935324d4b6c68626e4e335a584a6661575369626a476e5a6d3979613139705a4e6b775a5856795a5774684c584e31596e4e30636d46305a5470786457567a64476c76626a706b5a584e724c57787063335270626d63745958563061473979615852357032393164474e766257576e51323931626e526c5a4b4a765a73436f636d5630636d466a6448504170474e765a4758417247397764476c76626e4e666147467a614e6c414e3251354e575a6a4f44646b4e544d794e4449774e6a45785a6d55304d54566a4d57526c5a6a493359544d304e4463324d546469596a59344d6a4d315a6a51325a544e685a6a457a4d47466c59544d324d574d304f4b5a6a614739705932577259584e726157356e4c573170626d536b626d39305a6235556147556759584e725a58496762476c7a64484d6761475679494739336269426d62334a72637936725957357a643256795a575266595853304d6a41794e6930784d5330784d5651784e7a6f774d446f774d467172636d566a5a576c325a575266595853304d6a41794e6930784d5330784d5651784e7a6f774d446f774d4671765932786861573166615735665a6e563064584a6c77716868626e4e335a584a6c63734370595856306147397961585235706b316c62574a6c637170795a5846315a584e3058326c6b325351774d4441774d4441774d4330774d4441774c5451774d4441744f4441774d4330774d4441774d4441774d4441774d4448654142437463324e6f5a573168566d567963326c766272646e5957316c593356736443356b5a584e72583246756333646c636935324d4b6c68626e4e335a584a6661575369626a4b6e5a6d3979613139705a4e6b775a5856795a5774684c584e31596e4e30636d46305a5470786457567a64476c76626a706b5a584e724c57787063335270626d63745958563061473979615852357032393164474e766257576f556d563261584e706232366962326169626a476f636d5630636d466a6448504170474e765a4758417247397764476c76626e4e666147467a614e6c414e3251354e575a6a4f44646b4e544d794e4449774e6a45785a6d55304d54566a4d57526c5a6a493359544d304e4463324d546469596a59344d6a4d315a6a51325a544e685a6a457a4d47466c59544d324d574d304f4b5a6a614739705932577259584e726157356e4c573170626d536b626d39305a6164425a334a6c5a575175713246756333646c636d566b58324630744449774d6a59744d5445744d5446554d5463364d4455364d44426171334a6c59325670646d566b58324630744449774d6a59744d5445744d5446554d5463364d4455364d44426172324e7359576c7458326c7558325a31644856795a634b6f5957357a643256795a584c417157463164476876636d6c3065615a4e5a5731695a584b71636d56786457567a644639705a4e6b6b4d4441774d4441774d4441744d4441774d4330304d4441774c5467774d4441744d4441774d4441774d4441774d4441793367415172584e6a6147567459565a6c636e4e70623236335a3246745a574e31624851755a47567a61313968626e4e335a584975646a43705957357a6432567958326c6b6f6d347a70325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656164766458526a6232316c70314a6c5a6e567a5a5753696232624171484a6c64484a685933527a774b526a6232526c72573576644639685832526c59326c6b5a584b7362334230615739756331396f59584e6f325541335a446b315a6d4d344e3251314d7a49304d6a41324d54466d5a5451784e574d785a47566d4d6a64684d7a51304e7a59784e324a694e6a67794d7a566d4e445a6c4d32466d4d544d77595756684d7a5978597a5134706d4e6f62326c6a5a61746863327470626d637462576c755a4b52756233526c7030316c49485276627936725957357a643256795a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e6a6f774d467172636d566a5a576c325a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e6a6f774d4671765932786861573166615735665a6e563064584a6c77716868626e4e335a584a6c63734370595856306147397961585235706b316c62574a6c637170795a5846315a584e3058326c6b325351774d4441774d4441774d4330774d4441774c5451774d4441744f4441774d4330774d4441774d4441774d4441774d4450654142437463324e6f5a573168566d567963326c766272646e5957316c593356736443356b5a584e72583246756333646c636935324d4b6c68626e4e335a584a6661575369626a536e5a6d3979613139705a4e6b775a5856795a5774684c584e31596e4e30636d46305a5470786457567a64476c76626a706b5a584e724c57787063335270626d63745958563061473979615852357032393164474e7662575771556d5630636d466a64476c7662714a765a73436f636d5630636d466a64484f69626a4b6b5932396b5a63437362334230615739756331396f59584e6f325541335a446b315a6d4d344e3251314d7a49304d6a41324d54466d5a5451784e574d785a47566d4d6a64684d7a51304e7a59784e324a694e6a67794d7a566d4e445a6c4d32466d4d544d77595756684d7a5978597a5134706d4e6f62326c6a5a63436b626d39305a6343725957357a643256795a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e7a6f774d467172636d566a5a576c325a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e7a6f774d4671765932786861573166615735665a6e563064584a6c77716868626e4e335a584a6c63734370595856306147397961585235706b316c62574a6c637170795a5846315a584e3058326c6b325351774d4441774d4441774d4330774d4441774c5451774d4441744f4441774d4330774d4441774d4441774d4441774d44536c59584e666232624e4a38343daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.payload.hex new file mode 100644 index 0000000..50268be --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.payload.hex @@ -0,0 +1 @@ +81a4466f726b82a5656e74727983ad736368656d6156657273696f6eb667616d6563756c742e666f726b2e656e7472792e7630a463617264de0012ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e636172642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a57469746c65b457686f206d6179206c697374206120666f726b3fa7636f6e74657874d9314120666f726b2069732072656164206279206d656d62657273206f6e6c79206f6e6365206974206973206c69737465642ea76f7074696f6e739282a26964ab61736b696e672d6d696e64a474657874d9245468652061736b696e67204d696e64206c6973747320686572206f776e20666f726b732e82a26964a86f70657261746f72a474657874b7416e206f70657261746f72206c69737473207468656d2eae7265636f6d6d656e646174696f6eab61736b696e672d6d696e64a7646570656e647390a56c65616e73c0a4666c6167c0a97261697365645f696e82a46b696e64a449646561a26964d923657572656b612d7375627374726174653a696465613a6465736b2d646f6f722d6d6170a97261697365645f6174c0a8646563696465727390ad646563696465725f636f756e7402a6737461747573a46f70656ea672756c696e67c0aa72756c65645f66726f6d90a56f7264657229ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a67265636f726486ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034a561735f6f66cd27ce diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.payload.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..96dfdb2c0910b9c19518deb09dbec6a7963e5191 GIT binary patch literal 2549 zcmc&!%Wl&^6s_O~5MN-4N{B@wzw)3PsF09YbW>Fzv9Tte>)49z2{YrCEGZJ}3h@Pz z6Q?aLLMn8JEcUR^rr*LH$Atz;DkTj>mYtb7ckawR_ncShW5zqX1BfN>y*?7I4-t6^ zTm&rs+$IsYNhqrxVyXqCx~-=!;q+|f_B8m@G+1E}o2f`Apko~p4jobvNlQphB&@wi zKuFB8MnQBcVSt0UT_K6|84qM{AOjh~$upm65$S1aT_c*>?m_0VSi(y=Hr9&?Ef5+9 zLbX;;iy&&@=`|LIJ$$*K1yZf6c%apcbyZIBcDEnULn1mVXB5OVEjC=;@aD*rhy zOJaFrC?t`INb$7skS8HtKR>`P=JYs=_6Ce&*uD2&*XH(a|pRCVC=&2-0{PIt@%vrx}m z^RYh`HN(;%K|8>)jr*~2JQ?7ySiPA|#^T-C%B{cMPjz^?3Epr2;&$^3Bx8xh?t<)* zVisthdHIL~ip!PQPcEa7Uvoi*uFnD&F7|O6GYMZx*`Q36&VConVOcUPq&zWovtBU_ ze720nny%yX{1CZ^U|UuCa=-^SBte9-aJ)pKk$fGUvykuqbd2Ffq5T66g|#TxS=Gy5 zMSUx_dNtKc4a=V~7jfp|#Emv#J5WB0%wc2wC+@QKHV1UZStQl2BB=l@lO%I4lB~r^ z$~M7E(2_4v*Ace9L!8oJ|B=+P4bUWGGwG^dMOOh>CSBHCblHp3b+9=xz=^7U`C&gc fm+6RkVT1WB4JogiX>%r85T?>Ee)q*9L_DylYlNhc393FaTA}#0So75t;$1-k{+Y{ zg$1(_4QE@5<_?Fsz{SeZXpM=)J(G6p_w+^hwPKQs>pGS+TInUq0wyzErooZtt0P~m zDvmx+S&<1AZTt=6DMs0mxWJGi8IgRV*7-c(sPdg+1Zmtd7avN`mWxe;)7ar6InZ!& z^m9$4ti$i(`YFd3iJ+)_dv`JLL9vF{YWL@&t0indpv1s*e7;0dR)Hjct8P~-HRNFS zUo@DuQ3i&6a5m3418uzBf2;8PI*r{7-(rYP&N{rx|QIWozl#`GpOj T)37H$j3u@+zckzcv%nt#$``eN literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.frame.hex new file mode 100644 index 0000000..57ca2f8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.frame.hex @@ -0,0 +1 @@ +000001d989a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164d9f867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757755a6d39796131393062323966624746795a32576c5a584a7962334c5a616d5a76636d74666447397658327868636d646c4f69426d62334a7249475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30655342706379427759584e304948526f5a5342795a584e776232357a5a534269623356755a4342696553427064484e6c6247593daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.payload.hex new file mode 100644 index 0000000..660a3fb --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.payload.hex @@ -0,0 +1 @@ +81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ae666f726b5f746f6f5f6c61726765a56572726f72d96a666f726b5f746f6f5f6c617267653a20666f726b20657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f7269747920697320706173742074686520726573706f6e736520626f756e6420627920697473656c66 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.payload.msgpack new file mode 100644 index 0000000..a31a344 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.payload.msgpack @@ -0,0 +1 @@ +�©NotStoredƒ­schemaVersion¸gamecult.fork.refusal.v0¤code®fork_too_large¥errorÙjfork_too_large: fork eureka-substrate:question:desk-listing-authority is past the response bound by itself \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..40186373edea6957be586ae23ccf0b9eb0da45b5 GIT binary patch literal 365 zcmYk2TS~(~7=`PCT1?Xx^XjEhk%+A%c3xyIf74uMCd@w*$qsZKidrNTeDTSJxEn() z(&z8vaL(!V4zj1U1Voy`g?N_z(OOqvsLix`S!z?fRkSHJ6+e$DEw7nByA@&3kYs*p z?xLKAv0cQZ) literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.frame.hex new file mode 100644 index 0000000..4299eb0 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.frame.hex @@ -0,0 +1 @@ +0000016989a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164d98867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757735a6d397961313931626d74756233647570575679636d3979756d5a76636d746664573572626d3933626a6f67626d38676333566a6143426d62334a72af7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.payload.hex new file mode 100644 index 0000000..2634d13 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.payload.hex @@ -0,0 +1 @@ +81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ac666f726b5f756e6b6e6f776ea56572726f72ba666f726b5f756e6b6e6f776e3a206e6f207375636820666f726b diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.payload.msgpack new file mode 100644 index 0000000..fbd6335 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.payload.msgpack @@ -0,0 +1 @@ +�©NotStoredƒ­schemaVersion¸gamecult.fork.refusal.v0¤code¬fork_unknown¥errorºfork_unknown: no such fork \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..181317a6cb0e7dda51352ad73cb4d043b449c1a2 GIT binary patch literal 462 zcmb7>O-{ow5QXV2xJ0Y`6eJdGN=s@ zj=*UMr~d{fFj-9#F5LK@=^tnt-$cCb$EB1CS+o;EjCyS zHfn|=x9U7vn26%0A zmc<`m%2W2|D(FNR@PGcQJp{H9YS!scyCN*?E~c9{LZoD#nz*^!MMzx26Dz2v3aYsc zpO15Czs}t=f40W!f?g|wBvJ0f)7k0^-@0G;R@!gRJe?OTpLBsbsTj$w%y&EHwaMBi zDlLc-<+{S0>c%~{=LVc3tup45)7raLk!Dw69<|hWmB4^Ke0yB^cEydGXmwtO-3;Dk zhtLo`pU)E0V3#Owep?}OfjC9{>@d7v9tQv2tctoB_|`Miu*RmLiqNw}RNzC1#!bAN zUBa9Q(+~tt9hA=tVVhtAaQ1$+6LY@tDf(^EZr{IZk9x- zmFGS<9@xxhP2fYE-K=E3c?dOrzMr%0*X8OMc)Jqz7cvi_w#vBSj`*6-!sF}`8E!{? z`w|^L?E!gk+jTvLI3Px-SD4$$khi*h_-#Y!cf}!(Gx@&HR&E1vpQG>R8Ov`8`+i=5 zzbZm*=0hBZ+CB|&EJemV1J2|y6S-=XyC=F+d+u31-~jyWp90So)Opm%zJ!_y{`70@ zTM5J|m_A83o|nvUQ)wF&n;qJ1RhG_DoSQecQ%2OWv$bQV7@6(H+iUEo+^Az6F~SO4 zxPs&%vlg@C#@lMlA<5cdDNdIyF{rwks|hx?%GwcPV^EDc>_|NLZl%K6NddqUV~c%{(`|US*vRxIRDZhkitIt$mTT z!p4_t75T0hdI*i4Ahtf})B%f7>tgNcDmN0?Ln!et7xRsxi7ID*y6hS zcA_V|n8Gs)YRwW9xfOqd?Fo^FdZLva1bwLTXQ+L&Dj)S?{u+3|&9Q+yn(AWhBndIL zzyr9aM`g?$p06D}0bc28Z)e2YBUXz9>H(Z;Rs1>69S^Vq8VCI@Ui)F_8EY}U!kLF> z?F_#yk^j$?kz9XW`(%UpXu4eeuRaC7L+#7s{IXAL$-5H20`zZXopH?vZIxI=&P;IM z5&>Gak>(zuS0&$zO`)rxfrI93a=B;I4jN|+n)7*lpiQmXG8WC7PkN?KO6rpm?We$NnaXlJB514P-c)cpMNr$EIoliX>kaxU z$0<*6zg0+2ZvZD>3+2KmRGjeN{t#Zh@IpnaJ`*DO+VvKMqrLQ zGJjRWx$bj}KFm7V17Gml4u8)T<&Ld`#MOQ6-#F~P62JA|jNepCKo0x=#&0JNxCOY~ z2d<-lamdU65#Cf`KEuq?y%BHe&%oQ=_Dy)BJxMUk3(#9FK_+5=3+5!iWddd!v&A0n z*n$W-@5uS)8{w`0N_bPg72ay`Nk65||3p(k%R9K2T$tfcPsZdr%)teD38bbGF$FFg z6OW}EnBRzlI|8}|v)PU1dj&ggieWxwsR=VAR>&d(zT-!l(h*KE%&S`@XHS+cdu-@1 z^TKorbLM8nVE&j?6#3I?&{2Q-Hhq*D`Br_r+rCX7f9{<8ZS;|>+$2&b7uG06fB4~t zA78JZ%OWcIu{iwg^{!e4QT8qjn)qq-@zrD(C2=17JvkqaMe&};P4>G}+*D=J#P2Wa z$7Xo##-|3Zw*QFEqAlX~{in`q^$jHs$#g#rC;qk2$>icz^dU WKA++&_*2sc?Ya3&5Jhp-#{6I4VOO{S literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.response.frame.hex new file mode 100644 index 0000000..5dbf346 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.response.frame.hex @@ -0,0 +1 @@ +00000f5689a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164da0e746761524762334a726771566c626e527965594f7463324e6f5a573168566d567963326c7662725a6e5957316c593356736443356d62334a724c6d567564484a354c6e597770474e68636d546541424b7463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d4e68636d5175646a436e5a6d3979613139705a4e6b775a5856795a5774684c584e31596e4e30636d46305a5470786457567a64476c76626a706b5a584e724c57787063335270626d6374595856306147397961585235705852706447786c7446646f6279427459586b6762476c7a6443426849475a76636d732f70324e76626e526c6548545a4d5545675a6d397961794270637942795a57466b49474a354947316c62574a6c636e4d676232357365534276626d4e6c49476c3049476c7a494778706333526c5a43366e623342306157397563354b436f6d6c6b7132467a61326c755a7931746157356b7048526c6548545a4a46526f5a53426863327470626d636754576c755a43427361584e306379426f5a5849676233647549475a76636d747a4c6f4b696157536f6233426c636d463062334b6b64475634644c64426269427663475679595852766369427361584e3063794230614756744c7135795a574e766257316c626d526864476c766271746863327470626d637462576c755a4b646b5a58426c626d527a6b4b56735a5746756338436b5a6d78685a384370636d46706332566b58326c75677152726157356b70456c6b5a5747696157545a49325631636d56725953317a64574a7a64484a68644755366157526c5954706b5a584e724c575276623349746257467771584a6861584e6c5a463968644d436f5a47566a6157526c636e4f53325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d625a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b316b5a574e705a47567958324e766457353041715a7a6447463064584f6b6233426c62715a7964577870626d6641716e4a316247566b58325a7962323251705739795a4756794b617876634852706232357a5832686863326a5a5144646b4f54566d597a67335a44557a4d6a51794d4459784d575a6c4e444531597a466b5a5759794e32457a4e4451334e6a4533596d49324f44497a4e5759304e6d557a595759784d7a42685a57457a4e6a466a4e44696d636d566a62334a6b6871317a5932686c625746575a584a7a61573975743264686257566a645778304c6d5a76636d7375636d566a62334a6b4c6e597770325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c3065617876634852706232357a5832686863326a5a5144646b4f54566d597a67335a44557a4d6a51794d4459784d575a6c4e444531597a466b5a5759794e32457a4e4451334e6a4533596d49324f44497a4e5759304e6d557a595759784d7a42685a57457a4e6a466a4e44697859323931626e526c5a463968626e4e335a584a6661575369626a47755957357a643256795a584a6659323931626e5144703246756333646c636e4f553367415172584e6a6147567459565a6c636e4e70623236335a3246745a574e31624851755a47567a61313968626e4e335a584975646a43705957357a6432567958326c6b6f6d347870325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656164766458526a6232316c70304e76645735305a5753696232624171484a6c64484a685933527a774b526a6232526c774b7876634852706232357a5832686863326a5a5144646b4f54566d597a67335a44557a4d6a51794d4459784d575a6c4e444531597a466b5a5759794e32457a4e4451334e6a4533596d49324f44497a4e5759304e6d557a595759784d7a42685a57457a4e6a466a4e44696d5932687661574e6c7132467a61326c755a7931746157356b704735766447572b5647686c4947467a61325679494778706333527a4947686c63694276643234675a6d397961334d75713246756333646c636d566b58324630744449774d6a59744d5445744d5446554d5463364d4441364d44426171334a6c59325670646d566b58324630744449774d6a59744d5445744d5446554d5463364d4441364d44426172324e7359576c7458326c7558325a31644856795a634b6f5957357a643256795a584c5a4a44566d4d474d79596a466c4c5468684e4751744e474d7a596930355a54646d4c5446684d6d497a597a526b4e5755325a716c686458526f62334a7064486d6d54575674596d5679716e4a6c6358566c633352666157545a4a4441774d4441774d4441774c5441774d4441744e4441774d4330344d4441774c5441774d4441774d4441774d4441774d643441454b317a5932686c625746575a584a7a61573975743264686257566a645778304c6d526c633274665957357a643256794c6e5977715746756333646c636c39705a4b4a754d71646d62334a7258326c6b3254426c64584a6c6132457463335669633352795958526c4f6e46315a584e30615739754f6d526c6332737462476c7a64476c755a7931686458526f62334a7064486d6e62335630593239745a6168535a585a7063326c7662714a765a714a754d6168795a58527959574e306338436b5932396b5a63437362334230615739756331396f59584e6f325541335a446b315a6d4d344e3251314d7a49304d6a41324d54466d5a5451784e574d785a47566d4d6a64684d7a51304e7a59784e324a694e6a67794d7a566d4e445a6c4d32466d4d544d77595756684d7a5978597a5134706d4e6f62326c6a5a61746863327470626d637462576c755a4b52756233526c7030466e636d566c5a4336725957357a643256795a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e546f774d467172636d566a5a576c325a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e546f774d4671765932786861573166615735665a6e563064584a6c77716868626e4e335a584a6c63746b6b4d4749355a44646c4d3245744e6d4d794d5330305a6a55344c5745775a4451744f5755345a6a64684e6d4931597a526b7157463164476876636d6c3065615a4e5a5731695a584b71636d56786457567a644639705a4e6b6b4d4441774d4441774d4441744d4441774d4330304d4441774c5467774d4441744d4441774d4441774d4441774d4441793367415172584e6a6147567459565a6c636e4e70623236335a3246745a574e31624851755a47567a61313968626e4e335a584975646a43705957357a6432567958326c6b6f6d347a70325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656164766458526a6232316c70314a6c5a6e567a5a5753696232624171484a6c64484a685933527a774b526a6232526c72573576644639685832526c59326c6b5a584b7362334230615739756331396f59584e6f325541335a446b315a6d4d344e3251314d7a49304d6a41324d54466d5a5451784e574d785a47566d4d6a64684d7a51304e7a59784e324a694e6a67794d7a566d4e445a6c4d32466d4d544d77595756684d7a5978597a5134706d4e6f62326c6a5a61746863327470626d637462576c755a4b52756233526c7030316c49485276627936725957357a643256795a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e6a6f774d467172636d566a5a576c325a575266595853304d6a41794e6930784d5330784d5651784e7a6f774e6a6f774d4671765932786861573166615735665a6e563064584a6c77716868626e4e335a584a6c63746b6b597a4e684d5759795a5451744e3249325a4330305a54686d4c546c684d4749744d574d795a444e6c4e47593159545a697157463164476876636d6c3065615a4e5a5731695a584b71636d56786457567a644639705a4e6b6b4d4441774d4441774d4441744d4441774d4330304d4441774c5467774d4441744d4441774d4441774d4441774d44417a3367415172584e6a6147567459565a6c636e4e70623236335a3246745a574e31624851755a47567a61313968626e4e335a584975646a43705957357a6432567958326c6b6f6d343070325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656164766458526a6232316c716c4a6c64484a6859335270623236696232624171484a6c64484a685933527a6f6d347970474e765a4758417247397764476c76626e4e666147467a614e6c414e3251354e575a6a4f44646b4e544d794e4449774e6a45785a6d55304d54566a4d57526c5a6a493359544d304e4463324d546469596a59344d6a4d315a6a51325a544e685a6a457a4d47466c59544d324d574d304f4b5a6a61473970593258417047357664475841713246756333646c636d566b58324630744449774d6a59744d5445744d5446554d5463364d4463364d44426171334a6c59325670646d566b58324630744449774d6a59744d5445744d5446554d5463364d4463364d44426172324e7359576c7458326c7558325a31644856795a634b6f5957357a643256795a584c5a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b6c686458526f62334a7064486d6d54575674596d5679716e4a6c6358566c633352666157545a4a4441774d4441774d4441774c5441774d4441744e4441774d4330344d4441774c5441774d4441774d4441774d4441774e4b5668633139765a73306e7a673d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.response.payload.hex new file mode 100644 index 0000000..bdede68 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.response.payload.hex @@ -0,0 +1 @@ +81a4466f726b82a5656e74727983ad736368656d6156657273696f6eb667616d6563756c742e666f726b2e656e7472792e7630a463617264de0012ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e636172642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a57469746c65b457686f206d6179206c697374206120666f726b3fa7636f6e74657874d9314120666f726b2069732072656164206279206d656d62657273206f6e6c79206f6e6365206974206973206c69737465642ea76f7074696f6e739282a26964ab61736b696e672d6d696e64a474657874d9245468652061736b696e67204d696e64206c6973747320686572206f776e20666f726b732e82a26964a86f70657261746f72a474657874b7416e206f70657261746f72206c69737473207468656d2eae7265636f6d6d656e646174696f6eab61736b696e672d6d696e64a7646570656e647390a56c65616e73c0a4666c6167c0a97261697365645f696e82a46b696e64a449646561a26964d923657572656b612d7375627374726174653a696465613a6465736b2d646f6f722d6d6170a97261697365645f6174c0a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464ad646563696465725f636f756e7402a6737461747573a46f70656ea672756c696e67c0aa72756c65645f66726f6d90a56f7264657229ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a67265636f726486ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034a561735f6f66cd27ce diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.response.payload.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..a90ade37ff68951053bfd5bbb712a96a36abba7b GIT binary patch literal 2773 zcmc&#O>Y!87)Ip}p#Fl9h=h9ZYCNBtTuOw5#37dmfz->&W53RhvNI00C$L9?#2F!e zLA1NGNgAq3wA`u{cY5CozooBdCnafNL0LXVqZ!$LUcdJH_<8(zbX)1&gE5p=51*VG z-iLzS18s6u{<*^n;MKr(Bw{)Zq_gjjIMd?Yl^-vH|F{Tt7{t~tvX_gw50!>pMog6% ztC@w32Nf8LSvCZiT{6hAQSJ~{*}l@b9gb~o2k`2CUwH)^dT!ifo}2FHnyb>nBRi*? zO^25oPXiNPHuMT8GF-i<%E1sn9K77RaTgDwQ>#6f(>y;o%*6>ayDn#umtxdRxOTS> z-lEsr!rms#czw{GdRR8eOvi;z)gIt3ly2f)ZPh3sU(yIS;!+1Ln7yEezq=B2pLIuKuILyE@8fi5ZaX~ zOR^Y|r|qmguBy`hFfo?d%8YR56e_oPHn|co`@#j8@V?49oY%ytR2KefC5V!9n*Fc%PTG=T zA?>Tc4-!HtzISQ5;rsaha)R7LzV9miVZd*E!14km){~V*Q+hK!W8w3(a`dl7O|}}d zIo8m4nX|byzkP`NlgNF#?&F&6LodZ{usXZbZP?G9u_L{B`o0sv_2!NSwBlt<#-C!c z0W4$kbNp;&CWA{c8LrM`y$!O$i{b-1Jw*}qSg*C}ZzS4!3p}gTQcjakaoPZuamqtR zWdIRLvjlH4yqU;4^D{zuAVR=93-76wISntxX|y`0$J-0PUFh?@{T`OV*EHZ+C-vi}AE literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-whole.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-whole.payload.hex new file mode 100644 index 0000000..28d9000 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-whole.payload.hex @@ -0,0 +1 @@ +86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-whole.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-whole.payload.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..6db3bbb76cd7fe8157068fbe807bad2903044f6b GIT binary patch literal 2068 zcmc(f&5qMB5XX^t0Un`;y}F7W=c7G=IPaxeao`d;_PB}K#9%vJ@d7*nPf)Ek-7U)s z35gr3c#B?m7VNZq9G0xq!eUuYCZ1U1`OSac-WXm$#a@6mrK&$qSp~eA+o4o?Hq^kC z7Q;o*dNV7!8P4o>AGbT(y!-a($)E1$qun3k?sxV3afe^FRgLAUg7*0?%B7NCooRCXisFK5)nVHXfRo3F7Ru;aT6)AM|n`J9ziR@RwptSgGIs~;4$Cy@*|$uXdmQGqBA zb2NfPBFso0aw;N-<<-v;xjcm`2Ys!ft0iA%C&7Lo|3uU;sb9RgANU};5DqB5@)_F((*N8KsB?f;yb3Ojw*pj^@!M$w4H; z<4Lv?kc|OxhfZ&th%%N5;H3clK%#9=VPKW&Wg6edv~%cV%0q@F0g945cItE_(TD{( z!kmZ@9An4zXr|#oOzH7VuP3{HyVK{d_PeafzihypuIq04`l@fOy?wQF=p*+aYNX!* DB)03} literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-withheld.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-withheld.payload.hex new file mode 100644 index 0000000..dafd063 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-withheld.payload.hex @@ -0,0 +1 @@ +86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-withheld.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-withheld.payload.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..d00019fd42dd7526ea4435980a3d14c671859ecf GIT binary patch literal 1920 zcmc(f&2AGh5XX^t0UluwTsK;Kzd}x+o_mQZ4qR4_Jvv625>wv3 z%-M^S(_zli7$-Q&5aN)alp+Qk=Oj*&JVW_pk_~a3@+53_nvIAuT2P)Emz1cAqxAZ9e>X^6XFd^U-b&are9Y{kYvP+o;-7RY7yqN4ZqIU1@NM zqSmzMR557(azZX>sRV^JF*Bd{N=SAtm4$C7B?Rsx=)f~ELFwMqnG8HhGgxm@<`QYY z;0R|SLT;X*d=y1)zF9l=AQnE;4?En@IT2MMWWj6eisJj~7lronj{e0lR1?rw8oDy^ z74$sXjL<*B#4kg?cylw*PT=tV;8tT;2;T^foGYyV#IAWY)c|ZOr1UXJox>p^;hiAG z`$KBRz-t5Sf$q9?rC*SO0Tm4NKWeq+7y_%*R$SR*xH^YJ;)-{|mF$n}YTOH8-&OZ| axRm&iir98C*lrU#uXS66u5&mfl;kIrTf*D` literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..92b9314f2dfc77329f5c43f334e233569eec9266 GIT binary patch literal 266 zcmYk1%}T>S6h_;PPvSNqvBh1NQgk8MLWFGO_RcjOC-cL-GX)>P=MaB_gn}DaK8SB) zutB;$hmZ43CexFr&XNc;bg7L^m4tyM~?ZqcXS|t~?9aw*086_qIuM#09d1rbc HjHG@6kJ@W? literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.frame.hex new file mode 100644 index 0000000..3fdedc6 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.frame.hex @@ -0,0 +1 @@ +0000010689a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d924676164535a575a316332566b7230317063334e70626d644a5a47567564476c3065513d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.payload.hex new file mode 100644 index 0000000..bdb0ed9 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.payload.hex @@ -0,0 +1 @@ +81a752656675736564af4d697373696e674964656e74697479 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.payload.msgpack new file mode 100644 index 0000000..7a83e60 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.payload.msgpack @@ -0,0 +1 @@ +�§Refused¯MissingIdentity \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..bbb82db82832887cbfab45966737a99a39f386b6 GIT binary patch literal 314 zcmYk1O-{ow6okux5+#Lp*G&-(=>mxcC5ku5u^(x0Y)5`}q;dn!f%u6~REZ5M4#I5+ zB|=?}zM0X?WO6cp>?9!45@P-&y|B{_7-}o6p6|2~_}b82tttP$DJf4#MnX1CMM-U1 z$kjB@YRt1Wl01?mN`qhBs*NVe($Z`u;>)X`I9=CqR6oSgtmWs+9m!VWkhxwg_G9~n zVwzW~{1~@1lnJk(2=lXkv?Oc`l74PoSt;dnKyhmT8?6xhHtg|$Q!qG8|6;DT3atNs s=Exg#2&S#raQOYGZI9X+1{+sFMJunOW2XCKzk^lez literal 0 HcmV?d00001 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.frame.hex new file mode 100644 index 0000000..c2e1375 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.frame.hex @@ -0,0 +1 @@ +0000013689a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d954676164535a575a316332566b67617456626d463259576c7359574a735a59476d5a47563059576c73766e526f5a5342746157356b49484e3062334a6c49476c7a49473576644342795a57466b59574a735a513d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.payload.hex new file mode 100644 index 0000000..a1e1be5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.payload.hex @@ -0,0 +1 @@ +81a75265667573656481ab556e617661696c61626c6581a664657461696cbe746865206d696e642073746f7265206973206e6f74207265616461626c65 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.payload.msgpack new file mode 100644 index 0000000..cbfe1bc --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.payload.msgpack @@ -0,0 +1 @@ +�§Refused�«Unavailable�¦detail¾the mind store is not readable \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json b/tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json index 38b255d..e3e4ae9 100644 --- a/tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json +++ b/tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json @@ -527,16 +527,18 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], "order": 3, "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" + "messagePackHex": "DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" }, { "name": "leans_flag_closed_two_deciders", @@ -560,10 +562,12 @@ "leans": "yes", "flag": "provisional: Self expects this to move", "raised_in": null, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" ], + "decider_count": 2, "status": "closed", "ruling": "x:resolution:y", "ruled_from": [ @@ -572,7 +576,47 @@ "order": 0, "options_hash": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647390A56C65616E73A3796573A4666C6167D92670726F766973696F6E616C3A2053656C662065787065637473207468697320746F206D6F7665A97261697365645F696EC0A8646563696465727392D92465306431633262332D613439352D343638372D383837392D366135623463336432653166D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A6737461747573A6636C6F736564A672756C696E67AE783A7265736F6C7574696F6E3A79AA72756C65645F66726F6D91A26E31A56F7264657200AC6F7074696F6E735F68617368D94066383536656463653132316462343132316235663161663430306231633437316562626661363537396130343261636139613334616533316331356635633836" + "messagePackHex": "DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647390A56C65616E73A3796573A4666C6167D92670726F766973696F6E616C3A2053656C662065787065637473207468697320746F206D6F7665A97261697365645F696EC0A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727392D92465306431633262332D613439352D343638372D383837392D366135623463336432653166D92439663865376436632D356234612D343339322D383137302D306631653264336334623561AD646563696465725F636F756E7402A6737461747573A6636C6F736564A672756C696E67AE783A7265736F6C7574696F6E3A79AA72756C65645F66726F6D91A26E31A56F7264657200AC6F7074696F6E735F68617368D94066383536656463653132316462343132316235663161663430306231633437316562626661363537396130343261636139613334616533316331356635633836" + }, + { + "name": "raised_at_nil", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": null, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "messagePackHex": "DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174C0A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" }, { "name": "depends_at_both_caps", @@ -608,16 +652,18 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], "order": 3, "options_hash": "52163c7ae8a2c15d4380e7854505bedb5b3c84976ed301d86afc99a7bd0e03c8" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647398A161A161A161A161A161A161A161DA03E8C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94035323136336337616538613263313564343338306537383534353035626564623562336338343937366564333031643836616663393961376264306530336338" + "messagePackHex": "DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647398A161A161A161A161A161A161A161DA03E8C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94035323136336337616538613263313564343338306537383534353035626564623562336338343937366564333031643836616663393961376264306530336338" }, { "name": "closed_without_a_ruling_the_question_was_withdrawn", @@ -646,16 +692,56 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": null, "ruled_from": [], "order": 3, "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" + "messagePackHex": "DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" + }, + { + "name": "withheld_deciders", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 2, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "messagePackHex": "DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7402A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" }, { "name": "deciders_at_the_cap_of_64", @@ -684,6 +770,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "00000001-0000-4000-8000-000000000001", "00000002-0000-4000-8000-000000000002", @@ -750,13 +837,14 @@ "0000003f-0000-4000-8000-00000000003f", "00000040-0000-4000-8000-000000000040" ], + "decider_count": 64, "status": "open", "ruling": null, "ruled_from": [], "order": 3, "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A86465636964657273DC0040D92430303030303030312D303030302D343030302D383030302D303030303030303030303031D92430303030303030322D303030302D343030302D383030302D303030303030303030303032D92430303030303030332D303030302D343030302D383030302D303030303030303030303033D92430303030303030342D303030302D343030302D383030302D303030303030303030303034D92430303030303030352D303030302D343030302D383030302D303030303030303030303035D92430303030303030362D303030302D343030302D383030302D303030303030303030303036D92430303030303030372D303030302D343030302D383030302D303030303030303030303037D92430303030303030382D303030302D343030302D383030302D303030303030303030303038D92430303030303030392D303030302D343030302D383030302D303030303030303030303039D92430303030303030612D303030302D343030302D383030302D303030303030303030303061D92430303030303030622D303030302D343030302D383030302D303030303030303030303062D92430303030303030632D303030302D343030302D383030302D303030303030303030303063D92430303030303030642D303030302D343030302D383030302D303030303030303030303064D92430303030303030652D303030302D343030302D383030302D303030303030303030303065D92430303030303030662D303030302D343030302D383030302D303030303030303030303066D92430303030303031302D303030302D343030302D383030302D303030303030303030303130D92430303030303031312D303030302D343030302D383030302D303030303030303030303131D92430303030303031322D303030302D343030302D383030302D303030303030303030303132D92430303030303031332D303030302D343030302D383030302D303030303030303030303133D92430303030303031342D303030302D343030302D383030302D303030303030303030303134D92430303030303031352D303030302D343030302D383030302D303030303030303030303135D92430303030303031362D303030302D343030302D383030302D303030303030303030303136D92430303030303031372D303030302D343030302D383030302D303030303030303030303137D92430303030303031382D303030302D343030302D383030302D303030303030303030303138D92430303030303031392D303030302D343030302D383030302D303030303030303030303139D92430303030303031612D303030302D343030302D383030302D303030303030303030303161D92430303030303031622D303030302D343030302D383030302D303030303030303030303162D92430303030303031632D303030302D343030302D383030302D303030303030303030303163D92430303030303031642D303030302D343030302D383030302D303030303030303030303164D92430303030303031652D303030302D343030302D383030302D303030303030303030303165D92430303030303031662D303030302D343030302D383030302D303030303030303030303166D92430303030303032302D303030302D343030302D383030302D303030303030303030303230D92430303030303032312D303030302D343030302D383030302D303030303030303030303231D92430303030303032322D303030302D343030302D383030302D303030303030303030303232D92430303030303032332D303030302D343030302D383030302D303030303030303030303233D92430303030303032342D303030302D343030302D383030302D303030303030303030303234D92430303030303032352D303030302D343030302D383030302D303030303030303030303235D92430303030303032362D303030302D343030302D383030302D303030303030303030303236D92430303030303032372D303030302D343030302D383030302D303030303030303030303237D92430303030303032382D303030302D343030302D383030302D303030303030303030303238D92430303030303032392D303030302D343030302D383030302D303030303030303030303239D92430303030303032612D303030302D343030302D383030302D303030303030303030303261D92430303030303032622D303030302D343030302D383030302D303030303030303030303262D92430303030303032632D303030302D343030302D383030302D303030303030303030303263D92430303030303032642D303030302D343030302D383030302D303030303030303030303264D92430303030303032652D303030302D343030302D383030302D303030303030303030303265D92430303030303032662D303030302D343030302D383030302D303030303030303030303266D92430303030303033302D303030302D343030302D383030302D303030303030303030303330D92430303030303033312D303030302D343030302D383030302D303030303030303030303331D92430303030303033322D303030302D343030302D383030302D303030303030303030303332D92430303030303033332D303030302D343030302D383030302D303030303030303030303333D92430303030303033342D303030302D343030302D383030302D303030303030303030303334D92430303030303033352D303030302D343030302D383030302D303030303030303030303335D92430303030303033362D303030302D343030302D383030302D303030303030303030303336D92430303030303033372D303030302D343030302D383030302D303030303030303030303337D92430303030303033382D303030302D343030302D383030302D303030303030303030303338D92430303030303033392D303030302D343030302D383030302D303030303030303030303339D92430303030303033612D303030302D343030302D383030302D303030303030303030303361D92430303030303033622D303030302D343030302D383030302D303030303030303030303362D92430303030303033632D303030302D343030302D383030302D303030303030303030303363D92430303030303033642D303030302D343030302D383030302D303030303030303030303364D92430303030303033652D303030302D343030302D383030302D303030303030303030303365D92430303030303033662D303030302D343030302D383030302D303030303030303030303366D92430303030303034302D303030302D343030302D383030302D303030303030303030303430A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" + "messagePackHex": "DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA86465636964657273DC0040D92430303030303030312D303030302D343030302D383030302D303030303030303030303031D92430303030303030322D303030302D343030302D383030302D303030303030303030303032D92430303030303030332D303030302D343030302D383030302D303030303030303030303033D92430303030303030342D303030302D343030302D383030302D303030303030303030303034D92430303030303030352D303030302D343030302D383030302D303030303030303030303035D92430303030303030362D303030302D343030302D383030302D303030303030303030303036D92430303030303030372D303030302D343030302D383030302D303030303030303030303037D92430303030303030382D303030302D343030302D383030302D303030303030303030303038D92430303030303030392D303030302D343030302D383030302D303030303030303030303039D92430303030303030612D303030302D343030302D383030302D303030303030303030303061D92430303030303030622D303030302D343030302D383030302D303030303030303030303062D92430303030303030632D303030302D343030302D383030302D303030303030303030303063D92430303030303030642D303030302D343030302D383030302D303030303030303030303064D92430303030303030652D303030302D343030302D383030302D303030303030303030303065D92430303030303030662D303030302D343030302D383030302D303030303030303030303066D92430303030303031302D303030302D343030302D383030302D303030303030303030303130D92430303030303031312D303030302D343030302D383030302D303030303030303030303131D92430303030303031322D303030302D343030302D383030302D303030303030303030303132D92430303030303031332D303030302D343030302D383030302D303030303030303030303133D92430303030303031342D303030302D343030302D383030302D303030303030303030303134D92430303030303031352D303030302D343030302D383030302D303030303030303030303135D92430303030303031362D303030302D343030302D383030302D303030303030303030303136D92430303030303031372D303030302D343030302D383030302D303030303030303030303137D92430303030303031382D303030302D343030302D383030302D303030303030303030303138D92430303030303031392D303030302D343030302D383030302D303030303030303030303139D92430303030303031612D303030302D343030302D383030302D303030303030303030303161D92430303030303031622D303030302D343030302D383030302D303030303030303030303162D92430303030303031632D303030302D343030302D383030302D303030303030303030303163D92430303030303031642D303030302D343030302D383030302D303030303030303030303164D92430303030303031652D303030302D343030302D383030302D303030303030303030303165D92430303030303031662D303030302D343030302D383030302D303030303030303030303166D92430303030303032302D303030302D343030302D383030302D303030303030303030303230D92430303030303032312D303030302D343030302D383030302D303030303030303030303231D92430303030303032322D303030302D343030302D383030302D303030303030303030303232D92430303030303032332D303030302D343030302D383030302D303030303030303030303233D92430303030303032342D303030302D343030302D383030302D303030303030303030303234D92430303030303032352D303030302D343030302D383030302D303030303030303030303235D92430303030303032362D303030302D343030302D383030302D303030303030303030303236D92430303030303032372D303030302D343030302D383030302D303030303030303030303237D92430303030303032382D303030302D343030302D383030302D303030303030303030303238D92430303030303032392D303030302D343030302D383030302D303030303030303030303239D92430303030303032612D303030302D343030302D383030302D303030303030303030303261D92430303030303032622D303030302D343030302D383030302D303030303030303030303262D92430303030303032632D303030302D343030302D383030302D303030303030303030303263D92430303030303032642D303030302D343030302D383030302D303030303030303030303264D92430303030303032652D303030302D343030302D383030302D303030303030303030303265D92430303030303032662D303030302D343030302D383030302D303030303030303030303266D92430303030303033302D303030302D343030302D383030302D303030303030303030303330D92430303030303033312D303030302D343030302D383030302D303030303030303030303331D92430303030303033322D303030302D343030302D383030302D303030303030303030303332D92430303030303033332D303030302D343030302D383030302D303030303030303030303333D92430303030303033342D303030302D343030302D383030302D303030303030303030303334D92430303030303033352D303030302D343030302D383030302D303030303030303030303335D92430303030303033362D303030302D343030302D383030302D303030303030303030303336D92430303030303033372D303030302D343030302D383030302D303030303030303030303337D92430303030303033382D303030302D343030302D383030302D303030303030303030303338D92430303030303033392D303030302D343030302D383030302D303030303030303030303339D92430303030303033612D303030302D343030302D383030302D303030303030303030303361D92430303030303033622D303030302D343030302D383030302D303030303030303030303362D92430303030303033632D303030302D343030302D383030302D303030303030303030303363D92430303030303033642D303030302D343030302D383030302D303030303030303030303364D92430303030303033652D303030302D343030302D383030302D303030303030303030303365D92430303030303033662D303030302D343030302D383030302D303030303030303030303366D92430303030303034302D303030302D343030302D383030302D303030303030303030303430AD646563696465725F636F756E7440A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" } ], "invalid": [ @@ -796,9 +884,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -834,9 +924,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -872,9 +964,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -910,9 +1004,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -944,9 +1040,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -955,8 +1053,8 @@ } }, { - "name": "no_deciders", - "why": "a fork with no decider cannot be answered", + "name": "decider_count_zero", + "why": "a fork with no decider cannot be answered; an empty deciders list now means withheld, so the count refuses it", "message": { "schemaVersion": "gamecult.fork.card.v0", "fork_id": "gamecult-chat:question:slot-mark-consent", @@ -982,7 +1080,244 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [], + "decider_count": 0, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "decider_count_missing", + "why": "map-mode writes every key: decider_count is required", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "decider_count_65", + "why": "decider_count is at most 64 (Bifrost's per-call list)", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 65, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "decider_count_string", + "why": "decider_count is an integer", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": "2", + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "decider_count_null", + "why": "decider_count is never nil: it states the count even when the ids are withheld", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": null, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "decider_count_fewer_than_the_ids", + "why": "rule R-card: a card that shows deciders shows all of them, so the count equals the ids (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "decider_count_more_than_the_ids_shown", + "why": "rule R-card: a visible list must be whole; a partial list is no view (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" + ], + "decider_count": 3, "status": "open", "ruling": null, "ruled_from": [], @@ -1018,9 +1353,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -1056,9 +1393,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -1096,9 +1435,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -1134,9 +1475,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "provisional", "ruling": null, "ruled_from": [], @@ -1172,9 +1515,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -1210,9 +1555,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -1248,9 +1595,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -1286,9 +1635,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -1324,10 +1675,12 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 2, "status": "open", "ruling": null, "ruled_from": [], @@ -1363,9 +1716,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [ @@ -1403,9 +1758,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": "cultlib-gaps:resolution:question.slot-mark-consent.n1", "ruled_from": [], @@ -1441,9 +1798,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": null, "ruled_from": [ @@ -1481,9 +1840,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -1491,6 +1852,125 @@ "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc" } }, + { + "name": "raised_at_epoch_integer", + "why": "raised_at is an RFC3339 instant string or nil, not epoch milliseconds", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": 1762767000000, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "raised_at_date_only", + "why": "raised_at is an instant with a time and a Z, not a date", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "raised_at_missing", + "why": "map-mode writes every key: raised_at is required, nil when unknown", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, { "name": "deciders_65", "why": "deciders holds at most 64 (Huginn's bound)", @@ -1519,6 +1999,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "00000001-0000-4000-8000-000000000001", "00000002-0000-4000-8000-000000000002", @@ -1586,6 +2067,7 @@ "00000040-0000-4000-8000-000000000040", "00000041-0000-4000-8000-000000000041" ], + "decider_count": 65, "status": "open", "ruling": null, "ruled_from": [], @@ -1658,9 +2140,9 @@ "retracted": true, "retracts": "n1", "answered_at": "2026-11-12T19:02:00Z", - "request_id": "00000023-0000-4000-8000-000000000023" + "request_id": "00000024-0000-4000-8000-000000000024" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E73776572C0A9726574726163746564C3A87265747261637473A26E31AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032332D303030302D343030302D383030302D303030303030303030303233" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E73776572C0A9726574726163746564C3A87265747261637473A26E31AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032342D303030302D343030302D383030302D303030303030303030303234" }, { "name": "note_4000_ascii_bytes", @@ -1675,9 +2157,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "00000024-0000-4000-8000-000000000024" + "request_id": "00000025-0000-4000-8000-000000000025" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA078787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032342D303030302D343030302D383030302D303030303030303030303234" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA078787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032352D303030302D343030302D383030302D303030303030303030303235" }, { "name": "note_astral_4000_bytes", @@ -1692,9 +2174,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "00000025-0000-4000-8000-000000000025" + "request_id": "00000026-0000-4000-8000-000000000026" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA0F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032352D303030302D343030302D383030302D303030303030303030303235" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA0F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032362D303030302D343030302D383030302D303030303030303030303236" }, { "name": "note_combining_4000_bytes", @@ -1709,9 +2191,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "00000026-0000-4000-8000-000000000026" + "request_id": "00000027-0000-4000-8000-000000000027" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA065CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8178A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032362D303030302D343030302D383030302D303030303030303030303236" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA065CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8178A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032372D303030302D343030302D383030302D303030303030303030303237" } ], "invalid": [ @@ -1729,9 +2211,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "00000027-0000-4000-8000-000000000027" + "request_id": "00000028-0000-4000-8000-000000000028" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032372D303030302D343030302D383030302D303030303030303030303237" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032382D303030302D343030302D383030302D303030303030303030303238" }, { "name": "blank_note", @@ -1747,9 +2229,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "00000028-0000-4000-8000-000000000028" + "request_id": "00000029-0000-4000-8000-000000000029" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465A52020C2A020A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032382D303030302D343030302D383030302D303030303030303030303238" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465A52020C2A020A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032392D303030302D343030302D383030302D303030303030303030303239" }, { "name": "request_id_missing", @@ -1818,9 +2300,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "00000029-0000-4000-8000-000000000029" + "request_id": "0000002a-0000-4000-8000-00000000002a" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA17878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032392D303030302D343030302D383030302D303030303030303030303239" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA17878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032612D303030302D343030302D383030302D303030303030303030303261" }, { "name": "note_multibyte_4001_bytes", @@ -1836,9 +2318,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "0000002a-0000-4000-8000-00000000002a" + "request_id": "0000002b-0000-4000-8000-00000000002b" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA1C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9616263A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032612D303030302D343030302D383030302D303030303030303030303261" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA1C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9616263A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032622D303030302D343030302D383030302D303030303030303030303262" }, { "name": "note_astral_4004_bytes", @@ -1854,9 +2336,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "0000002b-0000-4000-8000-00000000002b" + "request_id": "0000002c-0000-4000-8000-00000000002c" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA4F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032622D303030302D343030302D383030302D303030303030303030303262" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA4F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032632D303030302D343030302D383030302D303030303030303030303263" }, { "name": "note_combining_4002_bytes", @@ -1872,9 +2354,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "0000002c-0000-4000-8000-00000000002c" + "request_id": "0000002d-0000-4000-8000-00000000002d" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA265CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC81A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032632D303030302D343030302D383030302D303030303030303030303263" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA265CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC81A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032642D303030302D343030302D383030302D303030303030303030303264" }, { "name": "options_hash_uppercase", @@ -1890,9 +2372,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "0000002d-0000-4000-8000-00000000002d" + "request_id": "0000002e-0000-4000-8000-00000000002e" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037443935464338374435333234323036313146453431354331444546323741333434373631374242363832333546343645334146313330414541333631433438A6616E7377657282A663686F696365A26E6FA46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032642D303030302D343030302D383030302D303030303030303030303264" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037443935464338374435333234323036313146453431354331444546323741333434373631374242363832333546343645334146313330414541333631433438A6616E7377657282A663686F696365A26E6FA46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032652D303030302D343030302D383030302D303030303030303030303265" }, { "name": "answerer_key", @@ -1947,9 +2429,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "0000002e-0000-4000-8000-00000000002e" + "request_id": "0000002f-0000-4000-8000-00000000002f" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657283A663686F696365A26E6FA46E6F7465C0A677656967687401A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032652D303030302D343030302D383030302D303030303030303030303265" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657283A663686F696365A26E6FA46E6F7465C0A677656967687401A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032662D303030302D343030302D383030302D303030303030303030303266" }, { "name": "answered_at_without_zone", @@ -1965,9 +2447,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00", - "request_id": "0000002f-0000-4000-8000-00000000002f" + "request_id": "00000030-0000-4000-8000-000000000030" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B3323032362D31312D31325431393A30323A3030AA726571756573745F6964D92430303030303032662D303030302D343030302D383030302D303030303030303030303266" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B3323032362D31312D31325431393A30323A3030AA726571756573745F6964D92430303030303033302D303030302D343030302D383030302D303030303030303030303330" }, { "name": "retracted_not_a_boolean", @@ -2001,9 +2483,9 @@ "retracted": false, "retracts": null, "answered_at": "2026-11-12T19:02:00Z", - "request_id": "00000030-0000-4000-8000-000000000030" + "request_id": "00000031-0000-4000-8000-000000000031" }, - "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964A0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303033302D303030302D343030302D383030302D303030303030303030303330" + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964A0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303033312D303030302D343030302D383030302D303030303030303030303331" } ] }, @@ -2071,9 +2553,31 @@ "claim_in_future": false, "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "authority": "Operator", - "request_id": "00000031-0000-4000-8000-000000000031" + "request_id": "00000032-0000-4000-8000-000000000032" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033312D303030302D343030302D383030302D303030303030303030303331" + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033322D303030302D343030302D383030302D303030303030303030303332" + }, + { + "name": "withheld_answerer", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "refused_stale", @@ -2093,9 +2597,9 @@ "claim_in_future": false, "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "authority": "Operator", - "request_id": "00000032-0000-4000-8000-000000000032" + "request_id": "00000033-0000-4000-8000-000000000033" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033322D303030302D343030302D383030302D303030303030303030303332" + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033332D303030302D343030302D383030302D303030303030303030303333" }, { "name": "refused_closed_retraction_names_its_target", @@ -2115,9 +2619,53 @@ "claim_in_future": false, "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "authority": "Operator", - "request_id": "00000033-0000-4000-8000-000000000033" + "request_id": "00000034-0000-4000-8000-000000000034" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033332D303030302D343030302D383030302D303030303030303030303333" + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033342D303030302D343030302D383030302D303030303030303030303334" + }, + { + "name": "withheld_answerer_revision", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "On reflection, share it.", + "answered_at": "2026-11-12T19:06:00Z", + "received_at": "2026-11-12T19:06:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + }, + { + "name": "withheld_answerer_retraction", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n8", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Retraction", + "of": null, + "retracts": "n1", + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "0000b001-0000-4000-8000-00000000b001" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030623030312D303030302D343030302D383030302D303030303030303062303031" } ], "invalid": [ @@ -2278,9 +2826,9 @@ "claim_in_future": false, "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "authority": "Operator", - "request_id": "00000034-0000-4000-8000-000000000034" + "request_id": "00000035-0000-4000-8000-000000000035" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033342D303030302D343030302D383030302D303030303030303030303334" + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033352D303030302D343030302D383030302D303030303030303030303335" }, { "name": "refused_naming_another_answer_as_of", @@ -2347,9 +2895,9 @@ "claim_in_future": false, "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "authority": "Operator", - "request_id": "00000035-0000-4000-8000-000000000035" + "request_id": "00000036-0000-4000-8000-000000000036" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033352D303030302D343030302D383030302D303030303030303030303335" + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033362D303030302D343030302D383030302D303030303030303030303336" }, { "name": "outcome_lower_case", @@ -2399,6 +2947,92 @@ } ] }, + "resultsInvalid": [ + { + "name": "answer_result_with_a_nil_answerer", + "why": "rule R-answer-result: the answer is the sender's own, stamped with their account; a nil answerer exists only inside a record shown to a non-decider (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + }, + "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A463617264C0B1636F756E7465645F616E737765725F6964A26E31" + }, + { + "name": "answer_result_with_a_nil_answerer_on_a_revision", + "why": "rule R-answer-result holds for every outcome: a Revision the sender just made is stamped with their account (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "On reflection, share it.", + "answered_at": "2026-11-12T19:06:00Z", + "received_at": "2026-11-12T19:06:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "card": null, + "counted_answer_id": "n2" + }, + "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032A463617264C0B1636F756E7465645F616E737765725F6964A26E32" + }, + { + "name": "answer_result_with_a_nil_answerer_on_a_retraction", + "why": "rule R-answer-result holds for every outcome: a Retraction the sender just made is stamped with their account (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n8", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Retraction", + "of": null, + "retracts": "n1", + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "0000b001-0000-4000-8000-00000000b001" + }, + "card": null, + "counted_answer_id": null + }, + "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030623030312D303030302D343030302D383030302D303030303030303062303031A463617264C0B1636F756E7465645F616E737765725F6964C0" + } + ], "results": [ { "name": "stale_fork", @@ -2448,9 +3082,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -2459,7 +3095,7 @@ }, "counted_answer_id": null }, - "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0" + "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0" }, { "name": "not_a_decider", @@ -3146,9 +3782,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3160,10 +3798,11 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "open_counted", @@ -3194,9 +3833,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3208,6 +3849,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -3230,7 +3872,7 @@ ] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" }, { "name": "closed_with_ruling_and_a_refusal", @@ -3261,9 +3903,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q3.n1", "ruled_from": [ @@ -3277,6 +3921,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -3317,7 +3962,426 @@ ] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232" + }, + { + "name": "withheld_open_counted", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230", + "twin": "open_counted" + }, + { + "name": "withheld_closed_with_ruling_and_a_refusal", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q3.n1", + "ruled_from": [ + "n1" + ], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": "n1", + "answerer_count": 2, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000021-0000-4000-8000-000000000021" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Steward", + "request_id": "00000022-0000-4000-8000-000000000022" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232", + "twin": "closed_with_ruling_and_a_refusal" + }, + { + "name": "withheld_no_answers", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q4", + "title": "Fork 4", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 4, + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q4", + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390", + "twin": "open_no_answers" + }, + { + "name": "open_counted_revision_and_retraction", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q6", + "title": "Fork 6", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 6, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q6", + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "counted_answer_id": "n2", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000024-0000-4000-8000-000000000024" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000025-0000-4000-8000-000000000025" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Retraction", + "of": null, + "retracts": "n1", + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:06:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000026-0000-4000-8000-000000000026" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A57469746C65A6466F726B2036A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657206AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7401A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032342D303030302D343030302D383030302D303030303030303030303234DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032352D303030302D343030302D383030302D303030303030303030303235DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032362D303030302D343030302D383030302D303030303030303030303236" + }, + { + "name": "withheld_counted_revision_and_retraction", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q6", + "title": "Fork 6", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 6, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q6", + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "counted_answer_id": "n2", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000024-0000-4000-8000-000000000024" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000025-0000-4000-8000-000000000025" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Retraction", + "of": null, + "retracts": "n1", + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:06:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000026-0000-4000-8000-000000000026" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A57469746C65A6466F726B2036A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657206AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7401A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032342D303030302D343030302D383030302D303030303030303030303234DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032352D303030302D343030302D383030302D303030303030303030303235DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032362D303030302D343030302D383030302D303030303030303030303236", + "twin": "open_counted_revision_and_retraction" } ], "invalid": [ @@ -3351,9 +4415,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3361,7 +4427,7 @@ "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" } }, - "messagePackHex": "82AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162" + "messagePackHex": "82AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162" }, { "name": "record_of_another_fork", @@ -3393,9 +4459,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3407,6 +4475,7 @@ "fork_id": "gamecult-chat:question:q9", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -3429,7 +4498,7 @@ ] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7139AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7139AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" }, { "name": "record_under_another_hash", @@ -3461,9 +4530,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3475,6 +4546,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -3497,7 +4569,7 @@ ] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" }, { "name": "counted_answer_not_derived", @@ -3529,9 +4601,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3543,6 +4617,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": null, + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -3565,7 +4640,836 @@ ] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964C0A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + }, + { + "name": "answerer_count_missing", + "why": "map-mode writes every key: answerer_count is required", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + }, + { + "name": "answerer_count_negative", + "why": "answerer_count is not negative", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answerer_count": -1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E74FFA7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + }, + { + "name": "answerer_count_null", + "why": "answerer_count is never nil: a decider and a non-decider read the same number", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answerer_count": null, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E74C0A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + }, + { + "name": "answerer_count_disagrees_with_the_ids", + "why": "rule R-record: in the decider view answerer_count is the number of distinct answerers (two here, not three; not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q3.n1", + "ruled_from": [ + "n1" + ], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": "n1", + "answerer_count": 3, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000021-0000-4000-8000-000000000021" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000022-0000-4000-8000-000000000022" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7403A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232" + }, + { + "name": "answerer_count_counts_answers_not_people", + "why": "rule R-record: two answers by one account are one answerer (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answerer_count": 2, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000023-0000-4000-8000-000000000023" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032332D303030302D343030302D383030302D303030303030303030303233" + }, + { + "name": "withheld_card_with_a_named_answerer_among_nils", + "why": "rule R-entry: a withheld card beside one named answerer, even among nils (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q3.n1", + "ruled_from": [ + "n1" + ], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": "n1", + "answerer_count": 2, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000021-0000-4000-8000-000000000021" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Steward", + "request_id": "00000022-0000-4000-8000-000000000022" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232" + }, + { + "name": "withheld_count_over_the_answers", + "why": "rule R-record: withheld answers are at most as many answerers as answers (two answers, count 3; not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q3.n1", + "ruled_from": [ + "n1" + ], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": "n1", + "answerer_count": 3, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000021-0000-4000-8000-000000000021" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Steward", + "request_id": "00000022-0000-4000-8000-000000000022" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7403A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232" + }, + { + "name": "withheld_count_zero_over_answers", + "why": "rule R-record: with at least one answer the count is at least 1 (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answerer_count": 0, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7400A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + }, + { + "name": "withheld_count_over_no_answers", + "why": "rule R-record: with no answers the count is 0 in both views (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q4", + "title": "Fork 4", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 4, + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q4", + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", + "counted_answer_id": null, + "answerer_count": 1, + "answers": [] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727390" + }, + { + "name": "decider_card_with_withheld_answers", + "why": "rule R-entry: a card that shows deciders is the decider view, so every answerer is named (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + }, + { + "name": "withheld_card_with_named_answers", + "why": "rule R-entry, the leak: a card with deciders withheld beside an answer that still names its account (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" }, { "name": "card_hash_wrong", @@ -3597,9 +5501,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3611,10 +5517,221 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "withheld_revision_and_retraction_beside_a_named_retraction", + "why": "rule R-entry: a withheld card beside one named answerer, a Retraction's included (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q6", + "title": "Fork 6", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 6, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q6", + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "counted_answer_id": "n2", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000024-0000-4000-8000-000000000024" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000025-0000-4000-8000-000000000025" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Retraction", + "of": null, + "retracts": "n1", + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:06:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Operator", + "request_id": "00000026-0000-4000-8000-000000000026" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A57469746C65A6466F726B2036A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657206AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7401A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032342D303030302D343030302D383030302D303030303030303030303234DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032352D303030302D343030302D383030302D303030303030303030303235DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032362D303030302D343030302D383030302D303030303030303030303236" + }, + { + "name": "withheld_revision_and_retraction_count_over_the_answers", + "why": "rule R-record: withheld answers are at most as many answerers as answers (three answers, count 4; not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q6", + "title": "Fork 6", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 6, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q6", + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "counted_answer_id": "n2", + "answerer_count": 4, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000024-0000-4000-8000-000000000024" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000025-0000-4000-8000-000000000025" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Retraction", + "of": null, + "retracts": "n1", + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:06:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000026-0000-4000-8000-000000000026" + } + ] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A57469746C65A6466F726B2036A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657206AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7404A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032342D303030302D343030302D383030302D303030303030303030303234DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032352D303030302D343030302D383030302D303030303030303030303235DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032362D303030302D343030302D383030302D303030303030303030303236" } ] }, @@ -3656,9 +5773,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3670,6 +5789,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -3719,9 +5839,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3733,13 +5855,14 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": "more" }, - "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A46D6F7265" + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A46D6F7265" }, { "name": "stamp_malformed", @@ -3778,9 +5901,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3792,6 +5917,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -3841,9 +5967,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3855,13 +5983,14 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": "c4" }, - "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A3343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26334" + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A3343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26334" }, { "name": "count_negative", @@ -3900,9 +6029,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3914,6 +6045,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -3963,9 +6095,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -3977,22 +6111,23 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": "c4" }, - "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E74FFAE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26334" + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E74FFAE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26334" }, { - "name": "two_cards_share_one_order", - "why": "rule: order is a unique rank per Mind; the refusal names both fork ids (not expressible in the schema)", + "name": "a_page_mixes_views", + "why": "rule R-list: a page is one reader's view, so every entry agrees on whether the deciders are shown (not expressible in the schema)", "message": { "schemaVersion": "gamecult.fork.list.v0", "mind": "gamecult-chat", "stamp": "s412", - "open_count": 4, + "open_count": 3, "recorded_count": 2, "forks": [ { @@ -4022,9 +6157,9 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, - "deciders": [ - "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" - ], + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -4036,6 +6171,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -4051,7 +6187,7 @@ "answered_at": "2026-11-12T19:02:00Z", "received_at": "2026-11-12T19:02:05Z", "claim_in_future": false, - "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "answerer": null, "authority": "Operator", "request_id": "00000020-0000-4000-8000-000000000020" } @@ -4085,9 +6221,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -4099,50 +6237,7 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", "counted_answer_id": null, - "answers": [] - } - }, - { - "schemaVersion": "gamecult.fork.entry.v0", - "card": { - "schemaVersion": "gamecult.fork.card.v0", - "fork_id": "gamecult-chat:question:q4b", - "title": "Fork 4", - "context": "Should a slot mark be shared?", - "options": [ - { - "id": "yes", - "text": "Share it." - }, - { - "id": "no", - "text": "Keep it local." - } - ], - "recommendation": "no", - "depends": [ - "gamecult-chat:question:file-zone-audience" - ], - "leans": null, - "flag": null, - "raised_in": { - "kind": "CutSpec", - "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" - }, - "deciders": [ - "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" - ], - "status": "open", - "ruling": null, - "ruled_from": [], - "order": 4, - "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa" - }, - "record": { - "schemaVersion": "gamecult.fork.record.v0", - "fork_id": "gamecult-chat:question:q4b", - "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", - "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -4173,9 +6268,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q3.n1", "ruled_from": [ @@ -4189,6 +6286,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -4256,9 +6354,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -4270,6 +6370,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -4300,9 +6401,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q1.n1", "ruled_from": [], @@ -4314,13 +6417,369 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": null }, - "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7402A5666F726B739683AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964BA67616D6563756C742D636861743A7175657374696F6E3A713462A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964BA67616D6563756C742D636861743A7175657374696F6E3A713462AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0", + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739583AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" + }, + { + "name": "two_cards_share_one_order", + "why": "rule: order is a unique rank per Mind; the refusal names both fork ids (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s412", + "open_count": 4, + "recorded_count": 2, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + } + ] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q4", + "title": "Fork 4", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 4, + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q4", + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q4b", + "title": "Fork 4", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 4, + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q4b", + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q3.n1", + "ruled_from": [ + "n1" + ], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": "n1", + "answerer_count": 2, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000021-0000-4000-8000-000000000021" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000022-0000-4000-8000-000000000022" + } + ] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q1.n1", + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "next_cursor": null + }, + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7402A5666F726B739683AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964BA67616D6563756C742D636861743A7175657374696F6E3A713462A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964BA67616D6563756C742D636861743A7175657374696F6E3A713462AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0", "refusal": "order 4 is shared by gamecult-chat:question:q4 and gamecult-chat:question:q4b" } ], @@ -4356,9 +6815,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -4370,6 +6831,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -4419,9 +6881,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -4433,6 +6897,7 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -4463,9 +6928,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q3.n1", "ruled_from": [ @@ -4479,6 +6946,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -4546,9 +7014,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -4560,6 +7030,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -4590,9 +7061,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q1.n1", "ruled_from": [], @@ -4604,6 +7077,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } @@ -4651,9 +7125,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -4665,6 +7141,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -4714,9 +7191,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -4728,13 +7207,14 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": "c4" }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26334" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26334" }, { "request": { @@ -4778,9 +7258,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -4792,13 +7274,14 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" } ] }, @@ -4833,9 +7316,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -4847,6 +7332,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -4896,9 +7382,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -4910,6 +7398,7 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -4940,9 +7429,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q3.n1", "ruled_from": [ @@ -4956,6 +7447,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -5023,9 +7515,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -5037,6 +7531,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -5067,9 +7562,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q1.n1", "ruled_from": [], @@ -5081,6 +7578,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } @@ -5128,9 +7626,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -5142,6 +7642,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -5191,9 +7692,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -5205,6 +7708,7 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -5235,9 +7739,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q3.n1", "ruled_from": [ @@ -5251,6 +7757,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -5318,9 +7825,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -5332,6 +7841,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -5362,9 +7872,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q1.n1", "ruled_from": [], @@ -5376,13 +7888,14 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739583AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739583AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" } ] }, @@ -5417,9 +7930,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -5431,6 +7946,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -5480,9 +7996,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -5494,6 +8012,7 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -5524,9 +8043,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q3.n1", "ruled_from": [ @@ -5540,6 +8061,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -5607,9 +8129,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -5621,6 +8145,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -5651,9 +8176,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q1.n1", "ruled_from": [], @@ -5665,6 +8192,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } @@ -5712,9 +8240,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q3.n1", "ruled_from": [ @@ -5728,6 +8258,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -5771,7 +8302,7 @@ ], "next_cursor": "c3" }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232AB6E6578745F637572736F72A26333" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232AB6E6578745F637572736F72A26333" }, { "request": { @@ -5815,9 +8346,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q1.n1", "ruled_from": [], @@ -5829,13 +8362,14 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" } ] }, @@ -5870,9 +8404,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -5884,6 +8420,7 @@ "fork_id": "gamecult-chat:question:q5", "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -5933,9 +8470,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -5947,6 +8486,7 @@ "fork_id": "gamecult-chat:question:q4", "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -5977,9 +8517,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q3.n1", "ruled_from": [ @@ -5993,6 +8535,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -6060,9 +8603,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6074,6 +8619,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -6104,9 +8650,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.q1.n1", "ruled_from": [], @@ -6118,6 +8666,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } @@ -6175,9 +8724,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6189,6 +8740,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -6219,9 +8771,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6233,6 +8787,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -6263,9 +8818,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6277,6 +8834,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -6307,9 +8865,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6321,6 +8881,7 @@ "fork_id": "gamecult-chat:question:q0", "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } @@ -6368,9 +8929,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6382,6 +8945,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -6412,9 +8976,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6426,6 +8992,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -6456,9 +9023,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6470,13 +9039,14 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": "c1" }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26331" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26331" }, { "request": { @@ -6520,9 +9090,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6534,16 +9106,612 @@ "fork_id": "gamecult-chat:question:q0", "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" } ] }, + { + "name": "non_decider_page", + "ordinal": 412, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + } + ] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q4", + "title": "Fork 4", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 4, + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q4", + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q3.n1", + "ruled_from": [ + "n1" + ], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": "n1", + "answerer_count": 2, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000021-0000-4000-8000-000000000021" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Steward", + "request_id": "00000022-0000-4000-8000-000000000022" + } + ] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q1.n1", + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 50 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s412", + "open_count": 3, + "recorded_count": 2, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + } + ] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q4", + "title": "Fork 4", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 4, + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q4", + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q3.n1", + "ruled_from": [ + "n1" + ], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": "n1", + "answerer_count": 2, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000021-0000-4000-8000-000000000021" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Steward", + "request_id": "00000022-0000-4000-8000-000000000022" + } + ] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q1.n1", + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739583AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" + } + ], + "twin": "all_forks_one_page" + }, { "name": "a_mind_with_no_forks", "ordinal": 7, @@ -6569,6 +9737,467 @@ "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64A46E696275A57374616D70A27337AA6F70656E5F636F756E7400AE7265636F726465645F636F756E7400A5666F726B7390AB6E6578745F637572736F72C0" } ] + }, + { + "name": "counted_revision_and_retraction_page", + "ordinal": 413, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q6", + "title": "Fork 6", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 6, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q6", + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "counted_answer_id": "n2", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000024-0000-4000-8000-000000000024" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000025-0000-4000-8000-000000000025" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Retraction", + "of": null, + "retracts": "n1", + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:06:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000026-0000-4000-8000-000000000026" + } + ] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 10 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s413", + "open_count": 1, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q6", + "title": "Fork 6", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 6, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q6", + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "counted_answer_id": "n2", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000024-0000-4000-8000-000000000024" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000025-0000-4000-8000-000000000025" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Retraction", + "of": null, + "retracts": "n1", + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:06:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000026-0000-4000-8000-000000000026" + } + ] + } + } + ], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343133AA6F70656E5F636F756E7401AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A57469746C65A6466F726B2036A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657206AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7401A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032342D303030302D343030302D383030302D303030303030303030303234DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032352D303030302D343030302D383030302D303030303030303030303235DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032362D303030302D343030302D383030302D303030303030303030303236AB6E6578745F637572736F72C0" + } + ] + }, + { + "name": "non_decider_counted_revision_and_retraction_page", + "ordinal": 413, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q6", + "title": "Fork 6", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 6, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q6", + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "counted_answer_id": "n2", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000024-0000-4000-8000-000000000024" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000025-0000-4000-8000-000000000025" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Retraction", + "of": null, + "retracts": "n1", + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:06:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000026-0000-4000-8000-000000000026" + } + ] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 10 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s413", + "open_count": 1, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q6", + "title": "Fork 6", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10T09:30:00Z", + "deciders": [], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 6, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q6", + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "counted_answer_id": "n2", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000024-0000-4000-8000-000000000024" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000025-0000-4000-8000-000000000025" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:q6", + "outcome": "Retraction", + "of": null, + "retracts": "n1", + "code": null, + "options_hash": "bc0f477ca229a4a09937a90537746204947df1a5ec5c40c2e052f4b7ac6d7adf", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:06:05Z", + "claim_in_future": false, + "answerer": null, + "authority": "Operator", + "request_id": "00000026-0000-4000-8000-000000000026" + } + ] + } + } + ], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343133AA6F70656E5F636F756E7401AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A57469746C65A6466F726B2036A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727390AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657206AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7401A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032342D303030302D343030302D383030302D303030303030303030303234DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032352D303030302D343030302D383030302D303030303030303030303235DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7136A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94062633066343737636132323961346130393933376139303533373734363230343934376466316135656335633430633265303532663462376163366437616466A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572C0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032362D303030302D343030302D383030302D303030303030303030303236AB6E6578745F637572736F72C0" + } + ], + "twin": "counted_revision_and_retraction_page" } ], "listShortPages": [ @@ -6603,9 +10232,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6617,6 +10248,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -6647,9 +10279,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6661,6 +10295,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -6691,9 +10326,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6705,6 +10342,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -6735,9 +10373,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6749,6 +10389,7 @@ "fork_id": "gamecult-chat:question:q0", "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } @@ -6796,9 +10437,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6810,6 +10453,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -6840,9 +10484,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6854,13 +10500,14 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": "c2" }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26332" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26332" }, { "request": { @@ -6904,9 +10551,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6918,6 +10567,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -6948,9 +10598,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -6962,13 +10614,14 @@ "fork_id": "gamecult-chat:question:q0", "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" } ] }, @@ -7003,9 +10656,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7017,6 +10672,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7047,9 +10703,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7061,6 +10719,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7091,9 +10750,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7105,6 +10766,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7135,9 +10797,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7149,6 +10813,7 @@ "fork_id": "gamecult-chat:question:q0", "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } @@ -7214,9 +10879,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7228,6 +10895,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7258,9 +10926,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7272,6 +10942,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7302,9 +10973,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7316,13 +10989,14 @@ "fork_id": "gamecult-chat:question:q0", "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" } ] }, @@ -7357,9 +11031,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7371,6 +11047,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7401,9 +11078,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7415,6 +11094,7 @@ "fork_id": "gamecult-chat:question:q0", "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } @@ -7462,9 +11142,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7476,13 +11158,14 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } ], "next_cursor": "c1" }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7402AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26331" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7402AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26331" }, { "request": { @@ -7537,9 +11220,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7551,6 +11236,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7581,9 +11267,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7595,6 +11283,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7625,9 +11314,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7639,6 +11330,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7669,9 +11361,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7683,6 +11377,7 @@ "fork_id": "gamecult-chat:question:q0", "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } @@ -7733,9 +11428,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7747,6 +11444,7 @@ "fork_id": "gamecult-chat:question:q3", "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7777,9 +11475,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7791,6 +11491,7 @@ "fork_id": "gamecult-chat:question:q2", "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7821,9 +11522,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7835,6 +11538,7 @@ "fork_id": "gamecult-chat:question:q1", "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } }, @@ -7865,9 +11569,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -7879,6 +11585,7 @@ "fork_id": "gamecult-chat:question:q0", "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", "counted_answer_id": null, + "answerer_count": 0, "answers": [] } } @@ -9249,9 +12956,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -9311,6 +13020,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -9332,7 +13042,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "choice_alone_is_counted", @@ -9361,9 +13071,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -9423,6 +13135,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -9444,7 +13157,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030352D303030302D343030302D383030302D303030303030303030303035" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030352D303030302D343030302D383030302D303030303030303030303035" }, { "name": "note_alone_is_counted_a_clear_pick", @@ -9473,9 +13186,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -9535,6 +13250,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -9556,7 +13272,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030362D303030302D343030302D383030302D303030303030303030303036" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030362D303030302D343030302D383030302D303030303030303030303036" }, { "name": "V1_answerer_in_the_body_is_refused_at_the_door_and_stores_nothing", @@ -9585,9 +13301,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -9630,9 +13348,10 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 0, "answers": [] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "V1_authority_nested_in_answer_is_refused", @@ -9661,9 +13380,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -9706,9 +13427,10 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 0, "answers": [] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "V1_both_are_refused_not_stripped", @@ -9737,9 +13459,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -9783,9 +13507,10 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 0, "answers": [] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "M5_a_body_naming_deciders_is_an_unknown_key_the_decider_list_is_never_the_bodys", @@ -9814,9 +13539,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -9861,9 +13588,10 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 0, "answers": [] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "M1_a_body_naming_a_mind_is_an_unknown_key_the_mind_is_the_routes", @@ -9892,9 +13620,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -9937,9 +13667,10 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 0, "answers": [] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "an_unknown_key_is_refused_the_body_is_closed", @@ -9968,9 +13699,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -10013,9 +13746,10 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 0, "answers": [] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "an_answer_with_neither_choice_nor_note_is_invalid", @@ -10044,9 +13778,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -10088,9 +13824,10 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 0, "answers": [] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "a_non_retraction_without_an_answer_is_invalid", @@ -10119,9 +13856,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -10160,9 +13899,10 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 0, "answers": [] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "unavailable_before_huginn_stored_nothing_is_retried_into_a_Counted_answer", @@ -10191,9 +13931,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -10283,6 +14025,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -10304,7 +14047,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "unavailable_after_huginn_stored_the_retry_with_the_same_request_id_returns_the_stored_outcome", @@ -10333,9 +14076,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -10426,6 +14171,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -10447,7 +14193,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "a_retry_never_adds_a_Revision", @@ -10476,9 +14222,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -10583,6 +14331,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -10604,7 +14353,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "Q1_a_replayed_stale_fork_returns_the_CURRENT_card_and_stores_nothing_new", @@ -10633,9 +14382,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -10710,9 +14461,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -10752,9 +14505,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -10828,9 +14583,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -10846,6 +14603,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7", "counted_answer_id": null, + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -10867,7 +14625,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637B1636F756E7465645F616E737765725F6964C0A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030372D303030302D343030302D383030302D303030303030303030303037" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030372D303030302D343030302D383030302D303030303030303030303037" }, { "name": "V13_only_answered_at_changed_is_request_id_conflict", @@ -10896,9 +14654,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -10985,6 +14745,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -11006,7 +14767,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "V14_an_identical_body_from_another_account_gets_its_own_Counted_and_never_the_first_accounts_outcome", @@ -11035,10 +14796,12 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" ], + "decider_count": 2, "status": "open", "ruling": null, "ruled_from": [], @@ -11188,6 +14951,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n2", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -11227,7 +14991,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E32A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AD6E6F745F615F64656369646572AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AD6E6F745F615F64656369646572AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "V14_an_identical_body_from_another_decider_is_its_own_Revision_and_the_first_retry_still_returns_n1", @@ -11256,10 +15020,12 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" ], + "decider_count": 2, "status": "open", "ruling": null, "ruled_from": [], @@ -11409,6 +15175,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -11448,7 +15215,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "V16_Q5_a_forbidden_request_binds_no_request_id", @@ -11477,9 +15244,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -11567,6 +15336,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -11588,7 +15358,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "V17_Q6_a_body_refused_at_the_door_binds_no_request_id", @@ -11617,9 +15387,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -11707,6 +15479,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -11728,7 +15501,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "V17_Q6_an_unknown_fork_binds_no_request_id", @@ -11757,9 +15530,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -11846,6 +15621,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -11867,7 +15643,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "the_same_request_id_with_another_body_is_request_id_conflict", @@ -11896,9 +15672,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -11985,6 +15763,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -12006,7 +15785,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "the_same_request_id_from_another_account_is_another_request", @@ -12035,10 +15814,12 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" ], + "decider_count": 2, "status": "open", "ruling": null, "ruled_from": [], @@ -12143,6 +15924,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -12182,7 +15964,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "a_non_active_member_is_forbidden_before_the_body_is_read", @@ -12211,9 +15993,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -12316,9 +16100,10 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 0, "answers": [] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "no_session_is_unauthenticated", @@ -12347,9 +16132,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -12391,9 +16178,10 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 0, "answers": [] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "an_unknown_fork_is_refused_at_the_door", @@ -12422,9 +16210,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -12466,9 +16256,10 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 0, "answers": [] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" }, { "name": "V2_stale_hash_is_refused_not_counted_and_stored_as_Refused", @@ -12497,9 +16288,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -12574,9 +16367,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -12592,6 +16387,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -12613,7 +16409,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030372D303030302D343030302D383030302D303030303030303030303037" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030372D303030302D343030302D383030302D303030303030303030303037" }, { "name": "V2_a_stale_answer_then_a_fresh_one_the_fresh_one_is_counted", @@ -12642,9 +16438,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -12719,9 +16517,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -12782,6 +16582,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n2", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -12821,7 +16622,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E32A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030622D303030302D343030302D383030302D303030303030303030303062DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030622D303030302D343030302D383030302D303030303030303030303062DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "V3_an_answer_off_the_decider_list_is_Refused_not_a_decider_and_stored", @@ -12850,9 +16651,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -12912,6 +16715,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -12933,7 +16737,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E34A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AD6E6F745F615F64656369646572AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465AE53686172652C20706C656173652EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303030342D303030302D343030302D383030302D303030303030303030303034" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E34A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AD6E6F745F615F64656369646572AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465AE53686172652C20706C656173652EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303030342D303030302D343030302D383030302D303030303030303030303034" }, { "name": "a_non_decider_with_a_stale_hash_is_stale_first", @@ -12962,9 +16766,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -13039,9 +16845,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -13057,6 +16865,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -13078,7 +16887,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E34A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A3796573A46E6F7465AE53686172652C20706C656173652EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303030342D303030302D343030302D383030302D303030303030303030303034" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E34A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A3796573A46E6F7465AE53686172652C20706C656173652EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303030342D303030302D343030302D383030302D303030303030303030303034" }, { "name": "V4_a_closed_fork_refuses_and_stores_the_refusal", @@ -13107,9 +16916,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.slot-mark-consent.n1", "ruled_from": [], @@ -13169,6 +16980,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -13190,7 +17002,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "V5_second_answer_from_one_decider_is_a_Revision_the_first_stays_counted", @@ -13219,9 +17031,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -13326,6 +17140,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -13365,7 +17180,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" }, { "name": "V5_receiver_order_decides_not_answered_at", @@ -13394,9 +17209,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -13501,6 +17318,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -13540,7 +17358,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "V5_reversed_arrival_reverses_the_result", @@ -13569,9 +17387,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -13676,6 +17496,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -13715,7 +17536,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" }, { "name": "a_second_decider_answer_is_recorded_as_a_Revision", @@ -13744,10 +17565,12 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" ], + "decider_count": 2, "status": "open", "ruling": null, "ruled_from": [], @@ -13852,6 +17675,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -13891,7 +17715,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030332D303030302D343030302D383030302D303030303030303030303033" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030332D303030302D343030302D383030302D303030303030303030303033" }, { "name": "a_claim_exactly_five_minutes_ahead_is_not_flagged", @@ -13920,9 +17744,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -13982,6 +17808,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -14003,7 +17830,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30373A30355AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030632D303030302D343030302D383030302D303030303030303030303063" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30373A30355AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030632D303030302D343030302D383030302D303030303030303030303063" }, { "name": "a_claim_five_minutes_and_half_a_second_ahead_is_flagged_from_the_stored_received_at", @@ -14032,9 +17859,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -14094,6 +17923,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -14115,7 +17945,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B8323032362D31312D31325431393A30373A30352E3530305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C3A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030642D303030302D343030302D383030302D303030303030303030303064" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B8323032362D31312D31325431393A30373A30352E3530305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C3A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030642D303030302D343030302D383030302D303030303030303030303064" }, { "name": "a_claim_more_than_five_minutes_ahead_is_accepted_and_flagged", @@ -14144,9 +17974,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -14206,6 +18038,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -14227,7 +18060,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C3A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030652D303030302D343030302D383030302D303030303030303030303065" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C3A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030652D303030302D343030302D383030302D303030303030303030303065" }, { "name": "V6_a_matrix_edit_redaction_or_reaction_changes_nothing", @@ -14256,9 +18089,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -14342,6 +18177,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -14363,7 +18199,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "V7_a_choice_that_is_not_an_option_is_Refused_choice_invalid", @@ -14392,9 +18228,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -14454,6 +18292,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -14475,7 +18314,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE63686F6963655F696E76616C6964AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A56D61796265A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030662D303030302D343030302D383030302D303030303030303030303066" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE63686F6963655F696E76616C6964AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A56D61796265A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030662D303030302D343030302D383030302D303030303030303030303066" }, { "name": "a_changed_card_stops_the_old_answers_counting_and_a_new_answer_is_counted", @@ -14504,9 +18343,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -14590,9 +18431,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -14651,6 +18494,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7", "counted_answer_id": "n2", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -14690,7 +18534,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637B1636F756E7465645F616E737765725F6964A26E32A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031302D303030302D343030302D383030302D303030303030303030303130" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031302D303030302D343030302D383030302D303030303030303030303130" }, { "name": "a_changed_card_leaves_no_counted_answer_until_one_arrives", @@ -14719,9 +18563,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -14805,9 +18651,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -14821,6 +18669,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7", "counted_answer_id": null, + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -14842,7 +18691,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637B1636F756E7465645F616E737765725F6964C0A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" }, { "name": "V8_retraction_with_an_answer_is_bad", @@ -14871,9 +18720,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -14978,6 +18829,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -15017,7 +18869,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031312D303030302D343030302D383030302D303030303030303030303131" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031312D303030302D343030302D383030302D303030303030303030303131" }, { "name": "V8_retraction_without_retracts_is_bad", @@ -15046,9 +18898,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -15150,6 +19004,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -15189,7 +19044,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031322D303030302D343030302D383030302D303030303030303030303132" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031322D303030302D343030302D383030302D303030303030303030303132" }, { "name": "V8_retracts_on_a_plain_answer_is_bad", @@ -15218,9 +19073,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -15325,6 +19182,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -15364,7 +19222,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" }, { "name": "V8_retraction_naming_an_unknown_answer_is_bad", @@ -15393,9 +19251,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -15497,6 +19357,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -15536,7 +19397,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A36E3939A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031332D303030302D343030302D383030302D303030303030303030303133" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A36E3939A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031332D303030302D343030302D383030302D303030303030303030303133" }, { "name": "V8_retraction_naming_a_Refused_answer_is_bad", @@ -15565,9 +19426,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -15642,9 +19505,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -15702,6 +19567,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -15741,7 +19607,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031342D303030302D343030302D383030302D303030303030303030303134DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031352D303030302D343030302D383030302D303030303030303030303135" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031342D303030302D343030302D383030302D303030303030303030303134DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031352D303030302D343030302D383030302D303030303030303030303135" }, { "name": "V8_retracting_twice_is_already_retracted", @@ -15770,9 +19636,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -15916,6 +19784,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -15973,7 +19842,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031362D303030302D343030302D383030302D303030303030303030303136DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465B1616C72656164795F726574726163746564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A31303A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031372D303030302D343030302D383030302D303030303030303030303137" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031362D303030302D343030302D383030302D303030303030303030303136DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465B1616C72656164795F726574726163746564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A31303A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031372D303030302D343030302D383030302D303030303030303030303137" }, { "name": "V8_retraction_under_a_stale_hash_is_stale_fork", @@ -16002,9 +19871,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -16121,9 +19992,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -16139,6 +20012,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -16178,7 +20052,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031382D303030302D343030302D383030302D303030303030303030303138" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031382D303030302D343030302D383030302D303030303030303030303138" }, { "name": "V9_another_decider_retracts_and_the_answer_stops_counting", @@ -16207,10 +20081,12 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" ], + "decider_count": 2, "status": "open", "ruling": null, "ruled_from": [], @@ -16312,6 +20188,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -16351,7 +20228,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E37A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303031392D303030302D343030302D383030302D303030303030303030303139" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E37A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303031392D303030302D343030302D383030302D303030303030303030303139" }, { "name": "V9_an_account_off_the_list_cannot_retract", @@ -16380,9 +20257,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -16484,6 +20363,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -16523,7 +20403,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E36A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AD6E6F745F615F64656369646572AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303031612D303030302D343030302D383030302D303030303030303030303161" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E36A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AD6E6F745F615F64656369646572AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303031612D303030302D343030302D383030302D303030303030303030303161" }, { "name": "V10a_a_ruling_that_closed_the_fork_makes_a_retraction_fork_closed", @@ -16552,9 +20432,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -16638,9 +20520,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "closed", "ruling": "cultlib-gaps:resolution:question.slot-mark-consent.n1", "ruled_from": [ @@ -16698,6 +20582,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -16737,7 +20622,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031622D303030302D343030302D383030302D303030303030303030303162" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031622D303030302D343030302D383030302D303030303030303030303162" }, { "name": "V11_retracting_the_counted_answer_makes_the_next_decider_answer_counted", @@ -16766,10 +20651,12 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" ], + "decider_count": 2, "status": "open", "ruling": null, "ruled_from": [], @@ -16916,6 +20803,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n3", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -16973,7 +20861,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E33A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030332D303030302D343030302D383030302D303030303030303030303033DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031632D303030302D343030302D383030302D303030303030303030303163" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E33AE616E7377657265725F636F756E7402A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030332D303030302D343030302D383030302D303030303030303030303033DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031632D303030302D343030302D383030302D303030303030303030303163" }, { "name": "V11_retracting_an_answer_that_was_not_counted_changes_nothing", @@ -17002,10 +20890,12 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" ], + "decider_count": 2, "status": "open", "ruling": null, "ruled_from": [], @@ -17152,6 +21042,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 2, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -17209,7 +21100,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030332D303030302D343030302D383030302D303030303030303030303033DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E33A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031642D303030302D343030302D383030302D303030303030303030303164" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030332D303030302D343030302D383030302D303030303030303030303033DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E33A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031642D303030302D343030302D383030302D303030303030303030303164" }, { "name": "after_every_answer_is_retracted_a_fresh_answer_is_Counted_again", @@ -17238,9 +21129,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -17387,6 +21280,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n2", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -17444,7 +21338,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E32A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031652D303030302D343030302D383030302D303030303030303030303165DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7401A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031652D303030302D343030302D383030302D303030303030303030303165DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" }, { "name": "R1_a_retraction_with_an_answer_is_a_stored_bad_retraction_and_a_retry_replays_it", @@ -17473,9 +21367,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -17625,6 +21521,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -17664,7 +21561,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" }, { "name": "R1_a_retraction_with_no_target_is_a_stored_bad_retraction_and_a_retry_replays_it", @@ -17693,9 +21590,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -17839,6 +21738,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -17878,7 +21778,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030623030312D303030302D343030302D383030302D303030303030303062303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030623030312D303030302D343030302D383030302D303030303030303062303031" }, { "name": "R1_a_plain_answer_with_a_target_is_a_stored_bad_retraction_and_a_retry_replays_it", @@ -17907,9 +21807,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -18059,6 +21961,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -18098,7 +22001,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" }, { "name": "a_replay_returns_the_counted_answer_id_current_at_the_retry_not_the_one_stored", @@ -18127,9 +22030,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -18276,6 +22181,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": null, + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -18315,7 +22221,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031662D303030302D343030302D383030302D303030303030303030303166" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031662D303030302D343030302D383030302D303030303030303030303166" }, { "name": "R1_retracted_true_with_an_answer_and_no_target_is_a_stored_bad_retraction", @@ -18344,9 +22250,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -18451,6 +22359,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -18490,7 +22399,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" }, { "name": "R1_retracted_true_then_false_under_one_request_id_is_request_id_conflict", @@ -18519,9 +22428,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -18653,6 +22564,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -18692,7 +22604,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" }, { "name": "R1_retracted_false_then_true_under_one_request_id_is_request_id_conflict", @@ -18721,9 +22633,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -18855,6 +22769,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -18894,7 +22809,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" }, { "name": "R1_a_target_less_retraction_with_retracted_flipped_is_body_invalid_before_any_request_id_lookup", @@ -18923,9 +22838,11 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], + "decider_count": 1, "status": "open", "ruling": null, "ruled_from": [], @@ -19051,6 +22968,7 @@ "fork_id": "gamecult-chat:question:slot-mark-consent", "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "counted_answer_id": "n1", + "answerer_count": 1, "answers": [ { "schemaVersion": "gamecult.desk_answer.v0", @@ -19090,7 +23008,7 @@ } ] }, - "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030623030312D303030302D343030302D383030302D303030303030303062303031" + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030623030312D303030302D343030302D383030302D303030303030303062303031" } ] } diff --git a/tests/Bifrost.Web.Tests/Support/DeskReplay.cs b/tests/Bifrost.Web.Tests/Support/DeskReplay.cs index 92eba39..b9e52e6 100644 --- a/tests/Bifrost.Web.Tests/Support/DeskReplay.cs +++ b/tests/Bifrost.Web.Tests/Support/DeskReplay.cs @@ -11,18 +11,18 @@ public static class DeskReplay private static JsonNode BaseCard => DeskVectors.Root["scenarios"]![0]!["card"]!; - public static ForkCard Card(string forkId, string title = "Slot mark consent") + public static JsonElement Card(string forkId, string title = "Slot mark consent") { var card = JsonNode.Parse(BaseCard.ToJsonString())!.AsObject(); card["fork_id"] = forkId; card["title"] = title; - return card.Deserialize(DeskJson.Options)!; + return card.Deserialize(DeskJson.Options); } - public static ForkEntry Entry(ForkCard card) => new( + public static ForkEntry Entry(JsonElement card) => new( "gamecult.fork.entry.v0", card, - new ForkRecord("gamecult.fork.record.v0", card.ForkId, card.OptionsHash, null, [])); + new ForkRecord("gamecult.fork.record.v0", card.GetProperty("fork_id").GetString()!, card.GetProperty("options_hash").GetString()!, null, 0, [])); // Every fork the vectors say each Mind holds, on that Mind's socket. public static void HoldVectorForks(FakeDeskDoor door) diff --git a/tests/Bifrost.Web.Tests/Support/DeskServe.cs b/tests/Bifrost.Web.Tests/Support/DeskServe.cs new file mode 100644 index 0000000..8125645 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/DeskServe.cs @@ -0,0 +1,158 @@ +using System.Buffers; +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using MessagePack; + +namespace Bifrost.Web.Tests.Support; + +// Serves a vector's own wire form (messagePackHex) through the real UnixSocketDeskDoor on a socket double: Huginn's +// externally tagged DeskResponse with the vector's bytes spliced in unchanged as the document. +public static class DeskServe +{ + public static byte[] Hex(JsonNode? hex) => Convert.FromHexString((string)hex!); + + // {variant: {key: , ..., "as_of": asOf}}: the raw bytes ride as they are. + public static byte[] Variant(string variant, (string Key, byte[] Raw)[] fields, ulong asOf = 1) + { + var buffer = new ArrayBufferWriter(); + var w = new MessagePackWriter(buffer); + w.WriteMapHeader(1); + w.Write(variant); + w.WriteMapHeader(fields.Length + 1); + foreach (var (key, raw) in fields) + { + w.Write(key); + w.WriteRaw(raw); + } + + w.Write("as_of"); + w.Write(asOf); + w.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + public static byte[] Str(string? value) + { + var buffer = new ArrayBufferWriter(); + var w = new MessagePackWriter(buffer); + w.Write(value); + w.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + public static byte[] Forks(byte[] list) => Variant("Forks", [("list", list)]); + + public static byte[] Fork(byte[] entry) => Variant("Fork", [("entry", entry)]); + + public static byte[] Answered(byte[] answer, string? counted = null) => + Variant("Answered", [("answer", answer), ("counted_answer_id", Str(counted))]); + + // A socket that answers every request with these payload bytes, built into a frame from the request's id and operation. + public static FakeHuginnSocket Socket(byte[] payload, string status = "accepted") => + new(FakeHuginnSocket.Build((id, operation) => DeskWire.ResponseFrame(id, operation, status, payload))); + + private static readonly DeskDoorContext Context = new("gamecult-chat", "Member", "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f", ["5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f"]); + + public static async Task ListAsync(byte[] listBytes, string mind = "gamecult-chat") + { + using var huginn = Socket(Forks(listBytes)); + var outcome = await new UnixSocketDeskDoor().ListAsync(huginn.Path, Context with { Mind = mind }, new DeskListRequest(mind, "open", null, 50), CancellationToken.None); + return Assert.IsType(outcome).List; + } + + public static async Task ReadAsync(byte[] entryBytes) + { + using var huginn = Socket(Fork(entryBytes)); + // The route names the fork the entry's card names, which is what a real caller's route is. + var forkId = JsonNode.Parse(MessagePackSerializer.ConvertToJson(entryBytes)) is JsonObject { } entry + && entry["card"] is JsonObject { } card && (string?)card["fork_id"] is { } named ? named : OpenCountedFork; + var outcome = await new UnixSocketDeskDoor().ReadAsync(huginn.Path, Context, forkId, CancellationToken.None); + return Assert.IsType(outcome).Entry; + } + + public static async Task AnswerAsync(byte[] answerBytes) + { + using var huginn = Socket(Answered(answerBytes)); + var outcome = await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody(DeskReplay.ChatFork), CancellationToken.None); + return Assert.IsType(outcome).Result.Answer; + } + + // What leaves the door as JSON text, written the way the edge writes it. + public static string Text(T value) => JsonSerializer.SerializeToNode(value, DeskJson.Options)!.ToJsonString(DeskJson.Options); + + public static string Text(JsonNode node) => node.ToJsonString(DeskJson.Options); + + // The app's own configuration with one Mind on the socket double and every other Mind on a path no one listens on. + public static Dictionary ConfigurationOn(string mind, string socket) + { + var configuration = DeskWebApplicationFactory.DefaultConfiguration(); + foreach (var key in configuration.Keys.Where(k => k.EndsWith(":Id", StringComparison.Ordinal)).ToList()) + { + configuration[key[..^2] + "Socket"] = configuration[key] == mind ? socket : FakeHuginnSocket.Absent(); + } + + return configuration; + } + + // An entry map whose card and record bytes are the given ones, untouched. + public static byte[] EntryOf(byte[] card, byte[] record) + { + var buffer = new ArrayBufferWriter(); + var w = new MessagePackWriter(buffer); + w.WriteMapHeader(3); + w.Write("schemaVersion"); + w.Write("gamecult.fork.entry.v0"); + w.Write("card"); + w.WriteRaw(card); + w.Write("record"); + w.WriteRaw(record); + w.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + public static byte[] FromJson(JsonNode node) => MessagePackSerializer.ConvertFromJson(node.ToJsonString(DeskJson.Options)); + + // A list page of one entry, as JSON (the shape the door takes), from a vendored CultLib entry file. + public static JsonObject PageOf(JsonNode entry, string mind = "gamecult-chat") => + JsonNode.Parse($$"""{"schemaVersion":"gamecult.fork.list.v0","mind":"{{mind}}","stamp":"s7","open_count":1,"recorded_count":0,"forks":[{{entry.ToJsonString(DeskJson.Options)}}],"next_cursor":null}""")!.AsObject(); + + public static JsonObject Entry(string file) => + JsonNode.Parse(MessagePackSerializer.ConvertToJson(File.ReadAllBytes(Path.Combine(DeskFeedFixturePinTests.Directory, "cultlib-d3025d3c", file + ".msgpack"))))!.AsObject(); + + // The reply payload exactly as given, asked as a list for "gamecult-chat"; throws what the door throws. + public static async Task ListPayloadAsync(byte[] payload) + { + using var huginn = Socket(payload); + return await new UnixSocketDeskDoor().ListAsync(huginn.Path, Context, new DeskListRequest("gamecult-chat", "open", null, 50), CancellationToken.None); + } + + // The reply payload exactly as given, asked as a read. + public static async Task ReadPayloadAsync(byte[] payload) + { + using var huginn = Socket(payload); + return await new UnixSocketDeskDoor().ReadAsync(huginn.Path, Context, OpenCountedFork, CancellationToken.None); + } + + // The fork the vectors' entry-open-counted names: the route of a read whose reply is a canned payload. + private static string OpenCountedFork => (string)Entry("entry-open-counted")["card"]!["fork_id"]!; + + public static async Task AnswerPayloadAsync(byte[] payload) + { + using var huginn = Socket(payload); + return await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody(DeskReplay.ChatFork), CancellationToken.None); + } + + // A JSON text becomes MessagePack with the one str `marker` (at most 31 bytes) replaced by `with`: a bin, bytes that + // are not UTF-8. JSON cannot spell either, so the marker stands where the test wants one. + public static byte[] WithMarkerReplaced(JsonNode json, string marker, byte[] with) + { + var bytes = MessagePackSerializer.ConvertFromJson(json.ToJsonString(DeskJson.Options)); + var needle = new byte[] { (byte)(0xa0 | marker.Length) }.Concat(System.Text.Encoding.ASCII.GetBytes(marker)).ToArray(); + var at = bytes.AsSpan().IndexOf(needle); + Assert.True(at >= 0 && bytes.AsSpan(at + 1).IndexOf(needle) < 0, "the marker is in the bytes exactly once"); + return [.. bytes[..at], .. with, .. bytes[(at + needle.Length)..]]; + } + + public static byte[] Bin(int length) => [0xc4, (byte)length, .. Enumerable.Repeat((byte)'x', length)]; +} diff --git a/tests/Bifrost.Web.Tests/Support/DeskVectors.cs b/tests/Bifrost.Web.Tests/Support/DeskVectors.cs index f613b9f..91c8f3c 100644 --- a/tests/Bifrost.Web.Tests/Support/DeskVectors.cs +++ b/tests/Bifrost.Web.Tests/Support/DeskVectors.cs @@ -3,12 +3,13 @@ using System.Text.Json.Nodes; namespace Bifrost.Web.Tests.Support; -// CultLib's fork-card vectors, vendored at commit de8a062ab9d8c510c8b36e634fae8f108a570aff. +// CultLib's fork-card vectors, vendored at commit d3025d3c19674803ad32eb2335461b8cbc22bd28 (#118: append-only on 3a51bbab, +// card DE0012 and record 86 unchanged). public static class DeskVectors { - public const string SourceCommit = "de8a062ab9d8c510c8b36e634fae8f108a570aff"; + public const string SourceCommit = "d3025d3c19674803ad32eb2335461b8cbc22bd28"; - public const string FileSha256 = "a7129eee00cb38c602481b0de7de0182f83122685b69ff441e3123adc3e0f774"; + public const string FileSha256 = "c2b215a81e6708855b2424f8383f1dff0f3877b7fc9264102ca61d302b00c24c"; public static string Path => System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "fork-card-vectors.json"); diff --git a/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs index c5c81ea..1247f99 100644 --- a/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs @@ -29,6 +29,7 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory private readonly Dictionary? configuration; private readonly bool registerPorts; private readonly bool realCredential; + private readonly bool fakeDoor; // A configured desk needs Bifrost:Oidc (its credential validates against the local issuer): generated keys per factory. private readonly OidcTestKit oidcKit = new(); @@ -36,9 +37,12 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory // The chat client's secret, for a test that holds the chat client's own token. public string OidcSecret => oidcKit.ClientSecret; - public DeskWebApplicationFactory(Dictionary? configuration = null, bool configured = true, bool registerPorts = true, bool realCredential = false) + // fakeDoor false leaves the door to the app's own wiring (UnixSocketDeskDoor), for tests that stand a socket up. + public DeskWebApplicationFactory( + Dictionary? configuration = null, bool configured = true, bool registerPorts = true, bool realCredential = false, bool fakeDoor = true) { this.realCredential = realCredential; + this.fakeDoor = fakeDoor; this.configuration = configured ? configuration ?? DefaultConfiguration() : null; this.registerPorts = registerPorts; } @@ -141,7 +145,10 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory builder.ConfigureServices(services => { services.AddSingleton(Credential); - services.AddSingleton(Door); + if (fakeDoor) + { + services.AddSingleton(Door); + } }); } } diff --git a/tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs b/tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs new file mode 100644 index 0000000..30b8b15 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/FakeHuginnSocket.cs @@ -0,0 +1,197 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Net.Sockets; +using System.Text.Json; +using MessagePack; + +namespace Bifrost.Web.Tests.Support; + +// Huginn's desk door as a test double on a real unix socket: it accepts connections, reads each request frame, +// records it, and lets the test decide what happens next (reply, dribble, stall, close). +public sealed class FakeHuginnSocket : IDisposable +{ + private readonly Socket listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + private readonly CancellationTokenSource stop = new(); + private readonly string directory = Directory.CreateTempSubdirectory("hd").FullName; + private readonly Func handler; + + public FakeHuginnSocket(Func handler) + { + this.handler = handler; + Path = System.IO.Path.Combine(directory, "d.sock"); + listener.Bind(new UnixDomainSocketEndPoint(Path)); + listener.Listen(16); + _ = Task.Run(AcceptLoopAsync); + } + + public string Path { get; } + + // The whole request frame of every connection, header included. + public List Requests { get; } = []; + + // A path no one listens on. + public static string Absent() => System.IO.Path.Combine(Directory.CreateTempSubdirectory("hd").FullName, "absent.sock"); + + public static byte[] Fixture(string name) => + File.ReadAllBytes(System.IO.Path.Combine(DeskFeedFixturePinTests.Directory, name + ".frame.bin")); + + // Writes the bytes and closes. + public static Func Replay(byte[] frame) => + async (socket, _, token) => await socket.SendAsync(frame, SocketFlags.None, token); + + // Answers each request by its own message id and operation: the reply is built from the request. + public static Func Answer(Func variantJson, string status = "accepted") => + async (socket, request, token) => + { + using var envelope = DeskWire.Envelope(request); + var root = envelope.RootElement; + var operation = root.GetProperty("operation").GetString()!; + var reply = DeskWire.ResponseFrame(root.GetProperty("messageId").GetString()!, operation, status, variantJson(operation, envelope)); + await socket.SendAsync(reply, SocketFlags.None, token); + }; + + // Answers each request with a frame the test builds from the request's message id and operation. + public static Func Build(Func frameOf) => + async (socket, request, token) => + { + using var envelope = DeskWire.Envelope(request); + var root = envelope.RootElement; + await socket.SendAsync(frameOf(root.GetProperty("messageId").GetString()!, root.GetProperty("operation").GetString()!), SocketFlags.None, token); + }; + + private async Task AcceptLoopAsync() + { + while (!stop.IsCancellationRequested) + { + Socket client; + try + { + client = await listener.AcceptAsync(stop.Token); + } + catch (Exception) + { + return; + } + + _ = Task.Run(async () => + { + using (client) + { + try + { + var header = new byte[4]; + await client.ReceiveAllAsync(header, stop.Token); + var body = new byte[BinaryPrimitives.ReadUInt32BigEndian(header)]; + await client.ReceiveAllAsync(body, stop.Token); + var request = header.Concat(body).ToArray(); + lock (Requests) + { + Requests.Add(request); + } + + await handler(client, request, stop.Token); + } + catch (Exception) + { + // The client went away, or the test ended. + } + } + }); + } + } + + public void Dispose() + { + stop.Cancel(); + listener.Dispose(); + try + { + Directory.Delete(directory, true); + } + catch (IOException) + { + } + } +} + +internal static class SocketReceive +{ + public static async Task ReceiveAllAsync(this Socket socket, byte[] buffer, CancellationToken token) + { + for (var read = 0; read < buffer.Length;) + { + var count = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, token); + if (count == 0) + { + throw new EndOfStreamException(); + } + + read += count; + } + } +} + +// Frames and envelopes as the golden fixtures spell them, for building replies and reading requests. +public static class DeskWire +{ + public static JsonDocument Envelope(byte[] frame) + { + var reader = new MessagePackReader(frame.AsMemory(4)); + var json = new StringWriter(); + MessagePackSerializer.ConvertToJson(ref reader, json); + return JsonDocument.Parse(json.ToString()); + } + + // The base64 payload of an envelope, decoded to JSON. + public static JsonDocument Payload(JsonDocument envelope) + { + var bytes = Convert.FromBase64String(envelope.RootElement.GetProperty("payload").GetString()!); + var reader = new MessagePackReader(bytes); + var json = new StringWriter(); + MessagePackSerializer.ConvertToJson(ref reader, json); + return JsonDocument.Parse(json.ToString()); + } + + // A reply frame in operation-message key order. `mutate` edits the text fields (a pair can be added twice) before + // they are written; `trailing` rides inside the frame's one message, after the map. + public static byte[] ResponseFrame( + string messageId, string operation, string status, string variantJson, Action>? mutate = null) => + ResponseFrame(messageId, operation, status, MessagePackSerializer.ConvertFromJson(variantJson), mutate); + + public static byte[] ResponseFrame( + string messageId, string operation, string status, byte[] payload, Action>? mutate = null, byte[]? trailing = null) + { + var fields = new List<(string Key, string Value)> + { + ("messageId", messageId), + ("operation", operation), + ("payload", Convert.ToBase64String(payload)), + ("payloadEncoding", "messagepack-base64"), + ("payloadSchema", "huginn.desk_response.v1"), + ("schemaVersion", "cultnet.operation_response.v0"), + ("serviceId", "huginn.desk"), + ("sourceRuntimeId", "huginn-test"), + ("status", status), + }; + mutate?.Invoke(fields); + var buffer = new ArrayBufferWriter(); + var w = new MessagePackWriter(buffer); + w.WriteMapHeader(fields.Count); + foreach (var (key, value) in fields) + { + w.Write(key); + w.Write(value); + } + + w.Flush(); + return Frame([.. buffer.WrittenSpan, .. trailing ?? []]); + } + + public static byte[] Frame(ReadOnlySpan message) + { + var frame = new byte[4 + message.Length]; + BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)message.Length); + message.CopyTo(frame.AsSpan(4)); + return frame; + } +} diff --git a/tests/Bifrost.Web.Tests/UnixSocketDeskDoorBytesTests.cs b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorBytesTests.cs new file mode 100644 index 0000000..2272696 --- /dev/null +++ b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorBytesTests.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +// The reply's bytes that JSON cannot spell: a MessagePack bin where the contract has text or nil, and text that is not +// UTF-8. Either is a door failure wherever it sits, never a nil or a replacement character Bifrost made up. +public sealed class UnixSocketDeskDoorBytesTests +{ + private const string Marker = "@@MARK@@"; + + // Where a value can stand: a nullable key (note, answerer, next_cursor), a text key, and a card position. + public static TheoryData Positions() => new() + { + "note", "answerer", "next_cursor", "card.raised_at", "card.title", "card.options[0].text", "record.counted_answer_id", "mind", "card_key_name", + }; + + private static JsonObject PageWithMarkerAt(string position) + { + var page = DeskServe.PageOf(DeskServe.Entry("entry-open-counted")); + var entry = page["forks"]![0]!.AsObject(); + switch (position) + { + case "note": entry["record"]!["answers"]![0]!["note"] = Marker; break; + case "answerer": entry["record"]!["answers"]![0]!["answerer"] = Marker; break; + case "next_cursor": page["next_cursor"] = Marker; break; + case "card.raised_at": entry["card"]!["raised_at"] = Marker; break; + case "card.title": entry["card"]!["title"] = Marker; break; + case "card.options[0].text": entry["card"]!["options"]![0]!["text"] = Marker; break; + case "record.counted_answer_id": entry["record"]!["counted_answer_id"] = Marker; break; + case "mind": page["mind"] = Marker; break; + case "card_key_name": + var card = entry["card"]!.AsObject(); + var rest = card.ToList(); + card.Clear(); + foreach (var (key, value) in rest) + { + card[key == "title" ? Marker : key] = value; + } + + break; + default: throw new ArgumentOutOfRangeException(nameof(position)); + } + + return page; + } + + [Theory] + [MemberData(nameof(Positions))] + public async Task A_binary_in_a_nullable_text_or_card_position_is_a_door_failure(string position) + { + var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, DeskServe.Bin(3)); + + await Assert.ThrowsAsync(() => DeskServe.ListPayloadAsync(DeskServe.Forks(bytes))); + } + + [Theory] + [MemberData(nameof(Positions))] + public async Task Invalid_utf8_in_text_is_a_door_failure(string position) + { + // 0xff 0xfe as a str of 2 bytes; Bifrost writes no replacement character. + var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, [0xa2, 0xff, 0xfe]); + + await Assert.ThrowsAsync(() => DeskServe.ListPayloadAsync(DeskServe.Forks(bytes))); + } + + [Fact] + public async Task The_same_marker_as_valid_text_decodes_so_the_failures_above_are_the_bytes_not_the_page() + { + foreach (var position in new[] { "note", "answerer", "next_cursor", "card.raised_at", "card.title" }) + { + var bytes = DeskServe.WithMarkerReplaced(PageWithMarkerAt(position), Marker, [.. new byte[] { 0xa2 }, .. "ok"u8.ToArray()]); + + Assert.IsType(await DeskServe.ListPayloadAsync(DeskServe.Forks(bytes))); + } + } + + [Fact] + public async Task A_binary_or_invalid_utf8_in_a_read_or_an_answer_is_a_door_failure_too() + { + var entry = DeskServe.Entry("entry-open-counted"); + entry["record"]!["answers"]![0]!["note"] = Marker; + foreach (var with in new[] { DeskServe.Bin(2), new byte[] { 0xa2, 0xff, 0xfe } }) + { + var read = DeskServe.WithMarkerReplaced(entry, Marker, with); + await Assert.ThrowsAsync(() => DeskServe.ReadPayloadAsync(DeskServe.Fork(read))); + + var answer = DeskServe.Entry("entry-open-counted")["record"]!["answers"]![0]!; + answer["note"] = Marker; + var bytes = DeskServe.WithMarkerReplaced(answer, Marker, with); + await Assert.ThrowsAsync(() => DeskServe.AnswerPayloadAsync(DeskServe.Answered(bytes, "n1"))); + } + } +} diff --git a/tests/Bifrost.Web.Tests/UnixSocketDeskDoorRequiredKeysTests.cs b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorRequiredKeysTests.cs new file mode 100644 index 0000000..ad95a73 --- /dev/null +++ b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorRequiredKeysTests.cs @@ -0,0 +1,96 @@ +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +// The wire walker's closed maps (Closed in UnixSocketDeskDoor) are the single owner of "every contract key present with +// its type": the typed records carry no JsonRequired of their own. Each key of each contract document, taken from a real +// CultLib entry, is removed and, where the contract does not allow nil, nulled: every one is a door failure. +public sealed class UnixSocketDeskDoorRequiredKeysTests +{ + private static readonly HashSet Nullable = ["next_cursor", "counted_answer_id", "of", "retracts", "code", "choice", "note", "answerer"]; + + private static JsonObject Level(JsonObject page, string level) => level switch + { + "list" => page, + "entry" => page["forks"]![0]!.AsObject(), + "record" => page["forks"]![0]!["record"]!.AsObject(), + "answer" => page["forks"]![0]!["record"]!["answers"]![0]!.AsObject(), + _ => throw new ArgumentOutOfRangeException(nameof(level)), + }; + + public static TheoryData Keys() + { + var page = DeskServe.PageOf(DeskServe.Entry("entry-open-counted")); + var data = new TheoryData(); + foreach (var level in new[] { "list", "entry", "record", "answer" }) + { + foreach (var key in Level(page, level).Select(p => p.Key)) + { + data.Add(level, key); + } + } + + return data; + } + + private static JsonObject Page() => DeskServe.PageOf(DeskServe.Entry("entry-open-counted")); + + [Theory] + [MemberData(nameof(Keys))] + public async Task A_missing_required_key_is_a_door_failure(string level, string key) + { + var page = Page(); + Level(page, level).Remove(key); + + await Assert.ThrowsAsync(() => DeskServe.ListPayloadAsync(DeskServe.Forks(DeskServe.FromJson(page)))); + } + + [Theory] + [MemberData(nameof(Keys))] + public async Task A_nil_where_the_contract_has_no_nil_is_a_door_failure(string level, string key) + { + var page = Page(); + Level(page, level)[key] = null; + var failing = !Nullable.Contains(key); + + var served = DeskServe.ListPayloadAsync(DeskServe.Forks(DeskServe.FromJson(page))); + + if (failing) + { + await Assert.ThrowsAsync(() => served); + } + else + { + Assert.IsType(await served); + } + } + + [Theory] + [InlineData("answerer")] + [InlineData("answerer_count_is_not_an_answer_key")] + public async Task A_stored_answer_in_an_answered_reply_needs_the_same_keys(string key) + { + var answer = DeskServe.Entry("entry-open-counted")["record"]!["answers"]![0]!.AsObject(); + if (answer.ContainsKey(key)) + { + answer.Remove(key); + } + else + { + answer[key] = 1; + } + + await Assert.ThrowsAsync(() => DeskServe.AnswerPayloadAsync(DeskServe.Answered(DeskServe.FromJson(answer), "n1"))); + } + + [Fact] + public void The_typed_records_declare_no_required_keys_of_their_own() + { + var typed = new[] { typeof(DeskAnswerDocument), typeof(ForkRecord), typeof(ForkEntry), typeof(ForkList), typeof(AnswerResult) }; + var required = typed.SelectMany(t => t.GetProperties()).Where(p => p.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonRequiredAttribute), true).Length > 0); + + Assert.Empty(required.Select(p => p.DeclaringType!.Name + "." + p.Name)); + } +} diff --git a/tests/Bifrost.Web.Tests/UnixSocketDeskDoorStrictTests.cs b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorStrictTests.cs new file mode 100644 index 0000000..a142f1d --- /dev/null +++ b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorStrictTests.cs @@ -0,0 +1,339 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; +using MessagePack; + +namespace Bifrost.Web.Tests; + +// The door reads Huginn's reply strictly: text survives whatever it holds, every contract map is closed and typed, +// a key written twice or a float is a failure, and every malformed reply is a DeskDoorException (never a null +// reference, never an invalid-operation). +public sealed class UnixSocketDeskDoorStrictTests +{ + private static readonly DeskDoorContext Context = new("eureka", "Member", "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f", ["5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f", "0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d"]); + + private const string Card = """{"schemaVersion":"gamecult.fork.card.v0","fork_id":"x:question:a","title":"t","order":1,"options_hash":"ab"}"""; + + private static string AnswerJson(string note = "null", string answerer = "\"a1\"") => + $$$"""{"schemaVersion":"gamecult.desk_answer.v0","answer_id":"n1","fork_id":"x:question:a","outcome":"Counted","of":null,"retracts":null,"code":null,"options_hash":"ab","choice":"a","note":{{{note}}},"answered_at":"2026-11-11T09:00:00Z","received_at":"2026-11-11T09:00:01Z","claim_in_future":false,"answerer":{{{answerer}}},"authority":"Member","request_id":"r"}"""; + + private static string RecordJson(string answers = "[]", string extra = "") => + $$$"""{"schemaVersion":"gamecult.fork.record.v0","fork_id":"x:question:a","options_hash":"ab","counted_answer_id":null,"answerer_count":0,"answers":{{{answers}}}{{{extra}}}}"""; + + private static string EntryJson(string card = Card, string? record = null, string extra = "") => + $$$"""{"schemaVersion":"gamecult.fork.entry.v0","card":{{{card}}},"record":{{{record ?? RecordJson()}}}{{{extra}}}}"""; + + private static string ListJson(string forks, string mind = "\"eureka\"", string keys = "\"open_count\":1,\"recorded_count\":0,") => + $$$"""{"schemaVersion":"gamecult.fork.list.v0","mind":{{{mind}}},"stamp":"s1",{{{keys}}}"forks":{{{forks}}},"next_cursor":null}"""; + + private static string Forks(string list) => $$$"""{"Forks":{"list":{{{list}}},"as_of":1}}"""; + + private static Task List(FakeHuginnSocket huginn) => + new UnixSocketDeskDoor(() => "m-1").ListAsync(huginn.Path, Context, new DeskListRequest("eureka", "open", null, 50), CancellationToken.None); + + private static Task Read(FakeHuginnSocket huginn) => + new UnixSocketDeskDoor(() => "m-1").ReadAsync(huginn.Path, Context, "x:question:a", CancellationToken.None); + + private static FakeHuginnSocket Replying(string variantJson, string status = "accepted") => + new(FakeHuginnSocket.Answer((_, _) => variantJson, status)); + + private static FakeHuginnSocket ReplyingBytes(byte[] payload, Action>? mutate = null, byte[]? trailing = null, string operation = "forks") => + new(FakeHuginnSocket.Build((id, _) => DeskWire.ResponseFrame(id, operation, "accepted", payload, mutate, trailing))); + + private static async Task Faults(Func call) => await Assert.ThrowsAsync(call); + + // ---- F1: control characters in text ---- + + [Fact] + public async Task Text_with_control_characters_survives_the_door_and_the_edge() + { + const string Note = "n\\u0000\\u0007\\u000b\\u001b\\u001f\\u0008\\t\\n\\\"\\\\ end"; + var card = Card.Replace("\"t\"", "\"title \\u001b\\u0000\"", StringComparison.Ordinal); + var entry = EntryJson(card, RecordJson($"[{AnswerJson($"\"{Note}\"")}]").Replace("\"answerer_count\":0", "\"answerer_count\":1", StringComparison.Ordinal)); + using var huginn = Replying(Forks(ListJson($"[{entry}]"))); + + var listed = Assert.IsType(await List(huginn)); + + var expected = "n\0\a\v\u001b\u001f\b\t\n\"\\ end"; + Assert.Equal(expected, Assert.Single(Assert.Single(listed.List.Forks).Record.Answers).Note); + Assert.Equal("title \u001b\0", Assert.Single(listed.List.Forks).Card.GetProperty("title").GetString()); + + // The edge writes it back as JSON a client can parse, the decider's note whole. + using var chat = Replying(Forks(ListJson($"[{entry}]", "\"gamecult-chat\""))); + var config = DeskWebApplicationFactory.DefaultConfiguration(); + foreach (var key in config.Keys.Where(k => k.EndsWith(":Id", StringComparison.Ordinal)).ToList()) + { + config[key[..^2] + "Socket"] = config[key] == "nibu" ? FakeHuginnSocket.Absent() : chat.Path; + } + + using var factory = new DeskWebApplicationFactory(config, fakeDoor: false); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read")); + + Assert.Equal(200, reply.Status); + Assert.Equal(expected, (string?)JsonNode.Parse(reply.Text)!["forks"]![0]!["record"]!["answers"]![0]!["note"]); + } + + // ---- F2 / F3: strict documents ---- + + public static TheoryData MalformedLists() + { + var one = EntryJson(); + return new TheoryData + { + { "missing open_count", Forks(ListJson($"[{one}]", keys: "\"recorded_count\":0,")) }, + { "missing recorded_count", Forks(ListJson($"[{one}]", keys: "\"open_count\":1,")) }, + { "missing both counts", Forks(ListJson($"[{one}]", keys: "")) }, + { "negative count", Forks(ListJson($"[{one}]", keys: "\"open_count\":-1,\"recorded_count\":0,")) }, + { "mind is another Mind's", Forks(ListJson($"[{one}]", "\"nibu\"")) }, + { "mind is not text", Forks(ListJson($"[{one}]", "7")) }, + { "missing next_cursor", Forks(ListJson($"[{one}]").Replace(",\"next_cursor\":null", "", StringComparison.Ordinal)) }, + { "unknown list key", Forks(ListJson($"[{one}]").Replace("\"stamp\"", "\"extra\":1,\"stamp\"", StringComparison.Ordinal)) }, + { "forks is null", Forks(ListJson("null")) }, + { "forks:[null]", Forks(ListJson("[null]")) }, + { "forks:[5]", Forks(ListJson("[5]")) }, + { "card is not a map", Forks(ListJson($"[{EntryJson("[]")}]")) }, + { "card is null", Forks(ListJson($"[{EntryJson("null")}]")) }, + { "record missing schemaVersion", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"schemaVersion\":\"gamecult.fork.record.v0\",", "", StringComparison.Ordinal))}]")) }, + { "record missing fork_id", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"fork_id\":\"x:question:a\",", "", StringComparison.Ordinal))}]")) }, + { "record missing options_hash", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"options_hash\":\"ab\",", "", StringComparison.Ordinal))}]")) }, + { "record missing answerer_count", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"answerer_count\":0,", "", StringComparison.Ordinal))}]")) }, + { "record answerer_count is a string", Forks(ListJson($"[{EntryJson(record: RecordJson().Replace("\"answerer_count\":0", "\"answerer_count\":\"0\"", StringComparison.Ordinal))}]")) }, + { "answers:[null]", Forks(ListJson($"[{EntryJson(record: RecordJson("[null]"))}]")) }, + { "answers is null", Forks(ListJson($"[{EntryJson(record: RecordJson("null"))}]")) }, + { "answer with only schemaVersion", Forks(ListJson($"[{EntryJson(record: RecordJson("[{\"schemaVersion\":\"gamecult.desk_answer.v0\"}]"))}]")) }, + { "answer missing answerer", Forks(ListJson($"[{EntryJson(record: RecordJson($"[{AnswerJson().Replace(",\"answerer\":\"a1\"", "", StringComparison.Ordinal)}]"))}]")) }, + { "answer claim_in_future is text", Forks(ListJson($"[{EntryJson(record: RecordJson($"[{AnswerJson().Replace("\"claim_in_future\":false", "\"claim_in_future\":\"no\"", StringComparison.Ordinal)}]"))}]")) }, + { "unknown record key", Forks(ListJson($"[{EntryJson(record: RecordJson(extra: ",\"viewer\":1"))}]")) }, + { "unknown entry key", Forks(ListJson($"[{EntryJson(extra: ",\"viewer\":1")}]")) }, + { "unknown answer key", Forks(ListJson($"[{EntryJson(record: RecordJson($"[{AnswerJson().TrimEnd('}')},\"viewer\":1}}]"))}]")) }, + { "duplicate key in the record", Forks(ListJson($"[{EntryJson(record: RecordJson(extra: ",\"fork_id\":\"other\""))}]")) }, + { "duplicate key in the card", Forks(ListJson($"[{EntryJson(Card.TrimEnd('}') + ",\"title\":\"again\"}")}]")) }, + { "duplicate key in the list", Forks(ListJson($"[{one}]").TrimEnd('}') + ",\"stamp\":\"s2\"}") }, + { "float in the card", Forks(ListJson($"[{EntryJson(Card.TrimEnd('}') + ",\"weight\":1.0}")}]")) }, + { "float in the record", Forks(ListJson($"[{EntryJson(record: RecordJson(extra: ",\"weight\":1.5"))}]")) }, + { "Forks value is a bare number", """{"Forks":5}""" }, + { "Forks value is null", """{"Forks":null}""" }, + { "Forks without list", """{"Forks":{"as_of":1}}""" }, + { "NotStored is text", """{"NotStored":"x"}""" }, + { "NotStored is null", """{"NotStored":null}""" }, + { "NotStored refusal has an extra key", """{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_unknown","error":"x","z":1}}""" }, + }; + } + + [Theory] + [MemberData(nameof(MalformedLists))] + public async Task A_malformed_list_reply_is_a_door_failure(string label, string reply) + { + _ = label; + using var huginn = Replying(reply, reply.StartsWith("{\"NotStored", StringComparison.Ordinal) ? "rejected" : "accepted"); + await Faults(() => List(huginn)); + } + + public static TheoryData MalformedReads() => new() + { + { "Fork is a number", """{"Fork":5}""" }, + { "Fork is null", """{"Fork":null}""" }, + { "Fork without entry", """{"Fork":{"as_of":1}}""" }, + { "entry holds answers:[null]", $$$"""{"Fork":{"entry":{{{EntryJson(record: RecordJson("[null]"))}}},"as_of":1}}""" }, + { "entry with a nil card", $$$"""{"Fork":{"entry":{{{EntryJson("null")}}},"as_of":1}}""" }, + }; + + [Theory] + [MemberData(nameof(MalformedReads))] + public async Task A_malformed_read_reply_is_a_door_failure(string label, string reply) + { + _ = label; + using var huginn = Replying(reply); + await Faults(() => Read(huginn)); + } + + [Fact] + public async Task Answered_with_only_a_schemaVersion_or_a_missing_counted_id_is_a_failure() + { + foreach (var reply in new[] + { + """{"Answered":{"answer":{"schemaVersion":"gamecult.desk_answer.v0"},"counted_answer_id":null,"as_of":1}}""", + """{"Answered":{"answer":null,"counted_answer_id":null,"as_of":1}}""", + """{"Answered":5}""", + $$$"""{"Answered":{"answer":{{{AnswerJson()}}},"as_of":1}}""", + }) + { + using var huginn = Replying(reply); + await Faults(() => new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None)); + } + } + + [Fact] + public async Task fork_too_large_is_never_an_answer_refusal() + { + using var huginn = Replying("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_too_large","error":"fork_too_large: fork x:question:a is past the response bound by itself"}}""", "rejected"); + + await Faults(() => new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None)); + } + + [Fact] + public async Task A_nil_answerer_and_big_integers_come_through_whole() + { + var card = Card.TrimEnd('}') + ",\"big\":18446744073709551615,\"low\":-9223372036854775808,\"n\":null}"; + var entry = EntryJson(card, RecordJson($"[{AnswerJson(answerer: "null")}]").Replace("\"answerer_count\":0", "\"answerer_count\":1", StringComparison.Ordinal)); + using var huginn = Replying(Forks(ListJson($"[{entry}]"))); + + var listed = Assert.IsType(await List(huginn)); + + var fork = Assert.Single(listed.List.Forks); + Assert.Equal(card, JsonSerializer.Serialize(fork.Card, DeskJson.Options)); + Assert.Null(Assert.Single(fork.Record.Answers).Answerer); + } + + // ---- F4 / the survivors: the envelope and the bytes around it ---- + + private static byte[] ListPayload() => MessagePackSerializer.ConvertFromJson(Forks(ListJson($"[{EntryJson()}]"))); + + [Fact] + public async Task A_well_formed_reply_built_the_same_way_decodes() + { + using var huginn = ReplyingBytes(ListPayload()); + Assert.IsType(await List(huginn)); + } + + [Fact] + public async Task Payload_base64_must_be_strict_standard_base64() + { + foreach (var mutate in new Action>[] + { + fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", Insert(fields, "\n")), + fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", Insert(fields, " ")), + fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", fields.First(f => f.Key == "payload").Value.Replace('+', '-').Replace('/', '_') + "A"), + fields => fields[fields.FindIndex(f => f.Key == "payload")] = ("payload", fields.First(f => f.Key == "payload").Value.TrimEnd('=')), + }) + { + using var huginn = ReplyingBytes(ListPayload(), mutate); + await Faults(() => List(huginn)); + } + } + + private static string Insert(List<(string Key, string Value)> fields, string text) + { + var value = fields.First(f => f.Key == "payload").Value; + return value[..8] + text + value[8..]; + } + + [Fact] + public async Task Bytes_after_the_payload_message_or_after_the_frame_message_are_a_failure() + { + using var afterPayload = ReplyingBytes([.. ListPayload(), 0xc0]); + await Faults(() => List(afterPayload)); + + using var afterFrame = ReplyingBytes(ListPayload(), trailing: [0xc0]); + await Faults(() => List(afterFrame)); + } + + [Fact] + public async Task A_key_written_twice_in_the_envelope_or_the_variant_is_a_failure() + { + using var envelope = ReplyingBytes(ListPayload(), fields => fields.Add(("status", "rejected"))); + await Faults(() => List(envelope)); + + using var variant = ReplyingBytes(MessagePackSerializer.ConvertFromJson(Forks(ListJson("[]")).Replace("\"as_of\":1", "\"as_of\":1,\"as_of\":2", StringComparison.Ordinal))); + await Faults(() => List(variant)); + } + + // The payload is one tagged variant. A second key beside it, whatever it is called, is not a reply the door reads. + [Theory] + [InlineData("X")] + [InlineData("Refused")] + [InlineData("Forks2")] + public async Task A_payload_with_a_second_key_beside_the_variant_is_a_failure(string second) + { + var twoKeys = Forks(ListJson($"[{EntryJson()}]"))[..^1] + $",\"{second}\":\"Unavailable\"}}"; + using var huginn = ReplyingBytes(MessagePackSerializer.ConvertFromJson(twoKeys)); + + await Faults(() => List(huginn)); + } + + // The envelope is an operation response or nothing: a different schemaVersion is not read as one. + [Theory] + [InlineData("cultnet.operation_response.v1")] + [InlineData("cultnet.operation_response.v")] + [InlineData("")] + [InlineData("CULTNET.OPERATION_RESPONSE.V0")] + public async Task An_envelope_with_another_schemaVersion_is_a_failure(string schemaVersion) + { + using var huginn = ReplyingBytes(ListPayload(), fields => + { + var at = fields.FindIndex(f => f.Key == "schemaVersion"); + fields[at] = ("schemaVersion", schemaVersion); + }); + + await Faults(() => List(huginn)); + } + + // The route names the fork; an entry for another fork is not the answer to the question asked, however well formed. + [Fact] + public async Task A_read_that_returns_another_forks_entry_is_a_failure() + { + var other = Card.Replace("x:question:a", "x:question:b", StringComparison.Ordinal); + var record = RecordJson().Replace("\"fork_id\":\"x:question:a\"", "\"fork_id\":\"x:question:b\"", StringComparison.Ordinal); + using var huginn = Replying($$$"""{"Fork":{"entry":{{{EntryJson(other, record)}}},"as_of":1}}"""); + + await Faults(() => Read(huginn)); + } + + [Fact] + public async Task A_read_whose_record_disagrees_with_its_card_is_not_the_doors_rule() + { + var record = RecordJson().Replace("\"fork_id\":\"x:question:a\"", "\"fork_id\":\"x:question:b\"", StringComparison.Ordinal); + using var huginn = Replying($$$"""{"Fork":{"entry":{{{EntryJson(record: record)}}},"as_of":1}}"""); + + Assert.IsType(await Read(huginn)); + } + + [Fact] + public async Task A_read_of_the_named_fork_is_found() + { + using var huginn = Replying($$$"""{"Fork":{"entry":{{{EntryJson()}}},"as_of":1}}"""); + + Assert.IsType(await Read(huginn)); + } + + private static byte[] BinaryList() + { + var w = new System.Buffers.ArrayBufferWriter(); + var writer = new MessagePackWriter(w); + writer.WriteMapHeader(1); + writer.Write("Forks"); + writer.WriteMapHeader(2); + writer.Write("list"); + writer.Write(new byte[] { 1, 2, 3 }); + writer.Write("as_of"); + writer.Write(1); + writer.Flush(); + return w.WrittenSpan.ToArray(); + } + + [Fact] + public async Task A_binary_anywhere_is_a_failure_not_a_rewritten_value() + { + using var bin = ReplyingBytes(BinaryList()); + await Faults(() => List(bin)); + } + + // ---- the request, value by value ---- + + [Fact] + public async Task An_answer_request_carries_the_contexts_mind_subject_authority_and_deciders_and_the_body_as_a_map() + { + using var huginn = Replying("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_unknown","error":"x"}}""", "rejected"); + + await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None); + + using var envelope = DeskWire.Envelope(Assert.Single(huginn.Requests)); + using var payload = DeskWire.Payload(envelope); + var answer = payload.RootElement.GetProperty("Answer"); + Assert.Equal(("eureka", "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f", "Member"), (answer.GetProperty("mind").GetString(), answer.GetProperty("subject").GetString(), answer.GetProperty("authority").GetString())); + Assert.Equal(Context.Deciders, answer.GetProperty("deciders").EnumerateArray().Select(d => d.GetString())); + Assert.Equal("x:question:a", answer.GetProperty("request").GetProperty("fork_id").GetString()); + } +} diff --git a/tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs new file mode 100644 index 0000000..316af34 --- /dev/null +++ b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorTests.cs @@ -0,0 +1,594 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Nodes; +using MessagePack; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +// The production door against a real unix socket and Huginn's golden frames (fixtures at Huginn 232764bf, vectors at CultLib d3025d3c). +public sealed class UnixSocketDeskDoorTests +{ + private const string Fork = "eureka-substrate:question:desk-listing-authority"; + + // The fixtures' account ids: lowercase D-format UUIDs, synthetic. + private const string Decider = "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f"; + + private static readonly DeskDoorContext Context = new("eureka", "Member", Decider, [Decider, "0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d"]); + + private static UnixSocketDeskDoor DoorWith(string messageId) => new(() => messageId); + + private static Task List(UnixSocketDeskDoor door, string socket, string? status = "open", string? cursor = null, int limit = 50) => + door.ListAsync(socket, Context, new DeskListRequest("eureka", status, cursor, limit), CancellationToken.None); + + private static Task Read(UnixSocketDeskDoor door, string socket, string forkId = Fork) => + door.ReadAsync(socket, Context, forkId, CancellationToken.None); + + private const string UnknownFork = + """{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_unknown","error":"fork_unknown: no such fork"}}"""; + + // ---- (a) the request frames, byte for byte ---- + + [Theory] + [InlineData("list-open.request", "m-1")] + [InlineData("list-open-cursor.request", "m-2")] + [InlineData("list-counts.request", "m-3")] + [InlineData("read.request", "m-4")] + public async Task Request_frames_are_the_golden_bytes(string fixture, string messageId) + { + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected")); + var door = DoorWith(messageId); + + _ = fixture switch + { + "list-open.request" => await List(door, huginn.Path), + "list-open-cursor.request" => await List(door, huginn.Path, cursor: "c42"), + "list-counts.request" => await List(door, huginn.Path, limit: 0), + _ => (object)await Read(door, huginn.Path), + }; + + Assert.Equal(FakeHuginnSocket.Fixture(fixture), Assert.Single(huginn.Requests)); + } + + [Fact] + public async Task An_answer_request_is_the_Answer_variant_with_the_clients_body_as_a_map_in_its_own_order() + { + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected")); + var body = DeskReplay.AnswerBody(DeskReplay.ChatFork); + var context = new DeskDoorContext("gamecult-chat", "Steward", "9f8e", ["9f8e", "aa"]); + + var outcome = await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, context, body, CancellationToken.None); + + Assert.Equal(new DeskAnswerOutcome.NotStored("fork_unknown"), outcome); + using var envelope = DeskWire.Envelope(Assert.Single(huginn.Requests)); + Assert.Equal("answer", envelope.RootElement.GetProperty("operation").GetString()); + using var payload = DeskWire.Payload(envelope); + var answer = payload.RootElement.GetProperty("Answer"); + Assert.Equal(["mind", "subject", "authority", "deciders", "request"], answer.EnumerateObject().Select(p => p.Name)); + Assert.Equal("Steward", answer.GetProperty("authority").GetString()); + // Every key, the nulls too, in the order the client wrote them. + using var sent = JsonDocument.Parse(body); + Assert.Equal(sent.RootElement.EnumerateObject().Select(p => p.Name), answer.GetProperty("request").EnumerateObject().Select(p => p.Name)); + foreach (var property in sent.RootElement.EnumerateObject()) + { + var back = answer.GetProperty("request").GetProperty(property.Name); + Assert.True( + System.Text.Json.Nodes.JsonNode.DeepEquals( + System.Text.Json.Nodes.JsonNode.Parse(property.Value.GetRawText()), + System.Text.Json.Nodes.JsonNode.Parse(back.GetRawText())), + property.Name); + } + } + + // ---- (b) each golden reply ---- + + private static (string Kind, string MessageId) Shape(string fixture) + { + using var envelope = DeskWire.Envelope(FakeHuginnSocket.Fixture(fixture)); + var root = envelope.RootElement; + return root.TryGetProperty("operation", out var operation) + ? (operation.GetString() == "fork" ? "read" : "list", root.GetProperty("messageId").GetString()!) + : ("list", "m-1"); // a CultNet Error message has neither + } + + private static async Task Replayed(string fixture, string? readForkId = null) + { + var (kind, messageId) = Shape(fixture); + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture(fixture))); + var door = DoorWith(messageId); + return kind == "read" + ? await Read(door, huginn.Path, readForkId ?? Fork) + : await List(door, huginn.Path); + } + + [Fact] + public async Task An_empty_list_is_an_empty_list() + { + var listed = Assert.IsType(await Replayed("list-empty.response")); + Assert.Equal((0, 0, 0, "s10190", "eureka"), (listed.List.OpenCount, listed.List.RecordedCount, listed.List.Forks.Length, listed.List.Stamp, listed.List.Mind)); + Assert.Null(listed.List.NextCursor); + } + + [Fact] + public async Task The_counts_reply_carries_the_counts() + { + var listed = Assert.IsType(await Replayed("list-counts.response")); + Assert.Equal((3, 5, 0), (listed.List.OpenCount, listed.List.RecordedCount, listed.List.Forks.Length)); + } + + [Fact] + public async Task An_unknown_fork_is_not_found() + { + Assert.Equal(new DeskReadOutcome.NotFound("fork_unknown"), await Replayed("read-unknown.response")); + } + + [Fact] + public async Task A_list_too_large_names_its_fork_and_order_from_the_refusal_text() + { + Assert.Equal(new DeskListOutcome.ForkTooLarge(Fork, 41), await Replayed("list-too-large.response")); + } + + [Fact] + public async Task A_read_too_large_names_the_routes_fork_not_the_refusal_text() + { + // The refusal text names the fixture's fork; the route asked for another, and the route's id is what returns. + Assert.Equal( + new DeskReadOutcome.ForkTooLarge("route:question:asked"), + await Replayed("read-too-large.response", "route:question:asked")); + } + + [Theory] + [InlineData("list-response-too-large.response")] + [InlineData("refused-unit.response")] + [InlineData("unavailable.response")] + [InlineData("failure.response")] + [InlineData("error.response")] + public async Task A_refusal_a_failure_or_an_error_is_a_door_failure(string fixture) + { + var failure = await Assert.ThrowsAsync(() => Replayed(fixture)); + Assert.DoesNotContain("deadline", failure.Message); + } + + [Fact] + public async Task A_list_refusal_text_that_does_not_parse_exactly_is_a_failure_never_a_guess() + { + foreach (var text in new[] + { + "fork_too_large: fork a:question:b is past the page budget by itself", + "fork_too_large: fork a:question:b at order 4x is past the page budget by itself", + "fork_too_large: fork at order 4 is past the page budget by itself", + "fork_too_large: fork a:question:b at order 04 is past the page budget by itself", + "oops fork_too_large: fork a:question:b at order 4 is past the page budget by itself", + }) + { + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => + $$$"""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_too_large","error":"{{{text}}}"}}""", "rejected")); + await Assert.ThrowsAsync(() => List(new UnixSocketDeskDoor(), huginn.Path)); + } + } + + [Fact] + public async Task A_one_key_refusal_an_unknown_variant_and_an_unknown_code_are_failures() + { + foreach (var (variant, status) in new[] + { + ("""{"Refused":{"Unavailable":{"detail":"x"}}}""", "rejected"), + ("""{"Refused":"MissingIdentity"}""", "rejected"), + ("""{"Mystery":{"x":1}}""", "accepted"), + ("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"nope","error":"x"}}""", "rejected"), + ("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v9","code":"fork_unknown","error":"x"}}""", "rejected"), + ("""{"Fork":{"entry":{},"as_of":1}}""", "accepted"), + ("""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"eureka","stamp":"s1","open_count":0,"recorded_count":0,"forks":[],"next_cursor":null},"as_of":1}}""", "rejected"), + }) + { + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => variant, status)); + await Assert.ThrowsAsync(() => List(new UnixSocketDeskDoor(), huginn.Path)); + } + } + + [Fact] + public async Task A_reply_for_another_message_or_operation_is_a_failure() + { + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture("list-empty.response"))); + await Assert.ThrowsAsync(() => List(DoorWith("m-9"), huginn.Path)); + + using var other = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture("list-empty.response"))); + await Assert.ThrowsAsync(() => Read(DoorWith("m-1"), other.Path)); + } + + // ---- (c) entries pass through, the card untouched ---- + + private const string CardWithRaisedAt = + """{"schemaVersion":"gamecult.fork.card.v0","fork_id":"x:question:a","title":"It's é","raised_at":"2026-11-11T09:00:00Z","options":[{"id":"a","text":"A"}],"order":7,"options_hash":"ab"}"""; + + // Reordered, no raised_at, a key no contract names yet. + private const string CardReordered = + """{"order":8,"future_key":{"z":[3,null,100],"a":true},"options_hash":"cd","schemaVersion":"gamecult.fork.card.v0","fork_id":"x:question:b","title":"B","leans":null}"""; + + private static string EntryJson(string card, string forkId) => + $$$"""{"schemaVersion":"gamecult.fork.entry.v0","card":{{{card}}},"record":{"schemaVersion":"gamecult.fork.record.v0","fork_id":"{{{forkId}}}","options_hash":"ab","counted_answer_id":null,"answerer_count":0,"answers":[]}}"""; + + [Fact] + public async Task Cards_in_a_list_and_a_read_come_through_byte_faithful() + { + var list = $$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"eureka","stamp":"s7","open_count":2,"recorded_count":0,"forks":[{{{EntryJson(CardWithRaisedAt, "x:question:a")}}},{{{EntryJson(CardReordered, "x:question:b")}}}],"next_cursor":"c8"},"as_of":7}}"""; + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((operation, _) => + operation == "forks" ? list : $$$"""{"Fork":{"entry":{{{EntryJson(CardReordered, "x:question:b")}}},"as_of":7}}""")); + var door = new UnixSocketDeskDoor(); + + var listed = Assert.IsType(await List(door, huginn.Path)); + Assert.Equal(["x:question:a", "x:question:b"], listed.List.Forks.Select(f => f.Record.ForkId)); + Assert.Equal(CardWithRaisedAt, JsonSerializer.Serialize(listed.List.Forks[0].Card, DeskJson.Options)); + Assert.Equal(CardReordered, JsonSerializer.Serialize(listed.List.Forks[1].Card, DeskJson.Options)); + Assert.Equal("c8", listed.List.NextCursor); + + var found = Assert.IsType(await Read(door, huginn.Path, "x:question:b")); + Assert.Equal(CardReordered, JsonSerializer.Serialize(found.Entry.Card, DeskJson.Options)); + } + + [Fact] + public async Task An_answered_reply_becomes_the_answer_result_without_a_card() + { + var answered = """{"Answered":{"answer":{"schemaVersion":"gamecult.desk_answer.v0","answer_id":"n1","fork_id":"x:question:a","outcome":"Counted","of":null,"retracts":null,"code":null,"options_hash":"ab","choice":"a","note":null,"answered_at":"2026-11-11T09:00:00Z","received_at":"2026-11-11T09:00:01Z","claim_in_future":false,"answerer":"7","authority":"Member","request_id":"r"},"counted_answer_id":"n1","as_of":9}}"""; + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => answered)); + + var outcome = await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None); + + var result = Assert.IsType(outcome).Result; + Assert.Equal((DeskSchemas.AnswerResult, "n1", "Counted", "n1", null), (result.SchemaVersion, result.Answer.AnswerId, result.Answer.Outcome, result.CountedAnswerId, result.Card)); + } + + // ---- (c2) both reader views through the door and the edge ---- + + // What each piece of the fixtures directory is (its two READMEs): + // - record-whole is HUGINN's own encoder output at 232764bf: gamecult.fork.record.v0 with all four answer variants + // by three accounts, notes and ids whole. It has 6 keys, answerer_count included; record-withheld is the same record for a non-decider. + // - cultlib-d3025d3c/*.msgpack are CULTLIB's bytes (fourteen vectors pinned to d3025d3c, each a vector's + // messagePackHex decoded), vendored unedited. They are NOT Huginn output; Huginn's types do not carry those + // shapes yet. Bifrost reads them as the contract (vectors file d3025d3c, append-only on 3a51bbab) spells them. + private static JsonObject Fragment(string name) => + JsonNode.Parse(MessagePackSerializer.ConvertToJson(File.ReadAllBytes( + Path.Combine(DeskFeedFixturePinTests.Directory, name + ".payload.msgpack"))))!.AsObject(); + + private static byte[] CultLib(string name) => + File.ReadAllBytes(Path.Combine(DeskFeedFixturePinTests.Directory, "cultlib-d3025d3c", name + ".msgpack")); + + private static JsonObject CultLibJson(string name) => JsonNode.Parse(MessagePackSerializer.ConvertToJson(CultLib(name)))!.AsObject(); + + // file, vector section, vector name (the vector whose messagePackHex the file holds) + public static TheoryData CultLibFiles() => new() + { + { "card-operator-fork", "card/valid", "operator_fork" }, + { "card-raised-at-nil", "card/valid", "raised_at_nil" }, + { "card-withheld-deciders", "card/valid", "withheld_deciders" }, + { "answer-counted", "deskAnswer/valid", "counted" }, + { "answer-revision", "deskAnswer/valid", "revision" }, + { "answer-retraction", "deskAnswer/valid", "retraction" }, + { "answer-withheld-answerer", "deskAnswer/valid", "withheld_answerer" }, + { "answer-refused-stale", "deskAnswer/valid", "refused_stale" }, + { "entry-open-counted", "transport/entry/valid", "open_counted" }, + { "entry-withheld-open-counted", "transport/entry/valid", "withheld_open_counted" }, + { "entry-closed-with-ruling-and-a-refusal", "transport/entry/valid", "closed_with_ruling_and_a_refusal" }, + { "entry-withheld-closed-with-ruling-and-a-refusal", "transport/entry/valid", "withheld_closed_with_ruling_and_a_refusal" }, + }; + + [Theory] + [MemberData(nameof(CultLibFiles))] + public void Each_CultLib_file_is_its_vectors_wire_bytes_in_the_vendored_vectors_file(string file, string section, string name) + { + var vector = DeskVectors.Rows(section.Split('/')).Single(r => (string)r["name"]! == name); + + Assert.Equal((string)vector["messagePackHex"]!, Convert.ToHexString(CultLib(file)), ignoreCase: true); + Assert.True(DeskVectors.SameShape(vector["message"], CultLibJson(file)), file); + } + + [Fact] + public void The_CultLib_files_are_the_shapes_their_README_names() + { + // Card: 18 keys (DE 00 12), raised_at a string / nil with the key kept, deciders whole or withheld with the count. + Assert.Equal([0xde, 0x00, 0x12], CultLib("card-operator-fork")[..3]); + Assert.Equal(1, (int)CultLibJson("card-operator-fork")["decider_count"]!); + Assert.NotEmpty(CultLibJson("card-operator-fork")["deciders"]!.AsArray()); + Assert.Equal(JsonValueKind.String, CultLibJson("card-operator-fork")["raised_at"]!.GetValueKind()); + Assert.True(CultLibJson("card-raised-at-nil").ContainsKey("raised_at")); + Assert.Null(CultLibJson("card-raised-at-nil")["raised_at"]); + Assert.Empty(CultLibJson("card-withheld-deciders")["deciders"]!.AsArray()); + Assert.Equal(2, (int)CultLibJson("card-withheld-deciders")["decider_count"]!); + + // Answers: 16 keys (DE 00 10), answerer a string except the withheld one. + foreach (var answer in new[] { "answer-counted", "answer-revision", "answer-retraction", "answer-refused-stale", "answer-withheld-answerer" }) + { + Assert.Equal([0xde, 0x00, 0x10], CultLib(answer)[..3]); + Assert.Equal(answer == "answer-withheld-answerer", CultLibJson(answer)["answerer"] is null); + } + + // Entries: header 83, record header 86 with answerer_count the fifth key; the decider view names every answerer, + // its non-decider twin none, and the count is the same in both. + foreach (var (decider, withheld) in new[] { ("entry-open-counted", "entry-withheld-open-counted"), ("entry-closed-with-ruling-and-a-refusal", "entry-withheld-closed-with-ruling-and-a-refusal") }) + { + Assert.Equal(0x83, CultLib(decider)[0]); + var (whole, hidden) = (CultLibJson(decider), CultLibJson(withheld)); + Assert.Equal("answerer_count", whole["record"]!.AsObject().ElementAt(4).Key); + Assert.Equal(6, whole["record"]!.AsObject().Count); + Assert.NotEmpty(whole["card"]!["deciders"]!.AsArray()); + Assert.All(whole["record"]!["answers"]!.AsArray(), a => Assert.NotNull(a!["answerer"])); + Assert.Empty(hidden["card"]!["deciders"]!.AsArray()); + Assert.All(hidden["record"]!["answers"]!.AsArray(), a => Assert.Null(a!["answerer"])); + Assert.Equal((int)whole["record"]!["answerer_count"]!, (int)hidden["record"]!["answerer_count"]!); + Assert.Equal((int)whole["card"]!["decider_count"]!, (int)hidden["card"]!["decider_count"]!); + } + + Assert.Equal(1, (int)CultLibJson("entry-open-counted")["record"]!["answerer_count"]!); + Assert.Equal(2, (int)CultLibJson("entry-closed-with-ruling-and-a-refusal")["record"]!["answerer_count"]!); + } + + [Fact] + public void Every_account_id_in_record_whole_is_a_lowercase_D_format_uuid_and_the_record_is_what_the_README_says() + { + var whole = Fragment("record-whole"); + // Huginn's own record: 6 keys, answerer_count 3 (the README's table). + Assert.Equal(["schemaVersion", "fork_id", "options_hash", "counted_answer_id", "answerer_count", "answers"], whole.Select(p => p.Key)); + Assert.Equal(3, (int)whole["answerer_count"]!); + Assert.Equal(["Counted", "Revision", "Refused", "Retraction"], whole["answers"]!.AsArray().Select(a => (string)a!["outcome"]!).Distinct()); + + var grammar = new System.Text.RegularExpressions.Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); + var accounts = whole["answers"]!.AsArray().Select(a => (string)a!["answerer"]!).ToList(); + Assert.All(accounts, id => Assert.Matches(grammar, id)); + Assert.Equal(3, accounts.Distinct().Count()); + } + + [Fact] + public void Record_withheld_is_record_whole_with_every_answerer_nil_and_the_count_and_notes_kept() + { + var whole = Fragment("record-whole"); + var withheld = Fragment("record-withheld"); + + Assert.Equal(whole.Select(p => p.Key), withheld.Select(p => p.Key)); + Assert.Equal((int)whole["answerer_count"]!, (int)withheld["answerer_count"]!); + Assert.All(withheld["answers"]!.AsArray(), a => Assert.Null(a!["answerer"])); + Assert.Equal( + whole["answers"]!.AsArray().Select(a => (string?)a!["note"]), + withheld["answers"]!.AsArray().Select(a => (string?)a!["note"])); + } + + // Huginn's own record fragments, in a CultLib card's entry, through the real door: nothing added, dropped or invented. + [Theory] + [InlineData("record-whole", false)] + [InlineData("record-withheld", true)] + public async Task Huginns_own_record_in_either_view_comes_through_the_door_unchanged(string fragment, bool withheld) + { + var card = CultLibJson("entry-open-counted")["card"]!; + var entry = JsonNode.Parse($$$"""{"schemaVersion":"gamecult.fork.entry.v0","card":{{{card.ToJsonString(DeskJson.Options)}}},"record":{{{Fragment(fragment).ToJsonString(DeskJson.Options)}}}}""")!; + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(entry.ToJsonString(DeskJson.Options)))); + + var listed = Assert.IsType(await List(new UnixSocketDeskDoor(), huginn.Path)); + + var got = Assert.Single(listed.List.Forks); + Assert.Equal(DeskServe.Text(entry), DeskServe.Text(got)); + Assert.Equal(3, got.Record.AnswererCount); + Assert.All(got.Record.Answers, a => Assert.Equal(withheld, a.Answerer is null)); + } + + // A decider's entry and its non-decider twin, each served through the real door as a list page: the card (18 keys, + // raised_at, decider_count) and the record (answerer_count) leave exactly as CultLib wrote them. + [Theory] + [InlineData("entry-open-counted")] + [InlineData("entry-withheld-open-counted")] + [InlineData("entry-closed-with-ruling-and-a-refusal")] + [InlineData("entry-withheld-closed-with-ruling-and-a-refusal")] + public async Task A_CultLib_entry_in_either_view_comes_through_the_door_unchanged(string file) + { + var entry = CultLibJson(file); + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(entry.ToJsonString(DeskJson.Options)))); + + var listed = Assert.IsType(await List(new UnixSocketDeskDoor(), huginn.Path)); + + var got = Assert.Single(listed.List.Forks); + Assert.Equal(DeskServe.Text(entry), DeskServe.Text(got)); + var withheld = file.Contains("withheld", StringComparison.Ordinal); + Assert.Equal(withheld, got.Card.GetProperty("deciders").GetArrayLength() == 0); + Assert.All(got.Record.Answers, a => Assert.Equal(withheld, a.Answerer is null)); + } + + private static string ListOf(string entry, string mind = "eureka") => + $$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"{{{mind}}}","stamp":"s7","open_count":1,"recorded_count":0,"forks":[{{{entry}}}],"next_cursor":null},"as_of":7}}"""; + + [Fact] + public async Task A_decider_entry_keeps_its_ids_and_notes_and_a_non_decider_entry_has_nil_answerers_the_count_and_no_notes_through_the_edge() + { + var withheld = CultLibJson("entry-withheld-closed-with-ruling-and-a-refusal"); + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(withheld.ToJsonString(DeskJson.Options), "gamecult-chat"))); + using var factory = new DeskWebApplicationFactory(ConfigurationWith(huginn.Path, FakeHuginnSocket.Absent()), fakeDoor: false); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true); + var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: factory.Token(DeskWebApplicationFactory.Member, "bifrost-desk", "desk.read")); + + // Huginn withheld the ids; Bifrost invents none, and nils the notes (the one thing it strips). + Assert.Equal(200, reply.Status); + var shown = JsonNode.Parse(reply.Text)!["forks"]![0]!; + Assert.Equal(2, (int)shown["record"]!["answerer_count"]!); + Assert.All(shown["record"]!["answers"]!.AsArray(), a => + { + Assert.True(a!.AsObject().ContainsKey("answerer")); + Assert.Null(a["answerer"]); + Assert.Null(a["note"]); + }); + Assert.Empty(shown["card"]!["deciders"]!.AsArray()); + Assert.Equal((int)withheld["card"]!["decider_count"]!, (int)shown["card"]!["decider_count"]!); + + // The decider's view keeps every id and every note. + var decider = CultLibJson("entry-closed-with-ruling-and-a-refusal"); + using var huginn2 = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => ListOf(decider.ToJsonString(DeskJson.Options), "gamecult-chat"))); + using var factory2 = new DeskWebApplicationFactory(ConfigurationWith(huginn2.Path, FakeHuginnSocket.Absent()), fakeDoor: false); + await factory2.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var own = await factory2.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: factory2.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read")); + + Assert.Equal(200, own.Status); + Assert.Equal(DeskServe.Text(decider), DeskServe.Text(JsonNode.Parse(own.Text)!["forks"]![0]!)); + } + + // ---- (d) one dead Mind fails only itself, through the HTTP edge ---- + + private static Dictionary ConfigurationWith(string chatSocket, string nibuSocket) + { + var configuration = DeskWebApplicationFactory.DefaultConfiguration(); + foreach (var key in configuration.Keys.Where(k => k.EndsWith(":Id", StringComparison.Ordinal)).ToList()) + { + var prefix = key[..^2]; + configuration[prefix + "Socket"] = configuration[key] == "nibu" ? nibuSocket : chatSocket; + } + + return configuration; + } + + [Fact] + public async Task A_dead_Minds_socket_is_503_for_that_Mind_only_and_the_live_one_answers() + { + var list = $$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"gamecult-chat","stamp":"s7","open_count":1,"recorded_count":0,"forks":[{{{EntryJson(CardWithRaisedAt, "x:question:a")}}}],"next_cursor":null},"as_of":7}}"""; + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => list)); + using var factory = new DeskWebApplicationFactory(ConfigurationWith(huginn.Path, FakeHuginnSocket.Absent()), fakeDoor: false); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read"); + + var live = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: token); + var dead = await factory.SendAsync("GET", "/api/desk/nibu/forks", token: token); + var liveAgain = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: token); + + Assert.Equal(200, live.Status); + Assert.Contains("\"card\":" + CardWithRaisedAt + ",", live.Text); + Assert.Equal(503, dead.Status); + Assert.Equal("30", dead.Headers.RetryAfter.ToString()); + Assert.Contains("\"code\":\"unavailable\"", dead.Text); + Assert.Equal(200, liveAgain.Status); + Assert.Equal(2, huginn.Requests.Count); + } + + [Fact] + public async Task A_silent_door_is_503_with_retry_after_not_a_hang() + { + using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token)); + using var factory = new DeskWebApplicationFactory(ConfigurationWith(FakeHuginnSocket.Absent(), huginn.Path), fakeDoor: false); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + var clock = Stopwatch.StartNew(); + var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks", token: factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read")); + + Assert.Equal(503, reply.Status); + Assert.True(reply.Headers.ContainsKey("Retry-After")); + Assert.True(clock.Elapsed < TimeSpan.FromSeconds(3), $"took {clock.Elapsed}"); + } + + // ---- (e) deadline, bounds, partial frames ---- + + [Fact] + public async Task A_server_that_accepts_and_never_replies_fails_at_the_deadline() + { + using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token)); + + var clock = Stopwatch.StartNew(); + var failure = await Assert.ThrowsAsync(() => List(new UnixSocketDeskDoor(), huginn.Path)); + + Assert.Contains("deadline", failure.Message); + Assert.InRange(clock.Elapsed, TimeSpan.FromMilliseconds(900), TimeSpan.FromSeconds(2.5)); + } + + [Fact] + public async Task A_caller_that_gives_up_is_cancelled_not_a_door_failure() + { + using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token)); + using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + await Assert.ThrowsAnyAsync(() => + new UnixSocketDeskDoor().ListAsync(huginn.Path, Context, new DeskListRequest("eureka", null, null, 50), cancel.Token)); + } + + [Fact] + public async Task A_length_prefix_over_the_bound_is_refused_without_reading_the_body() + { + // The server sends only the prefix, then stalls: a client that waited for the body would run out the deadline. + using var huginn = new FakeHuginnSocket(async (socket, _, token) => + { + await socket.SendAsync(new byte[] { 0, 1, 0, 1 }, SocketFlags.None, token); // 65537 + await Task.Delay(Timeout.Infinite, token); + }); + + var clock = Stopwatch.StartNew(); + var failure = await Assert.ThrowsAsync(() => List(new UnixSocketDeskDoor(), huginn.Path)); + + Assert.Contains("frame bound", failure.Message); + Assert.True(clock.Elapsed < TimeSpan.FromMilliseconds(700), $"took {clock.Elapsed}"); + } + + [Fact] + public async Task A_frame_exactly_at_the_bound_is_read_whole() + { + // A prefix of 65536 is within the bound; the body that follows is not a message, so it fails on content, not size. + using var huginn = new FakeHuginnSocket(async (socket, _, token) => + { + var frame = new byte[4 + 65536]; + frame[1] = 1; + await socket.SendAsync(frame, SocketFlags.None, token); + }); + + var failure = await Assert.ThrowsAsync(() => List(new UnixSocketDeskDoor(), huginn.Path)); + Assert.DoesNotContain("frame bound", failure.Message); + } + + [Fact] + public async Task A_request_over_the_bound_is_never_sent() + { + var deciders = Enumerable.Range(0, 64).Select(i => i.ToString("D4") + new string('d', 1100)).ToArray(); + var context = new DeskDoorContext("eureka", "Member", "7", deciders); + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected")); + + var failure = await Assert.ThrowsAsync(() => + new UnixSocketDeskDoor().ListAsync(huginn.Path, context, new DeskListRequest("eureka", null, null, 50), CancellationToken.None)); + + Assert.Contains("frame bound", failure.Message); + Assert.Empty(huginn.Requests); + } + + [Fact] + public async Task A_truncated_frame_or_an_early_close_is_a_failure() + { + var golden = FakeHuginnSocket.Fixture("list-empty.response"); + foreach (var cut in new[] { 0, 2, 4, 10, golden.Length - 1 }) + { + using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(golden[..cut])); + var failure = await Assert.ThrowsAsync(() => List(DoorWith("m-1"), huginn.Path)); + Assert.Contains("closed", failure.Message); + } + } + + [Fact] + public async Task A_frame_that_arrives_in_pieces_is_read_whole() + { + var golden = FakeHuginnSocket.Fixture("list-empty.response"); + using var huginn = new FakeHuginnSocket(async (socket, _, token) => + { + for (var at = 0; at < golden.Length; at += 7) + { + await socket.SendAsync(golden.AsMemory(at, Math.Min(7, golden.Length - at)), SocketFlags.None, token); + await Task.Delay(2, token); + } + }); + + Assert.IsType(await List(DoorWith("m-1"), huginn.Path)); + } + + [Fact] + public async Task Absent_and_refusing_sockets_are_failures() + { + await Assert.ThrowsAsync(() => List(new UnixSocketDeskDoor(), FakeHuginnSocket.Absent())); + + // A socket file with nobody listening: ECONNREFUSED. + var stale = FakeHuginnSocket.Absent(); + using (var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified)) + { + socket.Bind(new UnixDomainSocketEndPoint(stale)); + } + + await Assert.ThrowsAsync(() => List(new UnixSocketDeskDoor(), stale)); + } +} diff --git a/tests/Bifrost.Web.Tests/UnixSocketDeskDoorWrapperTests.cs b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorWrapperTests.cs new file mode 100644 index 0000000..b82590e --- /dev/null +++ b/tests/Bifrost.Web.Tests/UnixSocketDeskDoorWrapperTests.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Nodes; +using MessagePack; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +// Huginn's DeskResponse variants are maps with a fixed key set (desk_wire.rs): Answered { answer, counted_answer_id, +// as_of: u64 }, Forks { list, as_of: u64 }, Fork { entry, as_of: u64 }. The door closes them like every other contract map: +// every key present, no other key, as_of an unsigned integer. (Huginn's own golden replies, list-empty and list-counts, +// carry exactly list and as_of.) +public sealed class UnixSocketDeskDoorWrapperTests +{ + private static string Entry => DeskServe.Entry("entry-open-counted").ToJsonString(DeskJson.Options); + + private static string Page => DeskServe.PageOf(DeskServe.Entry("entry-open-counted")).ToJsonString(DeskJson.Options); + + private static string Answer => DeskServe.Entry("entry-open-counted")["record"]!["answers"]![0]!.ToJsonString(DeskJson.Options); + + // variant, call, and a function from the wrapper's tail to the reply JSON. + public static TheoryData Variants() => ["Forks", "Fork", "Answered"]; + + private static string Reply(string variant, string tail) => variant switch + { + "Forks" => "{\"Forks\":{\"list\":" + Page + tail + "}}", + "Fork" => "{\"Fork\":{\"entry\":" + Entry + tail + "}}", + _ => "{\"Answered\":{\"answer\":" + Answer + ",\"counted_answer_id\":\"n1\"" + tail + "}}", + }; + + private static async Task Serve(string variant, byte[] payload) => variant switch + { + "Forks" => await DeskServe.ListPayloadAsync(payload), + "Fork" => await DeskServe.ReadPayloadAsync(payload), + _ => await DeskServe.AnswerPayloadAsync(payload), + }; + + private static byte[] Pack(string json) => MessagePackSerializer.ConvertFromJson(json); + + [Theory] + [MemberData(nameof(Variants))] + public async Task A_wrapper_with_as_of_alone_beside_its_value_decodes_and_as_of_may_be_any_u64(string variant) + { + await Serve(variant, Pack(Reply(variant, ",\"as_of\":0"))); + await Serve(variant, Pack(Reply(variant, ",\"as_of\":4294967296"))); + await Serve(variant, Pack(Reply(variant, ",\"as_of\":18446744073709551615"))); + } + + [Theory] + [MemberData(nameof(Variants))] + public async Task A_wrapper_without_as_of_or_with_one_that_is_not_an_unsigned_integer_is_a_door_failure(string variant) + { + foreach (var tail in new[] + { + "", ",\"as_of\":null", ",\"as_of\":-1", ",\"as_of\":\"1\"", ",\"as_of\":true", ",\"as_of\":1.5", ",\"as_of\":[1]", ",\"as_of\":{}", + }) + { + await Assert.ThrowsAsync(() => Serve(variant, Pack(Reply(variant, tail)))); + } + } + + [Theory] + [MemberData(nameof(Variants))] + public async Task A_wrapper_with_a_key_beyond_the_variants_declared_ones_is_a_door_failure(string variant) + { + foreach (var tail in new[] { ",\"as_of\":1,\"viewer\":\"x\"", ",\"viewer\":1,\"as_of\":1", ",\"as_of\":1,\"as_of_extra\":null" }) + { + await Assert.ThrowsAsync(() => Serve(variant, Pack(Reply(variant, tail)))); + } + } +} diff --git a/tests/Bifrost.Web.Tests/packages.lock.json b/tests/Bifrost.Web.Tests/packages.lock.json index 14c75c5..b81762f 100644 --- a/tests/Bifrost.Web.Tests/packages.lock.json +++ b/tests/Bifrost.Web.Tests/packages.lock.json @@ -60,6 +60,26 @@ "resolved": "2.4.0", "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" }, + "MessagePack": { + "type": "Transitive", + "resolved": "3.1.12", + "contentHash": "m1QEsd59gUj82ji5tEQbZiyjV6y0F7cYfGVhil6pAlNZpyzR7ze9TucMu4lPpi8cZAV8aNl3D9/T9J1HbFz15Q==", + "dependencies": { + "MessagePack.Annotations": "3.1.12", + "MessagePackAnalyzer": "3.1.12", + "Microsoft.NET.StringTools": "17.11.4" + } + }, + "MessagePack.Annotations": { + "type": "Transitive", + "resolved": "3.1.12", + "contentHash": "LdI67sY7F3KK+JsrG02+DP1XFkowaZSoyfL2M88hjm1MOOsjf8amBLAJolV+zxoPUZf3uGbKzq1cMQD8PlpSpA==" + }, + "MessagePackAnalyzer": { + "type": "Transitive", + "resolved": "3.1.12", + "contentHash": "iNSNatPh6bafRUbkQKlClExjpPpd8hiZgfZsodGf7bheIlkizGbMzsJ2mPui8jOHRe4Ky9eg5ou/fFzI7CT3fg==" + }, "Microsoft.AspNetCore.TestHost": { "type": "Transitive", "resolved": "8.0.0", @@ -461,6 +481,11 @@ "Microsoft.IdentityModel.Logging": "8.19.2" } }, + "Microsoft.NET.StringTools": { + "type": "Transitive", + "resolved": "17.11.4", + "contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA==" + }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", "resolved": "17.14.1", @@ -671,6 +696,7 @@ "type": "Project", "dependencies": { "BouncyCastle.Cryptography": "[2.4.0, )", + "MessagePack": "[3.1.12, )", "Microsoft.Extensions.Caching.Memory": "[8.0.1, )", "Npgsql": "[8.0.3, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.0, )", From afafff18bdde6484538a29c85b31a35d49f86cc5 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 12:21:31 +0000 Subject: [PATCH 028/102] Mouth: basis "follow" for engagement peers (CHAT_FOLLOW, default off) (#74) --- docs/bridge.md | 15 +- tests/persona-matrix-mouth-follow.test.mjs | 378 +++++++++++++++++++++ tools/persona-chat-documents.mjs | 4 +- tools/persona-matrix-mouth.mjs | 123 ++++++- 4 files changed, 512 insertions(+), 8 deletions(-) create mode 100644 tests/persona-matrix-mouth-follow.test.mjs diff --git a/docs/bridge.md b/docs/bridge.md index d66f9a5..5872f22 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -206,7 +206,7 @@ A client asks for the facts by the kind it polls with: `gamecult.persona.chat_pe | Field | Type | Meaning | |---|---|---| -| 12 `basis` | `"direct"`, `"tag"`, `"reply"`, `"room"` or nil | Why the message is for this Persona, by what Matrix says, first that holds: `direct` the room is one of her DMs; `tag` `m.mentions.user_ids` lists her id, or the client sent no `m.mentions` at all and an `` in the HTML body is exactly her `matrix.to` link; `reply` it replies to, or is in a thread under, an event her account wrote (her Self run speaks as her account, so its words count; any member's message in a thread under one of her events is a `reply` too, and the target is read once per room and event and kept for a minute, hit or failure); `room` an `@room` mention. A `notice` or an `edit` costs no reply lookup. Known limit: lookups run one at a time inside the `/sync` round (10 s each, at most two per reply), so a slow homeserver slows the round; they are not parallelised or capped per round. | +| 12 `basis` | `"direct"`, `"tag"`, `"reply"`, `"room"`, `"follow"` (only with `CHAT_FOLLOW=1`, see Follow) or nil | Why the message is for this Persona, by what Matrix says, first that holds: `direct` the room is one of her DMs; `tag` `m.mentions.user_ids` lists her id, or the client sent no `m.mentions` at all and an `` in the HTML body is exactly her `matrix.to` link; `reply` it replies to, or is in a thread under, an event her account wrote (her Self run speaks as her account, so its words count; any member's message in a thread under one of her events is a `reply` too, and the target is read once per room and event and kept for a minute, hit or failure); `room` an `@room` mention. A `notice` or an `edit` costs no reply lookup. Known limit: lookups run one at a time inside the `/sync` round (10 s each, at most two per reply), so a slow homeserver slows the round; they are not parallelised or capped per round. | | 13 `form` | `"message"`, `"notice"` or `"edit"` | `m.notice` is an automated line; `m.replace` is an edit of an earlier message. Only a `message` can address anyone. | What the mouth cannot know, and so what a nil `basis` means: **unproven, not "not for her"**. Her handle and public name in the text (any casing, with or without `@`, `'s`), the names her stewardships list, a project's repo name, and whether the sender is the operator or a Mind are all matched by Huginn, not here. A reply whose target the homeserver will not give is nil and logged. The mouth also cannot see whether a message was meant for her without naming her. Every delivery is still delivered, claimed tags included: nothing is dropped in the mouth, so the Huginn rule cannot lose a message the mouth had no way to judge. @@ -253,6 +253,19 @@ A line a delivery already shows and a room she cannot speak in change nothing he Golden bytes: `tests/fixtures/persona-chat-v5/` (`chat_pending.v3.tail` carries two lines and no gap, `chat_pending.v3.tail-gaps` one line and two gap rooms; the earlier v3 samples gain an empty fourth element); Huginn pins the same bytes. +### Follow (`basis` `"follow"`, `CHAT_FOLLOW=1`) + +A message from someone she is in conversation with is for her even when it names no one. **It is off unless the mouth runs with `CHAT_FOLLOW=1`** (exactly `1`; default off), because Huginn's parser refuses an unknown basis until its `Basis::Follow` (and the one `is_for` line) lands. With the flag off the mouth emits no `follow`, makes no extra read, and keeps no follow state. + +With it on: + +- **Anchor.** Her own post in a public room anchors that room for W = 600 s from the post. The mouth anchors at send time (the send's own time, in `send`, in the background: the send receipt never waits on the room-state read) and also from the /sync echo of her own events, so a post from another client of her account anchors too; one event anchors once. Only her posting again re-anchors the room and re-snapshots who is counted; a follow never extends the window. +- **Counted senders** (snapshotted at the post): those who spoke in that room in the 10 minutes before the post (a per-room ring of recent senders with event times, 200 entries), plus anyone the post tagged (`m.mentions.user_ids`) or replied to (the author of the quoted `m.in_reply_to`, not a thread fallback pointer) when the event carries it. Someone who first speaks after her post is not counted. A member who spoke in the 10 minutes before her post is counted, bounded by 3 follows per hour per (peer, room). Nothing reads message text; no ids are scraped from bodies. She herself, and bridged senders (the `platformEvent` identifier rule), never count. +- **Follow.** A `message` (not an edit, not a notice) from a counted sender gets `follow` when it would otherwise have no basis or `room`; `tag`, `reply` and `direct` are never replaced. Its timestamp must lie inside the window (a homeserver clock up to 60 s behind still follows) and this process's clock must not be past the window's end by more than 60 s, so an old or forged `origin_server_ts` reuses nothing. At most K = 3 per (Mind, peer, room) in the hour before the message; past it the message keeps the basis it had. +- **"Public" means non-DM, non-encrypted, non-bridged.** The room is not one of her DMs and its state has no `m.room.encryption`, `m.bridge` or `uk.half-shot.bridge` (read once per room, kept 5 minutes; unreadable or malformed state is not public). An invite-only plaintext room counts as public. Nothing is anchored in, and no follow is delivered in, any other room. + +State is in memory only (per-room anchor, sender ring, follow times), purged on every sync pass; nothing is stored, and a restart only shortens windows. + ### Reply link (`chat_pending.v4`) A reply to anyone but the Persona arrives bare in v3: `basis` is `reply` only when the quoted author is her account. v4 adds what the message quotes. **It is off unless the mouth runs with `CHAT_PENDING_V4=1`** (exactly `1`; default off), because Huginn pins the tuple bytes and must read v4 first. With the flag off, `gamecult.persona.chat_pending_request.v3` is not a served kind: it is refused as any unknown kind is (`chat_refusal` `UnknownKind`, logged), byte for byte, and v1/v2/v3 requests are answered as before. With it on, that request kind (the v1 tuple `[persona, after_seq]` again; the kind asked by names the answer) is answered with `gamecult.persona.chat_pending.v4`. diff --git a/tests/persona-matrix-mouth-follow.test.mjs b/tests/persona-matrix-mouth-follow.test.mjs new file mode 100644 index 0000000..c5bd5f2 --- /dev/null +++ b/tests/persona-matrix-mouth-follow.test.mjs @@ -0,0 +1,378 @@ +// Follow (docs/bridge.md, "Follow"): her own post in a public room anchors it for W; those who spoke there in the W before it (and whoever it tagged or +// replied to) are counted, and their later messages are basis "follow", at most K an hour per (peer, room), and only with the mouth's follow flag. The homeserver is an in-process fetch port: +// a scripted /sync (one reply per round), the room state and the events a reply target is read from. +import test from "node:test"; +import assert from "node:assert/strict"; +import { createMouth, followFromEnv } from "../tools/persona-matrix-mouth.mjs"; +import * as docs from "../tools/persona-chat-documents.mjs"; + +const HS = "gamecult.test"; +const ALIAS = `#swarm:${HS}`; +const ROOM = "!swarm:gamecult.test"; +const OTHER_ALIAS = `#side:${HS}`; +const OTHER_ROOM = "!side:gamecult.test"; +const ME = `@eureka:${HS}`; +const KIKO = `@kiko:${HS}`; +const STRANGER = `@stranger:${HS}`; +const LENA = `@lena:${HS}`; +const PLATFORM = ["disc", "ord"].join(""); +const SEC = 1000, W = 600 * SEC, T0 = 1_000_000_000_000; +const msg = (id, sender, ts, content = {}) => ({ type: "m.room.message", event_id: id, sender, origin_server_ts: ts, content: { msgtype: "m.text", body: `line ${id}`, ...content } }); +const tagKiko = { "m.mentions": { user_ids: [KIKO] } }; + +// rounds: sync replies after the baseline, each { [roomId]: events }. state: room id -> state events. +function world({ rounds, state = {}, stateStatus = {}, hangState = false, stateDelay = 0, stateFailures = 0, baselineEvents = null, onSync = null, follow = true, rooms = [ALIAS, OTHER_ALIAS], direct } = {}) { + const seen = []; + const known = new Map(); + let round = 0; + const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + const hsFetch = async (url, init = {}) => { + const u = new URL(url); + const p = decodeURIComponent(u.pathname).replace(/^\/_matrix\/client\/v[13]/, ""); + seen.push(`${init.method ?? "GET"} ${p}`); + if (p === "/account/whoami") return reply(200, { user_id: ME }); + if (p === "/joined_rooms") return reply(200, { joined_rooms: [ROOM, OTHER_ROOM] }); + if (p.includes("/account_data/m.direct")) return direct ? reply(200, direct) : reply(404, { errcode: "M_NOT_FOUND" }); + if (p.startsWith("/directory/room/")) return reply(200, { room_id: { [ALIAS]: ROOM, [OTHER_ALIAS]: OTHER_ROOM }[p.slice(16)] }); + if (p.startsWith("/presence/") || p.includes("/receipt/")) return reply(200, {}); + const st = p.match(/^\/rooms\/([^/]+)\/state$/); + if (st && stateDelay > 0) await new Promise(r => setTimeout(r, stateDelay)); + if (st && hangState) return new Promise(() => {}); + if (st && stateFailures > 0) { stateFailures--; return reply(500, { errcode: "M_UNKNOWN" }); } + if (st) return stateStatus[st[1]] ? reply(stateStatus[st[1]], { errcode: "M_UNKNOWN" }) : reply(200, state[st[1]] ?? []); + if (init.method === "PUT" && p.includes("/send/m.room.message/")) return reply(200, { event_id: "$sent1" }); + const ev = p.match(/^\/rooms\/([^/]+)\/event\/(.+)$/); + if (ev) return known.has(ev[2]) ? reply(200, known.get(ev[2])) : reply(404, { errcode: "M_NOT_FOUND" }); + if (p === "/sync") { + if (u.searchParams.get("since") === null) return reply(200, { next_batch: "b0", ...(baselineEvents ? { rooms: { join: Object.fromEntries(Object.entries(baselineEvents).map(([r, list]) => [r, { timeline: { events: list } }])) } } : {}) }); + onSync?.(setClock); + const events = rounds[round++] ?? {}; + for (const list of Object.values(events)) for (const e of list) known.set(e.event_id, e); + return reply(200, { next_batch: `b${round}`, rooms: { join: Object.fromEntries(Object.entries(events).map(([r, list]) => [r, { timeline: { events: list } }])) } }); + } + if (p.includes("/messages")) return reply(200, { chunk: [] }); + return reply(404, { errcode: "M_NOT_FOUND" }); + }; + let clock = T0; + const setClock = t => { clock = t; }; + const mouth = createMouth({ + persona: "eureka", homeserver: "http://hs.test", serverName: HS, rooms, token: "tok", fetch: hsFetch, follow, + state: { load: async () => null, save: async () => {} }, brake: async () => true, log: () => {}, syncTimeoutMs: 1, now: () => clock, + }); + return { mouth, seen, clock: () => clock, at: t => { clock = t; }, basisOfRound: async () => (await mouth.syncOnce()).map(d => [d.eventId, d.basis]) }; +} +const run = async (w, n) => { await w.mouth.syncOnce(); const out = []; for (let i = 0; i < n; i++) out.push(await w.basisOfRound()); return out; }; + +const go = async (w, t) => { w.at(t); return w.basisOfRound(); }; // the process clock moves with the round +const mine = (id, ts, content = {}) => msg(id, ME, ts, content); +const MIN = 60 * SEC; + +test("follow: a counted sender is one who spoke in the 10 minutes before her post; 9 minutes counts, 11 does not; one who first speaks after does not", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$old", KIKO, T0 - 11 * MIN), msg("$edge", `@edge:${HS}`, T0 - 10.5 * MIN), msg("$recent", LENA, T0 - 9 * MIN)] }, + { [ROOM]: [mine("$mine", T0)] }, + { [ROOM]: [msg("$k", KIKO, T0 + MIN), msg("$l", LENA, T0 + 2 * MIN), msg("$s", STRANGER, T0 + 3 * MIN), msg("$e", `@edge:${HS}`, T0 + 3 * MIN)] }, + ] }); + const out = await run(w, 3); + assert.deepEqual(out[2].sort(), [["$e", null], ["$k", null], ["$l", "follow"], ["$s", null]]); +}); + +test("follow: a post's tag and reply target are counted though they never spoke; she herself never is", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$theirs", LENA, T0 - 5 * MIN)] }, + { [ROOM]: [mine("$mine", T0, { "m.mentions": { user_ids: [KIKO, ME] }, "m.relates_to": { "m.in_reply_to": { event_id: "$theirs" } } })] }, + { [ROOM]: [msg("$k", KIKO, T0 + MIN), msg("$s", STRANGER, T0 + MIN)] }, + ] }); + assert.deepEqual((await run(w, 3))[2], [["$k", "follow"], ["$s", null]]); +}); + + +test("follow: the chain dies when she stops speaking; a follow does not extend W; speaking again re-anchors and re-snapshots", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, + { [ROOM]: [mine("$mine", T0)] }, + { [ROOM]: [msg("$a", KIKO, T0 + 9 * MIN)] }, // inside W: follow + { [ROOM]: [msg("$b", KIKO, T0 + 10 * MIN + SEC)] }, // past W from her post though a follow came at 9 min + { [ROOM]: [mine("$again", T0 + 11 * MIN)] }, + { [ROOM]: [msg("$c", KIKO, T0 + 20 * MIN), msg("$d", LENA, T0 + 20 * MIN)] }, + ] }); + await w.mouth.syncOnce(); + assert.deepEqual(await go(w, T0 - MIN), [["$k0", null]]); + assert.deepEqual(await go(w, T0), []); + assert.deepEqual(await go(w, T0 + 9 * MIN), [["$a", "follow"]]); + assert.deepEqual(await go(w, T0 + 10 * MIN + SEC), [["$b", null]]); + assert.deepEqual(await go(w, T0 + 11 * MIN), []); + assert.deepEqual(await go(w, T0 + 20 * MIN), [["$c", "follow"], ["$d", null]]); // lena never spoke before the second post either +}); + +test("follow: the window needs this process's clock too: a message stamped inside it but read long after is not a follow", async () => { + const w = world({ rounds: [{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [mine("$mine", T0)] }, { [ROOM]: [msg("$forged", KIKO, T0 + MIN)] }] }); + await w.mouth.syncOnce(); + await go(w, T0 - MIN); await go(w, T0); + assert.deepEqual(await go(w, T0 + 30 * MIN), [["$forged", null]]); +}); + +test("follow: her mouth send anchors at send time, and its /sync echo does not anchor again", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, + { [ROOM]: [msg("$sent1", ME, T0 + 8 * MIN, { body: "echo" })] }, + { [ROOM]: [msg("$late", KIKO, T0 + 12 * MIN)] }, + ] }); + await w.mouth.syncOnce(); await w.basisOfRound(); + const sent = await w.mouth.send({ requestId: "r1", persona: "eureka", roomAlias: ALIAS, text: "hello" }); + assert.equal(sent.refusal, null); + w.at(T0 + 8 * MIN); + await w.basisOfRound(); // the echo, 8 minutes on: a second anchor would carry the window to 18 minutes + w.at(T0 + 12 * MIN); + assert.deepEqual(await w.basisOfRound(), [["$late", null]]); +}); + +test("follow: a send from the mouth anchors; a send into a DM or an unreadable room does not", async () => { + const mk = opts => world({ ...opts, rounds: [{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [msg("$k1", KIKO, T0 + MIN)] }] }); + const ok = mk({}); + await ok.mouth.syncOnce(); await ok.basisOfRound(); + await ok.mouth.send({ requestId: "r1", persona: "eureka", roomAlias: ALIAS, text: "hello" }); + assert.deepEqual(await ok.basisOfRound(), [["$k1", "follow"]]); + const dm = mk({ direct: { [KIKO]: [ROOM] } }); + await dm.mouth.syncOnce(); await dm.basisOfRound(); + await dm.mouth.send({ requestId: "r1", persona: "eureka", roomAlias: ROOM, text: "hello" }); + assert.deepEqual(await dm.basisOfRound(), [["$k1", "direct"]]); +}); + +test("follow: a DM, an encrypted room and a bridged room anchor nothing; an unreadable or non-array state is not public", async () => { + const rounds = () => [{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [mine("$mine", T0)] }, { [ROOM]: [msg("$k", KIKO, T0 + MIN)] }]; + for (const state of [[{ type: "m.room.encryption", content: {} }], [{ type: "m.bridge", content: {} }], [{ type: "uk.half-shot.bridge", content: {} }], { not: "an array" }]) { + assert.deepEqual((await run(world({ state: { [ROOM]: state }, rounds: rounds() }), 3))[2], [["$k", null]]); + } + assert.deepEqual((await run(world({ stateStatus: { [ROOM]: 500 }, rounds: rounds() }), 3))[2], [["$k", null]]); + assert.deepEqual((await run(world({ direct: { [KIKO]: [ROOM] }, rounds: rounds() }), 3))[2], [["$k", "direct"]]); + // plaintext and unbridged is public, whatever else it is (an invite-only room included): the state holds no join rule the mouth reads + assert.deepEqual((await run(world({ state: { [ROOM]: [{ type: "m.room.join_rules", content: { join_rule: "invite" } }] }, rounds: rounds() }), 3))[2], [["$k", "follow"]]); +}); + +test("follow: a bridged sender or event is neither counted nor followed", async () => { + const puppet = `@_${PLATFORM}_123:${HS}`; + const w = world({ rounds: [{ [ROOM]: [msg("$p0", puppet, T0 - MIN), msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [mine("$mine", T0, { "m.mentions": { user_ids: [puppet] } })] }, + { [ROOM]: [msg("$p", puppet, T0 + MIN), msg("$k", KIKO, T0 + MIN, { [`${PLATFORM}.relay`]: true })] }] }); + assert.deepEqual((await run(w, 3))[2].sort(), [["$k", null], ["$p", null]]); +}); + +test("follow: at most three an hour per room and peer, counted independently; a half hour does not reset it; an hour later it is follow again", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$k0", KIKO, T0 - SEC), msg("$l0", LENA, T0 - SEC)], [OTHER_ROOM]: [msg("$ko0", KIKO, T0 - SEC)] }, + { [ROOM]: [mine("$m1", T0 + MIN)], [OTHER_ROOM]: [msg("$mo1", ME, T0 + MIN)] }, + { [ROOM]: [msg("$k1", KIKO, T0 + MIN + SEC)], [OTHER_ROOM]: [msg("$ko1", KIKO, T0 + MIN + SEC)] }, + { [ROOM]: [mine("$m2", T0 + 2 * MIN)] }, { [ROOM]: [msg("$k2", KIKO, T0 + 2 * MIN + SEC)] }, + { [ROOM]: [mine("$m3", T0 + 3 * MIN)] }, { [ROOM]: [msg("$k3", KIKO, T0 + 3 * MIN + SEC)] }, + { [ROOM]: [mine("$m4", T0 + 4 * MIN)] }, { [ROOM]: [msg("$k4", KIKO, T0 + 4 * MIN + SEC), msg("$l4", LENA, T0 + 4 * MIN + SEC)] }, + { [ROOM]: [msg("$k30", KIKO, T0 + 30 * MIN), mine("$m31", T0 + 31 * MIN)] }, { [ROOM]: [msg("$k31", KIKO, T0 + 31 * MIN + SEC)] }, + { [ROOM]: [msg("$k60", KIKO, T0 + 61 * MIN), mine("$m62", T0 + 62 * MIN)] }, { [ROOM]: [msg("$k62", KIKO, T0 + 62 * MIN + SEC)] }, + ] }); + await w.mouth.syncOnce(); + const seen = []; + const steps = [0, 1, 1, 2, 2, 3, 3, 4, 4, 31, 31, 62, 62]; + for (const m of steps) seen.push(...await go(w, T0 + m * MIN + 2 * SEC)); + const got = Object.fromEntries(seen.filter(([id]) => /^\$(k|ko|l)\d/.test(id)).map(([id, b]) => [id, b])); + assert.deepEqual(got, { $k0: null, $l0: null, $ko0: null, $k1: "follow", $ko1: "follow", $k2: "follow", $k3: "follow", $k4: null, $l4: "follow", $k30: null, $k31: null, $k60: null, $k62: "follow" }); +}); + +test("follow: an edit and a notice are never follow; a room-basis message from a counted sender is; a tag keeps its own basis", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, + { [ROOM]: [mine("$mine", T0)] }, + { [ROOM]: [ + msg("$edit", KIKO, T0 + SEC, { "m.new_content": { msgtype: "m.text", body: "x" }, "m.relates_to": { rel_type: "m.replace", event_id: "$k0" } }), + msg("$notice", KIKO, T0 + SEC, { msgtype: "m.notice" }), + msg("$room", KIKO, T0 + SEC, { "m.mentions": { room: true } }), + msg("$tag", KIKO, T0 + SEC, { "m.mentions": { user_ids: [ME] } }), + ] }, + ] }); + assert.deepEqual((await run(w, 3))[2].sort(), [["$edit", null], ["$notice", null], ["$room", "follow"], ["$tag", "tag"]]); +}); + +test("follow: flag off emits nothing new and reads nothing extra; a fresh mouth has no engagement", async () => { + const rounds = [{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [mine("$mine", T0)] }, { [ROOM]: [msg("$k", KIKO, T0 + MIN)] }]; + const off = world({ follow: false, rounds }); + assert.deepEqual((await run(off, 3))[2], [["$k", null]]); + assert.ok(!off.seen.some(l => l.endsWith("/state")), "off: no state read"); + const restarted = world({ rounds: [{ [ROOM]: [msg("$k", KIKO, T0 + MIN)] }] }); + assert.deepEqual((await run(restarted, 1))[0], [["$k", null]]); + assert.equal(followFromEnv({ CHAT_FOLLOW: "1" }), true); + for (const v of [undefined, "0", "true", "yes", ""]) assert.equal(followFromEnv({ CHAT_FOLLOW: v }), false); +}); + +test("follow: the basis is a name the delivery document accepts", () => { + assert.ok(docs.bases.includes("follow")); + const d = { seq: 1, roomAlias: ALIAS, roomId: ROOM, eventId: "$e", sender: KIKO, senderDisplay: "kiko", text: "x", atUnixMs: 1, context: [], image: null, mentions: [], isDirect: false, basis: "follow", form: "message" }; + assert.equal(docs.parseDeliveryV3(docs.deliveryV3Tuple(d)).basis, "follow"); +}); + +const tick = async () => { for (let i = 0; i < 20; i++) await new Promise(r => setImmediate(r)); }; + +test("follow: a send receipt is not delayed by a room-state read that hangs or fails; the anchor happens in the background", async () => { + for (const opts of [{ hangState: true }, { stateStatus: { [ROOM]: 500 } }]) { + const w = world({ ...opts, rounds: [] }); + await w.mouth.syncOnce(); + const sent = await Promise.race([w.mouth.send({ requestId: "r1", persona: "eureka", roomAlias: ALIAS, text: "hello" }), new Promise(r => setTimeout(() => r("late"), 500))]); + assert.equal(sent.refusal, null); + } + const ok = world({ rounds: [{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [msg("$k1", KIKO, T0 + MIN)] }] }); + await ok.mouth.syncOnce(); await ok.basisOfRound(); + await ok.mouth.send({ requestId: "r1", persona: "eureka", roomAlias: ALIAS, text: "hello" }); + await tick(); + assert.deepEqual(await ok.basisOfRound(), [["$k1", "follow"]]); +}); + +test("follow: the flag off makes a send cost no extra call and no extra turn", async () => { + const w = world({ follow: false, rounds: [] }); + await w.mouth.syncOnce(); + const before = w.seen.length; + await w.mouth.send({ requestId: "r1", persona: "eureka", roomAlias: ALIAS, text: "hello" }); + await tick(); + assert.ok(!w.seen.slice(before).some(l => l.endsWith("/state"))); +}); + +test("follow: a homeserver clock up to 60 s behind still follows; 61 s early does not; the very end of the window is out, 1 ms before it is in", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$k0", KIKO, T0 - MIN), msg("$l0", LENA, T0 - MIN), msg("$e0", `@e:${HS}`, T0 - MIN), msg("$f0", `@f:${HS}`, T0 - MIN)] }, + { [ROOM]: [mine("$mine", T0)] }, + { [ROOM]: [msg("$x1", KIKO, T0 - 60 * SEC), msg("$x2", LENA, T0 - 61 * SEC), msg("$x3", `@e:${HS}`, T0 + W - 1), msg("$x4", `@f:${HS}`, T0 + W)] }, + ] }); + await w.mouth.syncOnce(); await go(w, T0 - MIN); await go(w, T0); + assert.deepEqual((await go(w, T0 + 5 * SEC)).sort(), [["$x1", "follow"], ["$x2", null], ["$x3", "follow"], ["$x4", null]]); +}); + +test("follow: a window that ended while the pass was being read is out, whatever the message says", async () => { + let jump = null; + const w = world({ onSync: set => { if (jump !== null) set(jump); }, rounds: [{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [mine("$mine", T0)] }, { [ROOM]: [msg("$x", KIKO, T0 + MIN)] }] }); + await w.mouth.syncOnce(); await go(w, T0 - MIN); + const keep = w.mouth.followState; + w.at(T0); await w.basisOfRound(); // her post; the next sync moves the clock after the purge + assert.equal(keep().engagements.length, 1); + jump = T0 + 30 * MIN; w.at(T0); // the purge at the start of the pass sees T0; the clock moves while the pass reads + assert.deepEqual(await w.basisOfRound(), [["$x", null]]); +}); + +test("follow: the cap counts only the hour before the message", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$k0", KIKO, T0 - SEC)] }, + ...[1, 2, 3].flatMap(n => [{ [ROOM]: [mine(`$m${n}`, T0 + n * MIN)] }, { [ROOM]: [msg(`$x${n}`, KIKO, T0 + n * MIN + SEC)] }]), + { [ROOM]: [msg("$k90", KIKO, T0 + 89 * MIN), mine("$m90", T0 + 90 * MIN)] }, { [ROOM]: [msg("$x90", KIKO, T0 + 90 * MIN + SEC)] }, + ] }); + await w.mouth.syncOnce(); await go(w, T0); + for (const n of [1, 1, 2, 2, 3, 3]) await go(w, T0 + n * MIN + SEC); + assert.deepEqual(await go(w, T0 + 30 * MIN), [["$k90", null]]); // stamps run ahead of this process's clock, so only the cap's own hour bound can tell + assert.deepEqual(await go(w, T0 + 30 * MIN), [["$x90", "follow"]]); +}); + +test("follow: whoever her reply quotes is counted though they spoke long ago; a thread fallback pointer names no one", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$old", LENA, T0 - 30 * MIN), msg("$ghost", `@ghost:${HS}`, T0 - 30 * MIN)] }, + { [ROOM]: [mine("$mine", T0, { "m.relates_to": { "m.in_reply_to": { event_id: "$old" } } })] }, + { [ROOM]: [msg("$x1", LENA, T0 + MIN)] }, + { [ROOM]: [msg("$old2", KIKO, T0 + 2 * MIN)] }, + { [ROOM]: [mine("$mine2", T0 + 3 * MIN, { "m.relates_to": { rel_type: "m.thread", event_id: "$old", is_falling_back: true, "m.in_reply_to": { event_id: "$ghost" } } })] }, + { [ROOM]: [msg("$x2", STRANGER, T0 + 4 * MIN)] }, + ] }); + await w.mouth.syncOnce(); + await go(w, T0 - 30 * MIN); await go(w, T0); + assert.deepEqual(await go(w, T0 + MIN), [["$x1", "follow"]]); + await go(w, T0 + 2 * MIN); + await go(w, T0 + 3 * MIN); + const [engagement] = w.mouth.followState().engagements; + assert.ok(engagement.counted.includes(KIKO), "kiko spoke 1 minute before the second post"); + assert.ok(!engagement.counted.includes(`@ghost:${HS}`), "a fallback pointer names no one"); +}); + +test("follow: someone stamped after her send is not counted as having spoken before it", async () => { + const w = world({ rounds: [{ [ROOM]: [msg("$ahead", KIKO, T0 + MIN)] }, { [ROOM]: [msg("$x", KIKO, T0 + 2 * MIN)] }] }); + await w.mouth.syncOnce(); await w.basisOfRound(); + await w.mouth.send({ requestId: "r1", persona: "eureka", roomAlias: ALIAS, text: "hello" }); + await tick(); + assert.deepEqual(w.mouth.followState().engagements[0].counted, []); + assert.deepEqual(await go(w, T0 + 2 * MIN), [["$x", null]]); +}); + +test("follow: the ring keeps the newest 200 senders of a room; a flood pushes out the oldest, not the newest", async () => { + const flood = Array.from({ length: 250 }, (_, i) => msg(`$f${i}`, `@u${i}:${HS}`, T0 - 5 * MIN + i)); + const w = world({ rounds: [{ [ROOM]: flood }, { [ROOM]: [mine("$mine", T0)] }, { [ROOM]: [msg("$x0", `@u0:${HS}`, T0 + MIN), msg("$x249", `@u249:${HS}`, T0 + MIN)] }] }); + await w.mouth.syncOnce(); await w.basisOfRound(); + assert.equal(w.mouth.followState().spoke[0].senders.length, 200); + await go(w, T0); + assert.deepEqual(await go(w, T0 + MIN + SEC), [["$x0", null], ["$x249", "follow"]]); + assert.equal(w.mouth.followState().engagements[0].counted.length, 200); +}); + +test("follow: only senders, and only real ones, are remembered and counted: not her, not a bridged id, not an edit, not a notice, not a malformed id", async () => { + const puppet = `@_${PLATFORM}_9:${HS}`; + const w = world({ rounds: [ + { [ROOM]: [msg("$a", KIKO, T0 - 2 * MIN), mine("$self", T0 - 2 * MIN), msg("$b", puppet, T0 - 2 * MIN), + msg("$c", LENA, T0 - 2 * MIN, { "m.relates_to": { rel_type: "m.replace", event_id: "$a" } }), msg("$d", STRANGER, T0 - 2 * MIN, { msgtype: "m.notice" })] }, + { [ROOM]: [mine("$mine", T0, { "m.mentions": { user_ids: [ME, puppet, "not-an-id", `@${"x".repeat(300)}:${HS}`, LENA] } })] }, + ] }); + await w.mouth.syncOnce(); await go(w, T0 - MIN); + assert.deepEqual(w.mouth.followState().spoke, [{ room: ROOM, senders: [KIKO] }]); + await go(w, T0); + assert.deepEqual(w.mouth.followState().engagements[0].counted.sort(), [KIKO, LENA].sort()); +}); + +test("follow: her own post in the baseline sync, which is history, anchors nothing", async () => { + const w = world({ baselineEvents: { [ROOM]: [msg("$k0", KIKO, T0 - MIN), mine("$mine", T0)] }, rounds: [] }); + await w.mouth.syncOnce(); + assert.deepEqual(w.mouth.followState().engagements, []); +}); + +test("follow: every pass forgets what has aged out: anchors, speakers and cap times", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [mine("$mine", T0)] }, { [ROOM]: [msg("$x", KIKO, T0 + MIN)] }, {}, + ] }); + await w.mouth.syncOnce(); await go(w, T0 - MIN); await go(w, T0); await go(w, T0 + MIN + SEC); + const s1 = w.mouth.followState(); + assert.equal(s1.engagements.length, 1); assert.equal(s1.followsGiven, 1); assert.equal(s1.spoke.length, 1); + await go(w, T0 + 3 * 60 * MIN); + assert.deepEqual(w.mouth.followState(), { engagements: [], spoke: [], followsGiven: 0 }); +}); + +test("follow: a failed room-state read at send time leaves the post retryable: its echo anchors it", async () => { + const w = world({ stateFailures: 1, rounds: [{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [msg("$sent1", ME, T0, { body: "hello" })] }, { [ROOM]: [msg("$x", KIKO, T0 + MIN)] }] }); + await w.mouth.syncOnce(); await w.basisOfRound(); + await w.mouth.send({ requestId: "r1", persona: "eureka", roomAlias: ALIAS, text: "hello" }); + await tick(); + assert.deepEqual(w.mouth.followState().engagements, [], "the read failed: nothing anchored yet"); + await go(w, T0); + assert.equal(w.mouth.followState().engagements.length, 1, "the echo anchored it"); + assert.deepEqual(await go(w, T0 + MIN), [["$x", "follow"]]); +}); + +test("follow: a send and its echo in flight together anchor once, and a failure of that one read leaves the echo to retry", async () => { + const w = world({ stateFailures: 1, rounds: [{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [msg("$sent1", ME, T0, { body: "hello" })] }] }); + await w.mouth.syncOnce(); await w.basisOfRound(); + const sending = w.mouth.send({ requestId: "r1", persona: "eureka", roomAlias: ALIAS, text: "hello" }); + await sending; + await w.basisOfRound(); // the echo, possibly while the send's read is still in flight + await tick(); + assert.equal(w.mouth.followState().engagements.length, 1); + assert.equal(w.seen.filter(l => l.endsWith("/state")).length, 2, "one failed read, one that worked; no third"); +}); + +test("follow: a not-public verdict is recorded: the room is not read again for each post or pass", async () => { + const w = world({ state: { [ROOM]: [{ type: "m.bridge", content: {} }] }, rounds: [ + { [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [msg("$sent1", ME, T0)] }, { [ROOM]: [mine("$a", T0 + SEC), msg("$x", KIKO, T0 + 2 * SEC)] }, { [ROOM]: [mine("$b", T0 + 3 * SEC)] }, + ] }); + await w.mouth.syncOnce(); await w.basisOfRound(); + await w.mouth.send({ requestId: "r1", persona: "eureka", roomAlias: ALIAS, text: "hello" }); + await tick(); + for (const t of [0, 0, 0]) await go(w, T0 + t); + assert.equal(w.seen.filter(l => l.endsWith("/state")).length, 1); + assert.deepEqual(w.mouth.followState().engagements, []); +}); + +test("follow: an echo that arrives while the send's anchor is still reading waits for it: one read, one snapshot", async () => { + const w = world({ stateDelay: 80, rounds: [{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [msg("$sent1", ME, T0 + 5 * SEC, { body: "hello" })] }] }); + await w.mouth.syncOnce(); await w.basisOfRound(); + await w.mouth.send({ requestId: "r1", persona: "eureka", roomAlias: ALIAS, text: "hello" }); // its anchor is now waiting on the state read + await w.basisOfRound(); // the echo, meanwhile + await new Promise(r => setTimeout(r, 200)); + assert.equal(w.seen.filter(l => l.endsWith("/state")).length, 1); + assert.equal(w.mouth.followState().engagements[0].from, T0, "anchored at the send's time, not the echo's"); +}); diff --git a/tools/persona-chat-documents.mjs b/tools/persona-chat-documents.mjs index 059e595..afdc7f3 100644 --- a/tools/persona-chat-documents.mjs +++ b/tools/persona-chat-documents.mjs @@ -68,8 +68,8 @@ export function parsePendingRequest(t) { // What the mouth can establish about a delivery from Matrix alone, for the Persona it is delivered to (see docs/bridge.md, "Addressing facts"). // `basis` is why the message is for her, or null when the mouth has no proof (not that it is not for her: her names, her projects' names and -// the sender's role are not the mouth's to know). `form` is the event's kind: only a "message" can address anyone. -export const bases = Object.freeze(["direct", "tag", "reply", "room"]); +// the sender's role are not the mouth's to know). "follow" is emitted only under the mouth's CHAT_FOLLOW flag. `form` is the event's kind: only a "message" can address anyone. +export const bases = Object.freeze(["direct", "tag", "reply", "room", "follow"]); export const forms = Object.freeze(["message", "notice", "edit"]); export function ackTuple({ persona, throughSeq }) { diff --git a/tools/persona-matrix-mouth.mjs b/tools/persona-matrix-mouth.mjs index 10af28a..77b45e2 100644 --- a/tools/persona-matrix-mouth.mjs +++ b/tools/persona-matrix-mouth.mjs @@ -6,7 +6,7 @@ // its m.mentions user ids and whether the room is a DM. The /sync position // (next_batch) is persisted only when an ack covers it, so a restart before the // ack re-delivers instead of losing. A v3 delivery also carries what Matrix proves about why it is for her (basis: direct, tag, -// reply, room; form: message, notice, edit); names and roles are the heartbeat's to match, not the mouth's (docs/bridge.md, +// reply, room, and with CHAT_FOLLOW=1 follow; form: message, notice, edit); names and roles are the heartbeat's to match, not the mouth's (docs/bridge.md, // "Addressing facts"). Reading wakes nothing: the heartbeat polls // with a non-consuming chat_pending_request and decides who runs. An ack posts nothing to Matrix. Her public // m.read receipt follows a chat_read document from Huginn, sent when a delivery has gone into the model's @@ -23,6 +23,11 @@ // reads the room's head from Matrix and answers RoomMoved, naming the messages that moved it, instead // of the PUT. A v1 send, and a v2 send with no basis, are not checked; both still pass through the gate. // +// Follow (CHAT_FOLLOW=1, default off; docs/bridge.md "Follow"): her own send in a public room (not a DM, not encrypted, not bridged) anchors the room +// for 10 minutes; those who spoke there in the 10 minutes before it, and whoever it tagged or replied to, get basis "follow" on their later messages +// inside the window, at most 3 an hour per peer and room. Memory only. Off, no basis "follow" is ever emitted: Huginn's parser refuses it until its +// Basis::Follow lands. +// // Media: an m.image/m.file/m.audio/m.video event is delivered as one typed line (see bodyText), not its bare filename. // An m.image event in a delivered message is also fetched, as this Persona and only when an image store is configured // (persona-chat-images.mjs): the delivery then carries a reference to the stored file, or the line says why not. @@ -68,6 +73,12 @@ const FETCH_DEADLINE_MS = 15000; // one fetch, whole: it ends before Huginn's 20 const FETCH_PER_HOUR = 30; // fetches one Persona may ask in an hour const RELATION_PAGES = 3; // pages of a receipt target's relations read before the answer is called unproven const PLATFORM = ["disc", "ord"].join(""); // the bridged platform's name, as Huginn's room_scope spells it: never written out whole +const FOLLOW_WINDOW_MS = 10 * 60 * 1000; // W: an engagement lasts this long from the Mind's own post; a follow never extends it +const FOLLOW_PER_HOUR = 3; // K: follow bases given per (Mind, peer, room) in an hour; past it the message keeps the basis it had +const FOLLOW_ROOM_TTL_MS = 5 * 60 * 1000; // how long a room's public verdict (not encrypted, not bridged) is kept +const FOLLOW_SLACK_MS = 60 * 1000; // clock disagreement tolerated between this process and the homeserver +const FOLLOW_HOUR_MS = 60 * 60 * 1000; +const FOLLOW_RING = 200; // recent senders kept per room const HEAD_PAGE = 50; // the room head a gated send reads; also the most ids a RoomMoved receipt names // The kind of an event for a delivery: an edit (m.replace) and an automated notice (m.notice) are not messages anyone is addressed by. @@ -126,6 +137,7 @@ export function createMouth({ persona, homeserver, serverName, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake, now = Date.now, log = () => {}, syncTimeoutMs = 30000, maxPending = MAX_PENDING, gate = createRoomGate(), sendTimeoutMs = SEND_TIMEOUT_MS, presenceGraceMs = PRESENCE_GRACE_MS, fetchDeadlineMs = FETCH_DEADLINE_MS, + follow = false, // the follow basis (CHAT_FOLLOW): an untagged message from someone she tagged or replied to, within FOLLOW_WINDOW_MS of her own post, is for her replyLink = false, // resolve each reply's quoted event into delivery.replyTo (the v4 answer; one more event read per reply to a non-Mind) images = null, // this Persona's image store (createImageStore), or null: images stay the typed text line isServed = async () => false, // (async) whether a Matrix user id is an account this process speaks for: a Mind is not a person to open a DM with @@ -303,6 +315,96 @@ export function createMouth({ return lines; } + // Follow (docs/bridge.md, "Follow"). Memory only, bounded, purged on every sync pass. Her own send in a public room (not a DM, not encrypted, not + // bridged) anchors that room for W from the send: `engagements` room id -> { from, ends, counted }, counted being the senders who spoke there in the + // W before the send (`spoke`, a per-room ring of recent senders with event times) plus whoever the post tagged or replied to, if the event says so. + // Someone who first speaks after the send is not counted. Only her speaking again re-anchors (and re-snapshots); a follow never extends the window. + // `followsGiven` room|peer -> times follows were given. A restart empties all of it, which only shortens windows. + const engagements = new Map(); + const spoke = new Map(); + const followsGiven = new Map(); + const anchored = new Set(); // event ids of her own posts already anchored: the send path and its /sync echo anchor once + const anchoring = new Map(); // event ids whose anchor is in flight: the send path and its echo never snapshot twice + const publicRooms = new Map(); // room id -> { ok, at }: the verdicts, a "no" included, so a not-public room is read once per FOLLOW_ROOM_TTL_MS, not every pass + const platformId = id => platformEvent({ sender: id }); + // true or false is a verdict (kept); null is a failed read: unknown, retryable, kept nowhere. + async function publicRoom(roomId) { + if (directRooms.has(roomId)) return false; + const known = publicRooms.get(roomId); + if (known && now() - known.at < FOLLOW_ROOM_TTL_MS) return known.ok; + try { + const state = await matrix("GET", `/rooms/${encodeURIComponent(roomId)}/state`, undefined, API, AbortSignal.timeout(sendTimeoutMs)); + if (!Array.isArray(state)) return null; + const ok = !state.some(e => e?.type === "m.room.encryption" || e?.type === "m.bridge" || e?.type === "uk.half-shot.bridge"); + publicRooms.set(roomId, { ok, at: now() }); + return ok; + } catch (error) { + say(`follow: room ${roomId} state not read: ${error.message}`); + return null; // unknown is not public, and not a verdict either + } + } + const eventAt = event => Number.isSafeInteger(event.origin_server_ts) && event.origin_server_ts >= 0 ? event.origin_server_ts : now(); + function purgeFollow() { + const t = now(); + for (const [room, e] of engagements) if (e.ends + FOLLOW_SLACK_MS <= t) engagements.delete(room); + for (const [room, ring] of spoke) { + const kept = ring.filter(r => r.at > t - FOLLOW_WINDOW_MS - FOLLOW_SLACK_MS); + if (kept.length === 0) spoke.delete(room); else spoke.set(room, kept); + } + for (const [key, times] of followsGiven) { + const kept = times.filter(x => t - x < FOLLOW_HOUR_MS + FOLLOW_SLACK_MS); + if (kept.length === 0) followsGiven.delete(key); else followsGiven.set(key, kept); + } + } + // Someone spoke in the room (a message from another account, not an edit or a notice): the ring behind the snapshot. + function noteSpeaker(roomId, event) { + if (!follow || directRooms.has(roomId) || formOf(event.content) !== "message" || platformEvent(event) || event.sender === selfId) return; + const ring = spoke.get(roomId) ?? []; + ring.push({ sender: event.sender, at: eventAt(event) }); + if (ring.length > FOLLOW_RING) ring.shift(); + spoke.set(roomId, ring); + } + // Her post at time `at` (the send's own time, or the echo's origin time): snapshot the room. `tagged`: ids from the event's m.mentions, `replyTo` the quoted event id. + async function anchor(roomId, eventId, at, tagged = [], replyTo = null) { + if (!follow) return; + while (anchoring.has(eventId)) await anchoring.get(eventId).catch(() => {}); // the send and its echo meet here: the second waits, then sees whether the first succeeded + if (anchored.has(eventId)) return; + const run = (async () => { + const verdict = await publicRoom(roomId); + if (verdict === null) return; // the read failed: this post stays retryable, its echo tries again + anchored.add(eventId); + if (anchored.size > 200) anchored.delete(anchored.values().next().value); + if (!verdict) return; // a "no" stays recorded + const counted = new Set((spoke.get(roomId) ?? []).filter(r => r.at >= at - FOLLOW_WINDOW_MS && r.at <= at).map(r => r.sender)); + for (const id of tagged) counted.add(id); + if (typeof replyTo === "string" && replyTo !== "") { const author = await authorOf(roomId, replyTo); if (author !== null) counted.add(author); } + for (const id of [...counted]) if (!docs.isMatrixId("@", id) || id === selfId || platformId(id)) counted.delete(id); + engagements.set(roomId, { from: at, ends: at + FOLLOW_WINDOW_MS, counted }); + })(); + anchoring.set(eventId, run); + try { await run; } finally { anchoring.delete(eventId); } + } + // Her own event seen in /sync (a send from another client, or the echo of a mouth send, which is then not anchored twice). + async function noteOwnPost(roomId, event) { + if (!follow || formOf(event.content) !== "message" || platformEvent(event)) return; + const relates = event.content["m.relates_to"]; + const tagged = Array.isArray(event.content["m.mentions"]?.user_ids) ? event.content["m.mentions"].user_ids : []; + await anchor(roomId, event.event_id, eventAt(event), tagged.slice(0, docs.limits.mentions), relates?.is_falling_back === true ? null : relates?.["m.in_reply_to"]?.event_id); + } + // Whether this message is a follow, and if so spends one of the peer's K for the hour. Called only for a message that has no better basis. + async function followed(roomId, event) { + if (!follow || directRooms.has(roomId) || formOf(event.content) !== "message" || platformEvent(event)) return false; + const e = engagements.get(roomId), at = eventAt(event), t = now(); + // Both clocks must agree: the message's own time inside the window, and this process's time not past it (a forged or old timestamp reuses nothing). + if (!e || !e.counted.has(event.sender) || at < e.from - FOLLOW_SLACK_MS || at >= e.ends || t >= e.ends + FOLLOW_SLACK_MS) return false; + if (!(await publicRoom(roomId))) return false; + const key = `${roomId}|${event.sender}`; + const recent = (followsGiven.get(key) ?? []).filter(x => at - x < FOLLOW_HOUR_MS); + if (recent.length >= FOLLOW_PER_HOUR) { followsGiven.set(key, recent); return false; } + followsGiven.set(key, [...recent, at]); + return true; + } + // Why a delivered event is for this Persona, from what Matrix says and nothing else: "direct" (a DM), "tag" (m.mentions lists her // id, or, from a client that sends no m.mentions at all, the body links her matrix.to pill), "reply" (it replies to, or sits in a // thread under, an event her account wrote, so her Self run's words count as hers), "room" (an @room mention). In that order; null @@ -440,6 +542,7 @@ export function createMouth({ // One /sync round. Returns the deliveries it made. async function syncOnce() { if (!started || unjoined()) await start(); + purgeFollow(); replyReads = 0; held.clear(); const free = await released(); // the round's brake read for presence, taken before any early return @@ -474,7 +577,10 @@ export function createMouth({ const deliverable = !baseline && !freshRooms.has(roomId) && event.sender !== selfId; if (deliverable && pending.length + made.length >= maxPending) { truncated = true; break rounds; } if (deliverable && replyLink && replyReads + targetsToRead(roomId, event) > REPLY_READS_PER_ROUND) { say(`event ${event.event_id} not read: round cap on target reads, the rest of the round waits for the next`); truncated = true; break rounds; } // not processed: the next round meets it again, with its read count reset - const basis = deliverable ? await basisOf(roomId, event) : null; + if (!baseline && !freshRooms.has(roomId) && event.sender === selfId) await noteOwnPost(roomId, event); + noteSpeaker(roomId, event); + let basis = deliverable ? await basisOf(roomId, event) : null; + if (deliverable && (basis === null || basis === "room") && await followed(roomId, event)) basis = "follow"; const replyTo = deliverable ? await replyToOf(roomId, event) : null; processed.add(event.event_id); if (deliverable) { @@ -647,7 +753,10 @@ export function createMouth({ const sent = await matrix("PUT", `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(requestId)}`, { msgtype: "m.text", body: text }, API, AbortSignal.timeout(sendTimeoutMs)); return { requestId, eventId: sent.event_id, refusal: null, newer: [] }; }); - if (result.refusal === null) { touch(); setPresence(true).catch(() => {}); } // outside the room gate and not awaited: a slow presence write holds neither another send nor this receipt + if (result.refusal === null) { + touch(); setPresence(true).catch(() => {}); // outside the room gate and not awaited: a slow presence write holds neither another send nor this receipt + if (follow) { const at = now(); anchor(roomId, result.eventId, at).catch(error => say(`follow: send not anchored: ${error.message}`)); } // in the background too: a room-state read that hangs or fails never delays the receipt + } return result; } catch (error) { say(`send failed: ${error.message}`); @@ -887,7 +996,9 @@ export function createMouth({ } } - return { start, syncOnce, ack, read, pendingAfter, tail, speakableRooms, send, openDirect, fetchAttachment, run, get userId() { return selfId; }, get pendingCount() { return pending.length; } }; + // A plain copy of the follow memory, for tests: anchors with their counted senders, the sender ring, and how many cap entries are held. + const followState = () => ({ engagements: [...engagements].map(([room, e]) => ({ room, from: e.from, ends: e.ends, counted: [...e.counted] })), spoke: [...spoke].map(([room, ring]) => ({ room, senders: ring.map(r => r.sender) })), followsGiven: followsGiven.size }); + return { followState, start, syncOnce, ack, read, pendingAfter, tail, speakableRooms, send, openDirect, fetchAttachment, run, get userId() { return selfId; }, get pendingCount() { return pending.length; } }; } // The log's word for a failure: error class and HTTP status only, never the message (it can hold a URL, an mxc or a sender-chosen id). @@ -1074,6 +1185,8 @@ export function loadRuntime(cultRoot) { export const replyLinkFromEnv = env => env.CHAT_PENDING_V4 === "1"; // The fetch flag, the same way: on only for exactly "1". Off, the fetch request kind is an unknown kind. export const fetchFromEnv = env => env.CHAT_FETCH === "1"; +// The follow flag, the same way. Off, the mouth never emits basis "follow" (Huginn's parser refuses an unknown basis until its Basis::Follow lands). +export const followFromEnv = env => env.CHAT_FOLLOW === "1"; function required(env, name) { if (!env[name]) throw new Error(`${name} is required`); @@ -1127,7 +1240,7 @@ export async function main(env = process.env) { const mouths = new Map(personas.map(persona => { mkdirSync(join(stateRoot, persona), { recursive: true }); return [persona, createMouth({ - persona, homeserver, serverName, rooms: roomsByPersona.get(persona), token: tokens.get(persona), gate, replyLink, isServed: async id => { await Promise.all([...mouths.values()].map(m => m.start().catch(() => {}))); return [...mouths.values()].some(m => m.userId === id); }, log: line => log(`${persona}: ${line}`), + persona, homeserver, serverName, rooms: roomsByPersona.get(persona), token: tokens.get(persona), gate, replyLink, follow: followFromEnv(env), isServed: async id => { await Promise.all([...mouths.values()].map(m => m.start().catch(() => {}))); return [...mouths.values()].some(m => m.userId === id); }, log: line => log(`${persona}: ${line}`), images: imageRoot ? createImageStore({ root: join(imageRoot, persona), log: line => log(`${persona}: ${line}`) }) : null, state: cultcacheState({ runtime, path: join(stateRoot, persona, "cursor.cc") }), brake: createBrakeReader({ controlPath: join(controlRoot, persona, "control.cc"), readStore: runtime.readSingleFileStore, decode: runtime.msgpack.decode }), From a1b173079dfe2ef4ba60734e9c4000101c725c04 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 12:24:51 +0000 Subject: [PATCH 029/102] tests: dispatch-gate tests run on Linux; classify the remaining Yggdrasil failures (#75) --- tests/Bifrost.Web.Tests/BridgeCliTests.cs | 134 +++++++++++++--------- 1 file changed, 79 insertions(+), 55 deletions(-) diff --git a/tests/Bifrost.Web.Tests/BridgeCliTests.cs b/tests/Bifrost.Web.Tests/BridgeCliTests.cs index a8efe6d..3b13d08 100644 --- a/tests/Bifrost.Web.Tests/BridgeCliTests.cs +++ b/tests/Bifrost.Web.Tests/BridgeCliTests.cs @@ -1061,18 +1061,16 @@ rl.on("line", (line) => { await RunGitAsync(["commit", "-m", "Initial commit"], workDir); await RunGitAsync(["remote", "add", "origin", remoteDir], workDir); - var blocked = await RunCommandAsync( - "powershell", - ["-NoProfile", "-Command", "git push -u origin main"], + var blocked = await RunShellAsync( + "git push -u origin main", workDir, BuildDispatchedGitHookEnv()); Assert.NotEqual(0, blocked.ExitCode); Assert.Contains("Bifrost blocked git push", blocked.Stderr, StringComparison.OrdinalIgnoreCase); - var blockedNoVerify = await RunCommandAsync( - "powershell", - ["-NoProfile", "-Command", "git push --no-verify -u origin main"], + var blockedNoVerify = await RunShellAsync( + "git push --no-verify -u origin main", workDir, BuildDispatchedGitGateEnv()); @@ -1154,19 +1152,13 @@ console.log("https://github.com/GameCult/Bifrost/pull/999"); try { - var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd"); - await File.WriteAllTextAsync(fakeGhPath, """ -@echo off -echo should-not-run -exit /b 0 -""", Encoding.UTF8); + var fakeGhPath = await WriteFakeGhAsync(tempDir, "should-not-run"); var environment = BuildDispatchedGitGateEnv(); environment["BIFROST_REAL_GH"] = fakeGhPath; - var blocked = await RunCommandAsync( - "powershell", - ["-NoProfile", "-Command", "gh pr comment 42 --body blocked"], + var blocked = await RunShellAsync( + "gh pr comment 42 --body blocked", RepoRoot, environment); @@ -1188,19 +1180,13 @@ exit /b 0 try { - var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd"); - await File.WriteAllTextAsync(fakeGhPath, """ -@echo off -echo should-not-run -exit /b 0 -""", Encoding.UTF8); + var fakeGhPath = await WriteFakeGhAsync(tempDir, "should-not-run"); var environment = BuildDispatchedGitGateEnv(); environment["BIFROST_REAL_GH"] = fakeGhPath; - var blocked = await RunCommandAsync( - "powershell", - ["-NoProfile", "-Command", "gh -R GameCult/Bifrost pr comment 42 --body blocked"], + var blocked = await RunShellAsync( + "gh -R GameCult/Bifrost pr comment 42 --body blocked", RepoRoot, environment); @@ -1222,19 +1208,13 @@ exit /b 0 try { - var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd"); - await File.WriteAllTextAsync(fakeGhPath, """ -@echo off -echo should-not-run -exit /b 0 -""", Encoding.UTF8); + var fakeGhPath = await WriteFakeGhAsync(tempDir, "should-not-run"); var environment = BuildDispatchedGitGateEnv(); environment["BIFROST_REAL_GH"] = fakeGhPath; - var blocked = await RunCommandAsync( - "powershell", - ["-NoProfile", "-Command", "gh workflow run ci.yml"], + var blocked = await RunShellAsync( + "gh workflow run ci.yml", RepoRoot, environment); @@ -1256,19 +1236,13 @@ exit /b 0 try { - var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd"); - await File.WriteAllTextAsync(fakeGhPath, """ -@echo off -echo read-only-ok -exit /b 0 -""", Encoding.UTF8); + var fakeGhPath = await WriteFakeGhAsync(tempDir, "read-only-ok"); var environment = BuildDispatchedGitGateEnv(); environment["BIFROST_REAL_GH"] = fakeGhPath; - var allowed = await RunCommandAsync( - "powershell", - ["-NoProfile", "-Command", "gh -R GameCult/Bifrost pr view 42"], + var allowed = await RunShellAsync( + "gh -R GameCult/Bifrost pr view 42", RepoRoot, environment); @@ -1449,27 +1423,77 @@ import('./tools/bifrost-crossing-documents.mjs') private static Dictionary BuildDispatchedGitHookEnv() { var hooksPath = Path.Combine(RepoRoot, "tools", "git-hooks"); + var nodePath = ResolveExecutable("node"); var gatePath = Path.Combine(RepoRoot, "tools", "git-gate"); - var path = Environment.GetEnvironmentVariable("Path") - ?? Environment.GetEnvironmentVariable("PATH") - ?? string.Empty; - var nextPath = string.IsNullOrWhiteSpace(path) - ? gatePath - : $"{gatePath};{path}"; - return new Dictionary + var environment = new Dictionary { ["BIFROST_ENFORCE_GITHUB_GATE"] = "true", - ["BIFROST_GIT_EXECUTABLE"] = Path.Combine(gatePath, "git.cmd"), - ["BIFROST_GH_EXECUTABLE"] = Path.Combine(gatePath, "gh.cmd"), - ["BIFROST_NODE_EXECUTABLE"] = ResolveExecutable("node"), + ["BIFROST_NODE_EXECUTABLE"] = nodePath, ["GIT_CONFIG_COUNT"] = "1", ["GIT_CONFIG_KEY_0"] = "core.hooksPath", ["GIT_CONFIG_VALUE_0"] = hooksPath, - ["PATH"] = nextPath, - ["Path"] = nextPath, }; + + var path = Environment.GetEnvironmentVariable("Path") + ?? Environment.GetEnvironmentVariable("PATH") + ?? string.Empty; + + if (OperatingSystem.IsWindows()) + { + // The checked-in gate wrappers are .cmd files that PATH lookup finds as `git` and `gh`. + var nextPath = string.IsNullOrWhiteSpace(path) ? gatePath : $"{gatePath};{path}"; + environment["BIFROST_GIT_EXECUTABLE"] = Path.Combine(gatePath, "git.cmd"); + environment["BIFROST_GH_EXECUTABLE"] = Path.Combine(gatePath, "gh.cmd"); + environment["PATH"] = nextPath; + environment["Path"] = nextPath; + return environment; + } + + // Non-Windows has no .cmd wrappers: stand in for them with sh shims that run the same + // Node proxies, placed first on PATH. The proxies get the real git by explicit path so + // the shim cannot resolve itself. + var shimDir = Path.Combine(Path.GetTempPath(), $"bifrost-gate-shims-{Guid.NewGuid():N}"); + Directory.CreateDirectory(shimDir); + var gitShim = WriteShim(shimDir, "git", nodePath, Path.Combine(gatePath, "git-proxy.mjs")); + var ghShim = WriteShim(shimDir, "gh", nodePath, Path.Combine(gatePath, "gh-proxy.mjs")); + environment["BIFROST_REAL_GIT"] = ResolveExecutable("git"); + environment["BIFROST_GIT_EXECUTABLE"] = gitShim; + environment["BIFROST_GH_EXECUTABLE"] = ghShim; + environment["PATH"] = string.IsNullOrEmpty(path) ? shimDir : $"{shimDir}{Path.PathSeparator}{path}"; + return environment; } + private static string WriteShim(string directory, string name, string nodePath, string script) + { + var shim = Path.Combine(directory, name); + File.WriteAllText(shim, $"#!/bin/sh\nexec '{nodePath}' '{script}' \"$@\"\n", new UTF8Encoding(false)); + File.SetUnixFileMode(shim, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return shim; + } + + private static async Task WriteFakeGhAsync(string directory, string output) + { + if (OperatingSystem.IsWindows()) + { + var cmd = Path.Combine(directory, "fake-gh.cmd"); + await File.WriteAllTextAsync(cmd, $"@echo off\r\necho {output}\r\nexit /b 0\r\n", Encoding.UTF8); + return cmd; + } + + var script = Path.Combine(directory, "fake-gh.sh"); + await File.WriteAllTextAsync(script, $"#!/bin/sh\necho {output}\nexit 0\n", new UTF8Encoding(false)); + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return script; + } + + private static Task RunShellAsync( + string command, + string workingDirectory, + IReadOnlyDictionary environment) + => OperatingSystem.IsWindows() + ? RunCommandAsync("powershell", ["-NoProfile", "-Command", command], workingDirectory, environment) + : RunCommandAsync("sh", ["-c", command], workingDirectory, environment); + private static Dictionary BuildDispatchedGitGateEnv() => BuildDispatchedGitHookEnv(); private static string ResolveExecutable(string command) @@ -1478,7 +1502,7 @@ import('./tools/bifrost-crossing-documents.mjs') { StartInfo = new ProcessStartInfo { - FileName = "where.exe", + FileName = OperatingSystem.IsWindows() ? "where.exe" : "which", WorkingDirectory = RepoRoot, RedirectStandardOutput = true, RedirectStandardError = true, From 420a4dbf85141cb857a542a72513d6788e47367e Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 12:28:39 +0000 Subject: [PATCH 030/102] Mouth: follow upgrades only a plain basis; @room keeps room (#76) --- docs/bridge.md | 4 ++-- tests/persona-matrix-mouth-follow.test.mjs | 16 ++++++++++++++-- tools/persona-matrix-mouth.mjs | 10 +++++----- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/bridge.md b/docs/bridge.md index 5872f22..b4cf73c 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -255,13 +255,13 @@ Golden bytes: `tests/fixtures/persona-chat-v5/` (`chat_pending.v3.tail` carries ### Follow (`basis` `"follow"`, `CHAT_FOLLOW=1`) -A message from someone she is in conversation with is for her even when it names no one. **It is off unless the mouth runs with `CHAT_FOLLOW=1`** (exactly `1`; default off), because Huginn's parser refuses an unknown basis until its `Basis::Follow` (and the one `is_for` line) lands. With the flag off the mouth emits no `follow`, makes no extra read, and keeps no follow state. +A message from someone she is in conversation with is for her even when it names no one. **It is off unless the mouth runs with `CHAT_FOLLOW=1`** (exactly `1`; default off), because the basis is Huginn's `Basis::FollowUp`, wire string `follow` (`FOLLOW_UP_BASIS`, f09bc56e), and a Huginn that predates it refuses the string. With the flag off the mouth emits no `follow`, makes no extra read, and keeps no follow state. With it on: - **Anchor.** Her own post in a public room anchors that room for W = 600 s from the post. The mouth anchors at send time (the send's own time, in `send`, in the background: the send receipt never waits on the room-state read) and also from the /sync echo of her own events, so a post from another client of her account anchors too; one event anchors once. Only her posting again re-anchors the room and re-snapshots who is counted; a follow never extends the window. - **Counted senders** (snapshotted at the post): those who spoke in that room in the 10 minutes before the post (a per-room ring of recent senders with event times, 200 entries), plus anyone the post tagged (`m.mentions.user_ids`) or replied to (the author of the quoted `m.in_reply_to`, not a thread fallback pointer) when the event carries it. Someone who first speaks after her post is not counted. A member who spoke in the 10 minutes before her post is counted, bounded by 3 follows per hour per (peer, room). Nothing reads message text; no ids are scraped from bodies. She herself, and bridged senders (the `platformEvent` identifier rule), never count. -- **Follow.** A `message` (not an edit, not a notice) from a counted sender gets `follow` when it would otherwise have no basis or `room`; `tag`, `reply` and `direct` are never replaced. Its timestamp must lie inside the window (a homeserver clock up to 60 s behind still follows) and this process's clock must not be past the window's end by more than 60 s, so an old or forged `origin_server_ts` reuses nothing. At most K = 3 per (Mind, peer, room) in the hour before the message; past it the message keeps the basis it had. +- **Follow.** A `message` (not an edit, not a notice) from a counted sender gets `follow` only when it would otherwise have no basis (a plain message); `room` (@room), `tag`, `reply` and `direct` keep their own basis, and an @room neither wakes via follow nor spends the peer's K. Its timestamp must lie inside the window (a homeserver clock up to 60 s behind still follows) and this process's clock must not be past the window's end by more than 60 s, so an old or forged `origin_server_ts` reuses nothing. At most K = 3 per (Mind, peer, room) in the hour before the message; past it the message keeps the basis it had. - **"Public" means non-DM, non-encrypted, non-bridged.** The room is not one of her DMs and its state has no `m.room.encryption`, `m.bridge` or `uk.half-shot.bridge` (read once per room, kept 5 minutes; unreadable or malformed state is not public). An invite-only plaintext room counts as public. Nothing is anchored in, and no follow is delivered in, any other room. State is in memory only (per-room anchor, sender ring, follow times), purged on every sync pass; nothing is stored, and a restart only shortens windows. diff --git a/tests/persona-matrix-mouth-follow.test.mjs b/tests/persona-matrix-mouth-follow.test.mjs index c5bd5f2..c1a705d 100644 --- a/tests/persona-matrix-mouth-follow.test.mjs +++ b/tests/persona-matrix-mouth-follow.test.mjs @@ -176,7 +176,7 @@ test("follow: at most three an hour per room and peer, counted independently; a assert.deepEqual(got, { $k0: null, $l0: null, $ko0: null, $k1: "follow", $ko1: "follow", $k2: "follow", $k3: "follow", $k4: null, $l4: "follow", $k30: null, $k31: null, $k60: null, $k62: "follow" }); }); -test("follow: an edit and a notice are never follow; a room-basis message from a counted sender is; a tag keeps its own basis", async () => { +test("follow: an edit and a notice are never follow; a room-basis message from a counted sender stays room; a tag keeps its own basis", async () => { const w = world({ rounds: [ { [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [mine("$mine", T0)] }, @@ -187,7 +187,7 @@ test("follow: an edit and a notice are never follow; a room-basis message from a msg("$tag", KIKO, T0 + SEC, { "m.mentions": { user_ids: [ME] } }), ] }, ] }); - assert.deepEqual((await run(w, 3))[2].sort(), [["$edit", null], ["$notice", null], ["$room", "follow"], ["$tag", "tag"]]); + assert.deepEqual((await run(w, 3))[2].sort(), [["$edit", null], ["$notice", null], ["$room", "room"], ["$tag", "tag"]]); }); test("follow: flag off emits nothing new and reads nothing extra; a fresh mouth has no engagement", async () => { @@ -376,3 +376,15 @@ test("follow: an echo that arrives while the send's anchor is still reading wait assert.equal(w.seen.filter(l => l.endsWith("/state")).length, 1); assert.equal(w.mouth.followState().engagements[0].from, T0, "anchored at the send's time, not the echo's"); }); + +test("follow: an @room from a followed peer stays room and spends none of the cap; plain messages still upgrade to follow", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, + { [ROOM]: [mine("$mine", T0)] }, + { [ROOM]: [1, 2, 3, 4].map(n => msg(`$r${n}`, KIKO, T0 + n * SEC, { "m.mentions": { room: true } })) }, + { [ROOM]: [1, 2, 3].map(n => msg(`$p${n}`, KIKO, T0 + 10 * SEC + n)) }, + ] }); + const out = await run(w, 4); + assert.deepEqual(out[2], [["$r1", "room"], ["$r2", "room"], ["$r3", "room"], ["$r4", "room"]]); + assert.deepEqual(out[3], [["$p1", "follow"], ["$p2", "follow"], ["$p3", "follow"]]); // all three of K still unspent +}); diff --git a/tools/persona-matrix-mouth.mjs b/tools/persona-matrix-mouth.mjs index 77b45e2..0964830 100644 --- a/tools/persona-matrix-mouth.mjs +++ b/tools/persona-matrix-mouth.mjs @@ -25,8 +25,8 @@ // // Follow (CHAT_FOLLOW=1, default off; docs/bridge.md "Follow"): her own send in a public room (not a DM, not encrypted, not bridged) anchors the room // for 10 minutes; those who spoke there in the 10 minutes before it, and whoever it tagged or replied to, get basis "follow" on their later messages -// inside the window, at most 3 an hour per peer and room. Memory only. Off, no basis "follow" is ever emitted: Huginn's parser refuses it until its -// Basis::Follow lands. +// inside the window, at most 3 an hour per peer and room; only a plain message (no basis) is upgraded, an @room keeps `room`. Memory only. Off, no +// basis "follow" is ever emitted. Huginn's variant is Basis::FollowUp, wire string "follow" (FOLLOW_UP_BASIS, f09bc56e). // // Media: an m.image/m.file/m.audio/m.video event is delivered as one typed line (see bodyText), not its bare filename. // An m.image event in a delivered message is also fetched, as this Persona and only when an image store is configured @@ -137,7 +137,7 @@ export function createMouth({ persona, homeserver, serverName, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake, now = Date.now, log = () => {}, syncTimeoutMs = 30000, maxPending = MAX_PENDING, gate = createRoomGate(), sendTimeoutMs = SEND_TIMEOUT_MS, presenceGraceMs = PRESENCE_GRACE_MS, fetchDeadlineMs = FETCH_DEADLINE_MS, - follow = false, // the follow basis (CHAT_FOLLOW): an untagged message from someone she tagged or replied to, within FOLLOW_WINDOW_MS of her own post, is for her + follow = false, // the follow basis (CHAT_FOLLOW): a plain (no-basis) message from someone she tagged or replied to, within FOLLOW_WINDOW_MS of her own post, is for her replyLink = false, // resolve each reply's quoted event into delivery.replyTo (the v4 answer; one more event read per reply to a non-Mind) images = null, // this Persona's image store (createImageStore), or null: images stay the typed text line isServed = async () => false, // (async) whether a Matrix user id is an account this process speaks for: a Mind is not a person to open a DM with @@ -580,7 +580,7 @@ export function createMouth({ if (!baseline && !freshRooms.has(roomId) && event.sender === selfId) await noteOwnPost(roomId, event); noteSpeaker(roomId, event); let basis = deliverable ? await basisOf(roomId, event) : null; - if (deliverable && (basis === null || basis === "room") && await followed(roomId, event)) basis = "follow"; + if (deliverable && basis === null && await followed(roomId, event)) basis = "follow"; const replyTo = deliverable ? await replyToOf(roomId, event) : null; processed.add(event.event_id); if (deliverable) { @@ -1185,7 +1185,7 @@ export function loadRuntime(cultRoot) { export const replyLinkFromEnv = env => env.CHAT_PENDING_V4 === "1"; // The fetch flag, the same way: on only for exactly "1". Off, the fetch request kind is an unknown kind. export const fetchFromEnv = env => env.CHAT_FETCH === "1"; -// The follow flag, the same way. Off, the mouth never emits basis "follow" (Huginn's parser refuses an unknown basis until its Basis::Follow lands). +// The follow flag, the same way. Off, the mouth never emits basis "follow" (Huginn's variant is Basis::FollowUp, wire string "follow", FOLLOW_UP_BASIS f09bc56e). export const followFromEnv = env => env.CHAT_FOLLOW === "1"; function required(env, name) { From b80b27da0f63526229fc3054e82599b180b5de5d Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 12:55:35 +0000 Subject: [PATCH 031/102] Linux path fixes: containment by path.relative, hostile-cursor test, verify prerequisites (#77) --- README.md | 2 +- .../ConceptRankedExportTests.cs | 3 +- tests/path-within.test.mjs | 50 +++++++++++++++++++ tools/bifrost-bridge.mjs | 3 +- tools/git-gate/gh-proxy.mjs | 7 ++- tools/git-gate/git-proxy.mjs | 7 ++- tools/path-within.mjs | 15 ++++++ 7 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 tests/path-within.test.mjs create mode 100644 tools/path-within.mjs diff --git a/README.md b/README.md index 4994f86..8aaa457 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ For the full staged roadmap, including contribution points, revenue share, patro - set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production - default connection string points at local PostgreSQL - build with `dotnet build Bifrost.slnx` -- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed +- test with `dotnet test Bifrost.slnx`; the suite needs a real .NET 8 runtime (`DOTNET_ROOT`, with a runsettings `DotNetHostPath` if the SDK is newer; .NET 10 roll-forward makes Mvc.Testing 8.0.0 return 500 on every JSON response) and a built CultLib TypeScript runtime (`dist/` and `node_modules`) at `$VOIDBOT_CULTLIB_ROOT` - dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true` - print the Eve provider advertisement with `node tools/provider-advertisement.mjs print` - export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc` diff --git a/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs b/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs index c6ebd74..77df76f 100644 --- a/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs @@ -210,7 +210,8 @@ public sealed class ConceptRankedExportTests : IClassFixture { + assert.equal(isWithin(f.root, f.inside, opts), true); + assert.equal(isWithin(f.root, f.root, opts), true); + assert.equal(isWithin(f.root, f.root + f.sep, opts), true); + }); + + test(`${f.name}: a sibling sharing the root as a name prefix is refused`, () => { + assert.equal(isWithin(f.root, f.sibling, opts), false); + }); + + test(`${f.name}: a dot-dot escape and an unrelated absolute path are refused`, () => { + assert.equal(isWithin(f.root, f.root + f.sep + ["..", "x"].join(f.sep), opts), false); + assert.equal(isWithin(f.root, f.other, opts), false); + assert.equal(isWithin(f.root, f.root + f.sep + "..", opts), false); + }); + + test(`${f.name}: a name that merely starts with two dots is inside`, () => { + assert.equal(isWithin(f.root, f.root + f.sep + "..cache" + f.sep + "x", opts), true); + }); + + test(`${f.name}: excluded dir, strictly beneath it vs not`, () => { + const excluded = f.root + f.sep + "tools" + f.sep + "git-gate"; + const strict = { ...opts, allowSelf: false }; + assert.equal(isWithin(excluded, excluded + f.sep + "git.cmd", strict), true); + assert.equal(isWithin(excluded, excluded, strict), false); + assert.equal(isWithin(excluded, excluded + "-other" + f.sep + "git", strict), false); + assert.equal(isWithin(excluded, f.root + f.sep + "bin" + f.sep + "git", strict), false); + }); +} + +test("win32: comparison ignores case, as Windows paths do", () => { + assert.equal(isWithin("C:\\Work\\Repo", "c:\\work\\repo\\SRC\\a.txt", { pathApi: path.win32 }), true); + assert.equal(isWithin("C:\\Work\\Repo", "c:\\work\\repo-evil\\a.txt", { pathApi: path.win32 }), false); +}); + +test("posix: comparison is case-sensitive", () => { + assert.equal(isWithin("/srv/repo", "/srv/Repo/a.txt", { pathApi: path.posix }), false); +}); diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index e51b2f0..c0122b9 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -4,6 +4,7 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { isWithin } from "./path-within.mjs"; import { beginCrossingReceipt, crossingProvenanceFromOptions, @@ -182,7 +183,7 @@ async function createGitHubDraftPr(options) { } const targetPath = resolve(repoRoot, relativePath); - if (!targetPath.toLowerCase().startsWith(`${repoRoot.toLowerCase()}\\`) && targetPath.toLowerCase() !== repoRoot.toLowerCase()) { + if (!isWithin(repoRoot, targetPath)) { throw new Error(`Target path escapes repo root: ${relativePath}`); } diff --git a/tools/git-gate/gh-proxy.mjs b/tools/git-gate/gh-proxy.mjs index 94e05aa..896563d 100644 --- a/tools/git-gate/gh-proxy.mjs +++ b/tools/git-gate/gh-proxy.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; +import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { isWithin } from "../path-within.mjs"; const args = process.argv.slice(2); const wrapperDir = dirname(fileURLToPath(import.meta.url)); @@ -90,9 +91,7 @@ function resolveExecutable(command, excludedDir) { } function isWithinExcludedDir(candidate, excludedDir) { - const normalizedCandidate = resolve(candidate).toLowerCase(); - const normalizedExcludedDir = `${resolve(excludedDir).toLowerCase()}\\`; - return normalizedCandidate.startsWith(normalizedExcludedDir); + return isWithin(excludedDir, candidate, { allowSelf: false }); } function isWindowsCommandScript(command) { diff --git a/tools/git-gate/git-proxy.mjs b/tools/git-gate/git-proxy.mjs index 71b6ef2..902b787 100644 --- a/tools/git-gate/git-proxy.mjs +++ b/tools/git-gate/git-proxy.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; +import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { isWithin } from "../path-within.mjs"; const args = process.argv.slice(2); const wrapperDir = dirname(fileURLToPath(import.meta.url)); @@ -59,9 +60,7 @@ function resolveExecutable(command, excludedDir) { } function isWithinExcludedDir(candidate, excludedDir) { - const normalizedCandidate = resolve(candidate).toLowerCase(); - const normalizedExcludedDir = `${resolve(excludedDir).toLowerCase()}\\`; - return normalizedCandidate.startsWith(normalizedExcludedDir); + return isWithin(excludedDir, candidate, { allowSelf: false }); } function findGitSubcommand(input) { diff --git a/tools/path-within.mjs b/tools/path-within.mjs new file mode 100644 index 0000000..31219a3 --- /dev/null +++ b/tools/path-within.mjs @@ -0,0 +1,15 @@ +import path from "node:path"; + +// True when `candidate` is `root` itself (only if allowSelf) or lies beneath it. +// Containment is path.relative-based, so it follows the host's separator and +// case rules; pass path.win32 or path.posix to pin a flavour. +export function isWithin(root, candidate, { pathApi = path, allowSelf = true } = {}) { + const relative = pathApi.relative(pathApi.resolve(root), pathApi.resolve(candidate)); + if (relative === "") { + return allowSelf; + } + if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`)) { + return false; + } + return true; +} From 95d2e8d2629c04bfba723f0d1727463d615b6fcb Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 13:24:59 +0000 Subject: [PATCH 032/102] Read the Heimdall app secret from Heimdall__AppSecretFile; env value deprecated The file wins, is read once when the options are built, and a set-but-missing, unreadable or empty file stops startup in every environment without naming the secret. Heimdall__AppSecret still works and logs a deprecation warning. Co-Authored-By: Claude Sonnet 5.5 --- .../Configuration/HeimdallOptions.cs | 39 ++++- .../Shared/StartupConfigurationValidator.cs | 3 + src/Bifrost.Web/Program.cs | 3 +- src/Bifrost.Web/appsettings.json | 1 + .../HeimdallAppSecretFileTests.cs | 157 ++++++++++++++++++ 5 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs diff --git a/src/Bifrost.Web/Configuration/HeimdallOptions.cs b/src/Bifrost.Web/Configuration/HeimdallOptions.cs index 01eaec9..2dda782 100644 --- a/src/Bifrost.Web/Configuration/HeimdallOptions.cs +++ b/src/Bifrost.Web/Configuration/HeimdallOptions.cs @@ -14,7 +14,11 @@ public sealed class HeimdallOptions public string PatreonTierTitle { get; init; } = "Inner Sanctum"; - public string AppSecret { get; init; } = string.Empty; + /// Deprecated: rides in the process environment. Use ; the file wins when both are set. + public string AppSecret { get; set; } = string.Empty; + + /// Path of a file holding the app secret (systemd LoadCredential style: %d/heimdall-app-secret). + public string AppSecretFile { get; init; } = string.Empty; public string PatronSupportIntakeSecret { get; init; } = string.Empty; @@ -39,4 +43,37 @@ public sealed class HeimdallOptions IsConfigured && EnablePatronSupportIntake && !string.IsNullOrWhiteSpace(PatronSupportIntakeSecret); + + /// + /// Resolves once, when the options are built. A set must be a + /// readable, non-empty file, or startup fails; the message names the option and path, never a secret. + /// + public static void ResolveAppSecret(HeimdallOptions options, ILogger logger) + { + if (!string.IsNullOrWhiteSpace(options.AppSecret)) + { + logger.LogWarning("Heimdall:AppSecret is deprecated: it rides in the process environment. Use Heimdall:AppSecretFile, which wins when both are set."); + } + + if (!string.IsNullOrWhiteSpace(options.AppSecretFile)) + { + string secret; + try + { + secret = File.ReadAllText(options.AppSecretFile).TrimEnd('\r', '\n'); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + throw new InvalidOperationException( + $"Heimdall:AppSecretFile must name a readable file; '{options.AppSecretFile}' could not be read ({error.GetType().Name})."); + } + + if (string.IsNullOrWhiteSpace(secret)) + { + throw new InvalidOperationException($"Heimdall:AppSecretFile '{options.AppSecretFile}' is empty."); + } + + options.AppSecret = secret; + } + } } diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index a5d0752..2ab16cf 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -22,6 +22,9 @@ public sealed class StartupConfigurationValidator( { var failures = new List(); + // Building the options resolves Heimdall:AppSecretFile, and a bad file stops startup in every environment. + _ = heimdallOptions.Value; + // Checked in every environment: a bad value here would otherwise pass startup and throw on every cast. var conceptVotes = conceptVoteOptions.Value; if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index c7e4587..be3df54 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -64,7 +64,8 @@ builder.Services builder.Services .AddOptions() - .BindConfiguration(HeimdallOptions.SectionName); + .BindConfiguration(HeimdallOptions.SectionName) + .PostConfigure>(HeimdallOptions.ResolveAppSecret); builder.Services .AddOptions() diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json index 47bbb7b..09b3310 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -28,6 +28,7 @@ "BaseUrl": "https://heimdall.gamecult.org", "AppSlug": "bifrost", "AppSecret": "", + "AppSecretFile": "", "DiscordGuildId": "", "DiscordAllowedRoleIds": [], "PatreonTierTitle": "Inner Sanctum", diff --git a/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs b/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs new file mode 100644 index 0000000..5e3bca6 --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs @@ -0,0 +1,157 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +public sealed class HeimdallAppSecretFileTests : IDisposable +{ + private const string FileSecret = "file-secret-7f3a91c2"; + private const string EnvSecret = "env-secret-0b5d44e8"; + + private readonly string _directory = Directory.CreateTempSubdirectory("bifrost-appsecret-").FullName; + private readonly CapturingLoggerProvider _logs = new(); + + public void Dispose() => Directory.Delete(_directory, recursive: true); + + private string Write(string content) + { + var path = Path.Combine(_directory, Guid.NewGuid().ToString("N")); + File.WriteAllText(path, content); + return path; + } + + private HeimdallOptions Resolve(string appSecret = "", string appSecretFile = "") + { + var options = new HeimdallOptions { AppSecret = appSecret, AppSecretFile = appSecretFile }; + HeimdallOptions.ResolveAppSecret(options, _logs.CreateLogger("test")); + return options; + } + + private void AssertNoSecretLogged() + { + foreach (var line in _logs.Lines) + { + Assert.DoesNotContain(FileSecret, line); + Assert.DoesNotContain(EnvSecret, line); + } + } + + [Theory] + [InlineData("")] + [InlineData("\n")] + [InlineData("\r\n")] + public void The_file_is_the_secret_with_only_the_trailing_newline_trimmed(string ending) + { + var options = Resolve(appSecretFile: Write(FileSecret + ending)); + + Assert.Equal(FileSecret, options.AppSecret); + Assert.False(options.IsDiscordConfigured); // no guild configured + Assert.True(options.IsPatreonConfigured); + Assert.Empty(_logs.Lines); + } + + [Fact] + public void Inner_whitespace_survives_the_trim() + { + Assert.Equal(" a b ", Resolve(appSecretFile: Write(" a b \n")).AppSecret); + } + + [Fact] + public void The_file_wins_over_the_environment_value_and_the_deprecation_is_logged() + { + var options = Resolve(EnvSecret, Write(FileSecret + "\n")); + + Assert.Equal(FileSecret, options.AppSecret); + Assert.Contains(_logs.Lines, line => line.Contains("deprecated")); + AssertNoSecretLogged(); + } + + [Fact] + public void A_missing_file_fails_closed_naming_option_and_path_but_no_secret() + { + var path = Path.Combine(_directory, "absent"); + + var failure = Assert.Throws(() => Resolve(EnvSecret, path)); + + Assert.Contains("Heimdall:AppSecretFile", failure.Message); + Assert.Contains(path, failure.Message); + Assert.DoesNotContain(EnvSecret, failure.Message); + } + + [Fact] + public void A_directory_in_place_of_the_file_fails_closed() + { + Assert.Throws(() => Resolve(appSecretFile: _directory)); + } + + [Theory] + [InlineData("")] + [InlineData("\n")] + [InlineData(" \r\n")] + public void An_empty_file_fails_closed_even_when_the_environment_value_is_set(string content) + { + var path = Write(content); + + var failure = Assert.Throws(() => Resolve(EnvSecret, path)); + + Assert.Contains("Heimdall:AppSecretFile", failure.Message); + Assert.Contains(path, failure.Message); + Assert.DoesNotContain(EnvSecret, failure.Message); + } + + [Fact] + public void The_environment_value_alone_still_works_and_warns_without_logging_it() + { + var options = Resolve(EnvSecret); + + Assert.Equal(EnvSecret, options.AppSecret); + Assert.Single(_logs.Lines, line => line.Contains("deprecated") && line.Contains("Heimdall:AppSecretFile")); + AssertNoSecretLogged(); + } + + [Fact] + public void Neither_set_is_unconfigured_as_before_and_silent() + { + var options = Resolve(); + + Assert.Equal(string.Empty, options.AppSecret); + Assert.False(options.IsPatreonConfigured); + Assert.Empty(_logs.Lines); + } + + private TestWebApplicationFactory Host(string file) => new TestHost(file, _logs); + + private sealed class TestHost(string file, CapturingLoggerProvider logs) : TestWebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary { ["Heimdall:AppSecretFile"] = file })); + builder.ConfigureLogging(logging => logging.AddProvider(logs)); + } + } + + [Fact] + public void The_running_host_resolves_the_file_into_its_options() + { + using var host = Host(Write(FileSecret + "\n")); + + Assert.Equal(FileSecret, host.Services.GetRequiredService>().Value.AppSecret); + } + + [Fact] + public void The_running_host_refuses_to_start_on_a_missing_file_in_every_environment() + { + using var host = Host(Path.Combine(_directory, "absent")); + + var failure = Assert.ThrowsAny(() => host.CreateClient()); + + Assert.Contains("Heimdall:AppSecretFile", failure.ToString()); + } +} From 25b9cc1683d9d96d2dac1ae4bd7b3c8dab18994c Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 13:24:59 +0000 Subject: [PATCH 033/102] Document Heimdall__AppSecretFile and the AppSecret deprecation Co-Authored-By: Claude Sonnet 5.5 --- docs/concept-mill-votes-target.md | 2 +- docs/deploy-main-to-live.md | 8 +++++++- docs/heimdall-app-secret-file.md | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 docs/heimdall-app-secret-file.md diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md index 30a523e..40935af 100644 --- a/docs/concept-mill-votes-target.md +++ b/docs/concept-mill-votes-target.md @@ -41,7 +41,7 @@ Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only ### Data Protection on the live host -18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*`, `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration. +18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*` (the app secret as `Heimdall__AppSecret`; `Heimdall__AppSecretFile` is the replacement), `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration. 19. The service's working directory resolves to the release directory: `readlink /proc//cwd` = `/srv/bifrost/app/releases/20260723-daedcc4`. Data Protection's default application discriminator is the content-root path (prior-art note `:67`), so every deploy into a new release directory isolates payloads protected by the previous release: auth cookies, antiforgery tokens and any future device token stop decrypting after each deploy. Mechanism from source docs plus this probe; not observed as logged-out users. `SetApplicationName` is not called anywhere (`git grep SetApplicationName bf83a93` is empty). ### Proxy and client address diff --git a/docs/deploy-main-to-live.md b/docs/deploy-main-to-live.md index 88997ec..c0f0e14 100644 --- a/docs/deploy-main-to-live.md +++ b/docs/deploy-main-to-live.md @@ -54,7 +54,13 @@ permits, windows and `MaxTrackedKeys`. New since the live release, and not read by the validator: -- **`Heimdall__AppSecret`**: required for Discord and Patreon sign-in. Without it `HeimdallOptions.IsDiscordConfigured` and +- **`Heimdall__AppSecretFile`**: the preferred way to give Bifrost the app secret below. A path to a file holding it (one + trailing newline is trimmed), for example `%d/heimdall-app-secret` under systemd `LoadCredential=`, so the secret never + rides in the process environment. When set, the file wins over `Heimdall__AppSecret`; a missing, unreadable or empty file + stops startup in every environment, with a message that names the option and path and never the value. Ops steps: + `docs/heimdall-app-secret-file.md`. +- **`Heimdall__AppSecret`** (deprecated; use `Heimdall__AppSecretFile`; startup logs a warning when it is set): required for + Discord and Patreon sign-in. Without it `HeimdallOptions.IsDiscordConfigured` and `IsPatreonConfigured` are false and those sign-ins stop working (GitHub sign-in is unaffected); startup does not fail, so a missing key shows only at the live check. The value is Heimdall's app secret for slug `bifrost` (`GC_ACCESS_APP_BIFROST_SHARED_SECRET` on the Heimdall side). Bifrost sends it as `x-heimdall-app-secret`, redacted diff --git a/docs/heimdall-app-secret-file.md b/docs/heimdall-app-secret-file.md new file mode 100644 index 0000000..063dd7d --- /dev/null +++ b/docs/heimdall-app-secret-file.md @@ -0,0 +1,17 @@ +# Heimdall app secret from a file + +`Heimdall:AppSecret` (`Heimdall__AppSecret`) is Bifrost's app secret for Heimdall slug `bifrost`; it is sent as the +`x-heimdall-app-secret` header. Set through the environment it rides in the process environment. `Heimdall:AppSecretFile` +(`Heimdall__AppSecretFile`) names a file holding it instead. + +- File set: Bifrost reads it once at startup and one trailing newline is trimmed. The file wins over `Heimdall__AppSecret`. +- File set but missing, unreadable, a directory, or empty: startup fails in every environment. The message names + `Heimdall:AppSecretFile` and the path, never the secret. +- Only `Heimdall__AppSecret` set: still works; startup logs a deprecation warning (without the value). +- Neither set: Discord and Patreon sign-in stay off, as before. + +Under systemd, pass the file with `LoadCredential=heimdall-app-secret:` and +`Environment=Heimdall__AppSecretFile=%d/heimdall-app-secret`. + +Code: `HeimdallOptions.ResolveAppSecret` (`src/Bifrost.Web/Configuration/HeimdallOptions.cs`), wired in `Program.cs`, forced at +startup by `StartupConfigurationValidator.Validate`. Tests: `tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs`. From 280d843aebc205edea04083e8d3101cf1d6a013d Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 13:46:24 +0000 Subject: [PATCH 034/102] AppSecretFile: pin the deprecation at Warning level, say trailing newlines Soul pass on 25b9cc16: the warning level was unpinned (a Trace demotion passed) and the docs said one newline where the code trims all. Co-Authored-By: Claude Sonnet 5.5 --- docs/deploy-main-to-live.md | 2 +- docs/heimdall-app-secret-file.md | 2 +- .../HeimdallAppSecretFileTests.cs | 6 +++--- .../Support/CapturingLoggerProvider.cs | 20 ++++++++++++++++++- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/deploy-main-to-live.md b/docs/deploy-main-to-live.md index c0f0e14..9e50e98 100644 --- a/docs/deploy-main-to-live.md +++ b/docs/deploy-main-to-live.md @@ -55,7 +55,7 @@ permits, windows and `MaxTrackedKeys`. New since the live release, and not read by the validator: - **`Heimdall__AppSecretFile`**: the preferred way to give Bifrost the app secret below. A path to a file holding it (one - trailing newline is trimmed), for example `%d/heimdall-app-secret` under systemd `LoadCredential=`, so the secret never + trailing newlines are trimmed), for example `%d/heimdall-app-secret` under systemd `LoadCredential=`, so the secret never rides in the process environment. When set, the file wins over `Heimdall__AppSecret`; a missing, unreadable or empty file stops startup in every environment, with a message that names the option and path and never the value. Ops steps: `docs/heimdall-app-secret-file.md`. diff --git a/docs/heimdall-app-secret-file.md b/docs/heimdall-app-secret-file.md index 063dd7d..2f8c283 100644 --- a/docs/heimdall-app-secret-file.md +++ b/docs/heimdall-app-secret-file.md @@ -4,7 +4,7 @@ `x-heimdall-app-secret` header. Set through the environment it rides in the process environment. `Heimdall:AppSecretFile` (`Heimdall__AppSecretFile`) names a file holding it instead. -- File set: Bifrost reads it once at startup and one trailing newline is trimmed. The file wins over `Heimdall__AppSecret`. +- File set: Bifrost reads it once at startup and trailing newlines are trimmed. The file wins over `Heimdall__AppSecret`. - File set but missing, unreadable, a directory, or empty: startup fails in every environment. The message names `Heimdall:AppSecretFile` and the path, never the secret. - Only `Heimdall__AppSecret` set: still works; startup logs a deprecation warning (without the value). diff --git a/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs b/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs index 5e3bca6..1094bb5 100644 --- a/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs @@ -45,7 +45,7 @@ public sealed class HeimdallAppSecretFileTests : IDisposable [InlineData("")] [InlineData("\n")] [InlineData("\r\n")] - public void The_file_is_the_secret_with_only_the_trailing_newline_trimmed(string ending) + public void The_file_is_the_secret_with_only_trailing_newlines_trimmed(string ending) { var options = Resolve(appSecretFile: Write(FileSecret + ending)); @@ -67,7 +67,7 @@ public sealed class HeimdallAppSecretFileTests : IDisposable var options = Resolve(EnvSecret, Write(FileSecret + "\n")); Assert.Equal(FileSecret, options.AppSecret); - Assert.Contains(_logs.Lines, line => line.Contains("deprecated")); + Assert.Contains(_logs.WarningLines, line => line.Contains("deprecated")); AssertNoSecretLogged(); } @@ -110,7 +110,7 @@ public sealed class HeimdallAppSecretFileTests : IDisposable var options = Resolve(EnvSecret); Assert.Equal(EnvSecret, options.AppSecret); - Assert.Single(_logs.Lines, line => line.Contains("deprecated") && line.Contains("Heimdall:AppSecretFile")); + Assert.Single(_logs.WarningLines, line => line.Contains("deprecated") && line.Contains("Heimdall:AppSecretFile")); AssertNoSecretLogged(); } diff --git a/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs b/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs index ea7558b..a641e7c 100644 --- a/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs +++ b/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs @@ -5,6 +5,19 @@ namespace Bifrost.Web.Tests.Support; public sealed class CapturingLoggerProvider : ILoggerProvider { private readonly List _lines = []; + private readonly List _warnings = []; + + /// Lines logged at Warning or above, to pin that an operator-facing notice reaches the journal. + public IReadOnlyList WarningLines + { + get + { + lock (_lines) + { + return _warnings.ToArray(); + } + } + } public IReadOnlyList Lines { @@ -38,7 +51,12 @@ public sealed class CapturingLoggerProvider : ILoggerProvider { lock (owner._lines) { - owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception)); + var line = formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception); + owner._lines.Add(line); + if (logLevel >= LogLevel.Warning) + { + owner._warnings.Add(line); + } } } } From c085cead9d13b7e8d9249def4bd5e10428e9130a Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 14:26:14 +0000 Subject: [PATCH 035/102] Heimdall start refusal: log the HTTP status and Heimdall's error code The journal said only 'Heimdall rejected the auth start', which cannot tell a wrong app secret (401 app_auth_required) from a fault. The message now carries the status and, when the body has one, an error code that is a short snake_case token; no other body text is echoed. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Heimdall/HeimdallAuthService.cs | 28 ++++++++++++++++++- .../HeimdallCompletionTests.cs | 24 +++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 8d10b53..e3abda2 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -53,6 +53,32 @@ public sealed class HeimdallAuthService( } } + /// + /// Names the HTTP status and, when the body carries one, Heimdall's error code. The code is shown only + /// if it is a short snake_case token, so nothing else from the body (free text, tokens) reaches a log. + /// + private static async Task RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var detail = $"HTTP {(int)response.StatusCode}"; + try + { + var body = await response.Content.ReadFromJsonAsync(cancellationToken); + if (body.ValueKind == JsonValueKind.Object + && body.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.String + && error.GetString() is { Length: > 0 and <= 64 } code + && code.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) + { + detail += ", " + code; + } + } + catch (JsonException) + { + } + + return $"Heimdall rejected the auth start ({detail})."; + } + public async Task StartAsync( string provider, string callbackBaseUrl, @@ -91,7 +117,7 @@ public sealed class HeimdallAuthService( if (!response.IsSuccessStatusCode) { - throw new HeimdallAuthException("Heimdall rejected the auth start."); + throw new HeimdallAuthException(await RefusedStartMessageAsync(response, cancellationToken)); } var payload = await response.Content.ReadFromJsonAsync(cancellationToken); diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index afcfcba..f7e40de 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -1115,7 +1115,7 @@ public sealed class HeimdallCompletionTests } [Theory] - [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start.")] + [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start (HTTP 500).")] [InlineData(HttpStatusCode.OK, "{}", "Heimdall auth start returned no authorization URL.")] [InlineData(HttpStatusCode.OK, "{\"authorizationUrl\":\" \"}", "Heimdall auth start returned no authorization URL.")] public async Task A_start_Heimdall_cannot_serve_issues_no_cookie_and_a_fixed_refusal(HttpStatusCode status, string body, string reason) @@ -1132,6 +1132,28 @@ public sealed class HeimdallCompletionTests Assert.Contains("Heimdall start refused: " + reason, string.Join("\n", host.Logs.Lines)); } + [Theory] + [InlineData(HttpStatusCode.Unauthorized, """{"error":"app_auth_required"}""", "Heimdall rejected the auth start (HTTP 401, app_auth_required).")] + [InlineData(HttpStatusCode.BadRequest, """{"error":"provider_not_supported_for_app","detail":"free text"}""", "Heimdall rejected the auth start (HTTP 400, provider_not_supported_for_app).")] + [InlineData(HttpStatusCode.Unauthorized, """{"error":"Not a code CANARY secret=abc"}""", "Heimdall rejected the auth start (HTTP 401).")] + [InlineData(HttpStatusCode.Unauthorized, """{"error":{"nested":"app_auth_required"}}""", "Heimdall rejected the auth start (HTTP 401).")] + [InlineData(HttpStatusCode.Unauthorized, "not json at all", "Heimdall rejected the auth start (HTTP 401).")] + public async Task A_refused_start_logs_the_status_and_a_well_formed_error_code_and_nothing_else_from_the_body( + HttpStatusCode status, string body, string reason) + { + using var host = new HeimdallHost(); + host.Heimdall.StartStatus = status; + host.Heimdall.StartBody = body; + + var attempt = await host.StartAsync(member: true); + + Assert.Equal(ErrorRedirect, attempt.Response.Headers.Location!.OriginalString); + var logs = string.Join("\n", host.Logs.Lines); + Assert.Contains("Heimdall start refused: " + reason, logs); + Assert.DoesNotContain("CANARY", logs); + Assert.DoesNotContain("free text", logs); + } + [Fact] public async Task Refusals_echo_no_input_in_responses_or_logs() { From 87a1bb6b3b4f1cd552ffa84f55e21fc42d37ea41 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 14:41:14 +0000 Subject: [PATCH 036/102] Start refusal: bound the body read, survive undecodable bodies, pin the code length cap Soul pass on c085cead: a refusal with an unsupported charset escaped as a 500, the body was read without a bound, and the 64-character cap was untested. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Heimdall/HeimdallAuthService.cs | 23 +++++++--- .../HeimdallCompletionTests.cs | 43 +++++++++++++++++++ .../Support/HeimdallTestKit.cs | 10 +++-- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index e3abda2..b34a21d 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -53,18 +53,29 @@ public sealed class HeimdallAuthService( } } + private const int RefusalBodyLimit = 4096; + /// - /// Names the HTTP status and, when the body carries one, Heimdall's error code. The code is shown only - /// if it is a short snake_case token, so nothing else from the body (free text, tokens) reaches a log. + /// Names the HTTP status and, when the first 4 KiB of the body carry one, Heimdall's error code. The code + /// is shown only if it is a short snake_case token, so nothing else from the body (free text, tokens) + /// reaches a log. A body that cannot be read or parsed leaves the status alone: a refusal never becomes a 500. /// private static async Task RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var detail = $"HTTP {(int)response.StatusCode}"; try { - var body = await response.Content.ReadFromJsonAsync(cancellationToken); - if (body.ValueKind == JsonValueKind.Object - && body.TryGetProperty("error", out var error) + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var head = new byte[RefusalBodyLimit]; + var length = 0; + while (length < head.Length && await stream.ReadAsync(head.AsMemory(length), cancellationToken) is > 0 and var read) + { + length += read; + } + + using var body = JsonDocument.Parse(head.AsMemory(0, length)); + if (body.RootElement.ValueKind == JsonValueKind.Object + && body.RootElement.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.String && error.GetString() is { Length: > 0 and <= 64 } code && code.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) @@ -72,7 +83,7 @@ public sealed class HeimdallAuthService( detail += ", " + code; } } - catch (JsonException) + catch (Exception) when (!cancellationToken.IsCancellationRequested) { } diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index f7e40de..da9463a 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -1154,6 +1154,49 @@ public sealed class HeimdallCompletionTests Assert.DoesNotContain("free text", logs); } + [Fact] + public async Task A_code_longer_than_sixty_four_characters_is_not_logged() + { + using var host = new HeimdallHost(); + host.Heimdall.StartStatus = HttpStatusCode.Unauthorized; + host.Heimdall.StartBody = "{\"error\":\"" + new string('a', 65) + "\"}"; + + await host.StartAsync(member: true); + + var logs = string.Join("\n", host.Logs.Lines); + Assert.Contains("Heimdall start refused: Heimdall rejected the auth start (HTTP 401).", logs); + Assert.DoesNotContain(new string('a', 65), logs); + } + + [Theory] + [InlineData("text/html; charset=windows-1252")] + [InlineData("text/html; charset=bogus")] + [InlineData("text/html; charset=utf-7")] + public async Task A_refusal_whose_body_cannot_be_decoded_still_redirects_with_the_status(string contentType) + { + using var host = new HeimdallHost(); + host.Heimdall.StartStatus = HttpStatusCode.BadGateway; + host.Heimdall.StartContentType = contentType; + host.Heimdall.StartBody = "bad gateway"; + + var attempt = await host.StartAsync(); + + Assert.Equal(ErrorRedirect, attempt.Response.Headers.Location!.OriginalString); + Assert.Contains("Heimdall start refused: Heimdall rejected the auth start (HTTP 502).", string.Join("\n", host.Logs.Lines)); + } + + [Fact] + public async Task Only_the_head_of_a_refusal_body_is_read() + { + using var host = new HeimdallHost(); + host.Heimdall.StartStatus = HttpStatusCode.Unauthorized; + host.Heimdall.StartBody = "{\"pad\":\"" + new string('x', 200_000) + "\",\"error\":\"app_auth_required\"}"; + + await host.StartAsync(member: true); + + Assert.Contains("Heimdall start refused: Heimdall rejected the auth start (HTTP 401).", string.Join("\n", host.Logs.Lines)); + } + [Fact] public async Task Refusals_echo_no_input_in_responses_or_logs() { diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index bd5f917..67dd4fe 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -207,6 +207,9 @@ public sealed class FakeHeimdall : HttpMessageHandler public HttpStatusCode StartStatus { get; set; } = HttpStatusCode.OK; + /// A raw Content-Type header for the start answer (for example a charset .NET cannot decode); null means application/json. + public string? StartContentType { get; set; } + public string StartBody { get; set; } = """{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}"""; @@ -263,10 +266,9 @@ public sealed class FakeHeimdall : HttpMessageHandler }; } - return new HttpResponseMessage(StartStatus) - { - Content = new StringContent(StartBody, Encoding.UTF8, "application/json") - }; + var content = new ByteArrayContent(Encoding.UTF8.GetBytes(StartBody)); + content.Headers.TryAddWithoutValidation("Content-Type", StartContentType ?? "application/json"); + return new HttpResponseMessage(StartStatus) { Content = content }; } if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/auth-completions/redeem") From 31ced94f2aecef8fb23432ec9b3a5fefaaea227d Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 14:48:30 +0000 Subject: [PATCH 037/102] Start refusal: read headers only so the 4 KiB bound limits the read; narrow the swallow to non-cancellation Soul re-pass on 87a1bb6b: PostAsJsonAsync buffered the whole body before the cap applied. Test renamed to say what it pins. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Heimdall/HeimdallAuthService.cs | 12 +++++++----- tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index b34a21d..87ffdfd 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -83,7 +83,7 @@ public sealed class HeimdallAuthService( detail += ", " + code; } } - catch (Exception) when (!cancellationToken.IsCancellationRequested) + catch (Exception error) when (error is not OperationCanceledException) { } @@ -121,10 +121,12 @@ public sealed class HeimdallAuthService( new HeimdallHandoff("browser_completion"), entitlementPolicy); - var response = await httpClient.PostAsJsonAsync( - $"/v1/oauth/{provider}/start", - request, - cancellationToken); + // Headers only: a refusal body is read through RefusedStartMessageAsync's own 4 KiB bound, not buffered whole. + using var startMessage = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{provider}/start") + { + Content = JsonContent.Create(request) + }; + using var response = await httpClient.SendAsync(startMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!response.IsSuccessStatusCode) { diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index da9463a..9f91425 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -1172,7 +1172,7 @@ public sealed class HeimdallCompletionTests [InlineData("text/html; charset=windows-1252")] [InlineData("text/html; charset=bogus")] [InlineData("text/html; charset=utf-7")] - public async Task A_refusal_whose_body_cannot_be_decoded_still_redirects_with_the_status(string contentType) + public async Task A_refusal_with_an_unusual_charset_still_redirects_with_the_status(string contentType) { using var host = new HeimdallHost(); host.Heimdall.StartStatus = HttpStatusCode.BadGateway; From 1998f0e6ae19fc104e8f33fdd1fba89e9ca21ee2 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 14:54:19 +0000 Subject: [PATCH 038/102] Start refusal: a client timeout in the body read still redirects; user cancellation propagates Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 87ffdfd..076f788 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -83,7 +83,7 @@ public sealed class HeimdallAuthService( detail += ", " + code; } } - catch (Exception error) when (error is not OperationCanceledException) + catch (Exception) when (!cancellationToken.IsCancellationRequested) { } From 95416653babfb0be4806062a63600175ad8376ba Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 15:21:40 +0000 Subject: [PATCH 039/102] Google link: tests and fake Heimdall link routes (red until the flow lands) Co-Authored-By: Claude Sonnet 5.5 --- .../Fixtures/complete-page-harness.cjs | 7 +- .../HeimdallCompletePageScriptTests.cs | 26 +- .../HeimdallGoogleLinkTests.cs | 493 ++++++++++++++++++ .../Support/HeimdallTestKit.cs | 175 ++++++- 4 files changed, 695 insertions(+), 6 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs diff --git a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs index aeaf092..4ae3eae 100644 --- a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs +++ b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs @@ -35,12 +35,13 @@ function run(name, search, hash) { }, }; const form = { - elements: { attempt: { value: "" }, code: { value: "" } }, + elements: { attempt: { value: "" }, code: { value: "" }, pendingLinkId: { value: "" } }, submit() { events.push("submit"); submit = { attempt: form.elements.attempt.value, code: form.elements.code.value, + pendingLinkId: form.elements.pendingLinkId.value, href: location.href, hash: location.hash, }; @@ -59,6 +60,10 @@ console.log( run("error-status-with-code", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied" + complete), run("no-code", "?attempt=NONCE", "#heimdall_status=success"), run("no-attempt", "", "#heimdall_status=success" + complete), + run("link-return", "?attempt=NONCE", "#heimdall_status=success&heimdall_pending_link_id=PEND1"), + run("link-return-with-decoy-query-id", "?attempt=NONCE&heimdall_pending_link_id=QPEND", "#heimdall_status=success&heimdall_pending_link_id=PEND1"), + run("link-error-with-pending-id", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied&heimdall_pending_link_id=PEND1"), + run("link-pending-id-in-query-only", "?attempt=NONCE&heimdall_pending_link_id=QPEND", ""), run("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""), ]), ); diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs index da7ad9c..ba2b1fb 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs @@ -15,7 +15,7 @@ public sealed class HeimdallCompletePageScriptTests private sealed record Run(string Name, string[] Events, Submit? Submit); - private sealed record Submit(string Attempt, string Code, string Href, string Hash); + private sealed record Submit(string Attempt, string Code, string Href, string Hash, string PendingLinkId); private static async Task> RunScenariosAsync() { @@ -81,7 +81,7 @@ public sealed class HeimdallCompletePageScriptTests { var runs = await RunScenariosAsync(); - Assert.Equal(new Submit("NONCE", "CODE123", "/auth/heimdall/complete?attempt=NONCE", ""), runs["success"].Submit); + Assert.Equal(new Submit("NONCE", "CODE123", "/auth/heimdall/complete?attempt=NONCE", "", ""), runs["success"].Submit); Assert.Equal("CODE123", runs["success-with-decoy-query-code"].Submit!.Code); var queryOnly = runs["code-in-query-only"]; @@ -90,6 +90,28 @@ public sealed class HeimdallCompletePageScriptTests Assert.Equal(ErrorTarget, queryOnly.Events[^1]); } + [Fact] + public async Task A_pending_link_id_is_read_only_from_the_fragment_and_posted_without_a_code() + { + var runs = await RunScenariosAsync(); + + foreach (var name in new[] { "link-return", "link-return-with-decoy-query-id" }) + { + var run = runs[name]; + Assert.Equal(new Submit("NONCE", "", run.Submit!.Href, "", "PEND1"), run.Submit); + Assert.DoesNotContain("PEND1", run.Submit.Href); + Assert.StartsWith("replaceState:", run.Events[0]); + Assert.DoesNotContain("PEND1", run.Events[0]); + Assert.DoesNotContain("#", run.Events[0]); + Assert.Equal("submit", run.Events[1]); + } + + Assert.Null(runs["link-pending-id-in-query-only"].Submit); + Assert.Equal(ErrorTarget, runs["link-pending-id-in-query-only"].Events[^1]); + Assert.Null(runs["link-error-with-pending-id"].Submit); + Assert.Equal(ErrorTarget, runs["link-error-with-pending-id"].Events[^1]); + } + [Theory] [InlineData("error-status-with-code")] [InlineData("no-code")] diff --git a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs new file mode 100644 index 0000000..deaf751 --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs @@ -0,0 +1,493 @@ +using System.Net; +using System.Text.Json; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +/// +/// Attaching a Google login to a signed-in Heimdall account by explicit link. Bifrost keeps no Heimdall token: each +/// Heimdall call that needs a bearer follows a fresh Heimdall sign-in, and the bearer lives inside that one request. +/// The attempt cookie carries only a purpose, the expected account and a pending link id. +/// +public sealed class HeimdallGoogleLinkTests +{ + private const string Status = "/Membership/Status"; + private const string ErrorRedirect = "/?auth=heimdall-error"; + + private sealed record Step(HttpResponseMessage Response, StartedAttempt Next, string Token); + + private static string Jar(string session, StartedAttempt attempt) => + $"{BifrostCookies.Session}={session}; {attempt.CookieName}={attempt.Cookie}"; + + private static string? Location(HttpResponseMessage response) => response.Headers.Location?.OriginalString; + + /// B: the fresh sign-in of a LinkStart attempt completes; Bifrost asks Heimdall for the Google link. + private static async Task StepB(HeimdallHost host, string session, StartedAttempt start, string account, string? token = null) + { + token ??= host.Token(account); + var code = host.CodeFor(start, account, token); + var before = host.Heimdall.StartBodies.Count; + var response = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = start.Nonce, ["code"] = code }, Jar(session, start)); + return new Step(response, host.AttemptFrom(response, before), token); + } + + /// C: the browser is back from Google with a pending link id; Bifrost starts a fresh sign-in. + private static async Task StepC(HeimdallHost host, string session, StartedAttempt awaiting, string pendingLinkId) + { + var before = host.Heimdall.StartBodies.Count; + var response = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = awaiting.Nonce, ["pendingLinkId"] = pendingLinkId }, Jar(session, awaiting)); + return new Step(response, host.AttemptFrom(response, before), string.Empty); + } + + /// D: the fresh sign-in of a LinkConfirm attempt completes; Bifrost confirms the link. + private static async Task StepD(HeimdallHost host, string session, StartedAttempt confirming, string account, string? token = null) + { + token ??= host.Token(account); + var code = host.CodeFor(confirming, account, token); + var response = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = confirming.Nonce, ["code"] = code }, Jar(session, confirming)); + return new Step(response, confirming, token); + } + + private sealed record Journey(string Account, string Session, StartedAttempt Start, Step B, string PendingLinkId, Step C); + + /// Everything up to the LinkConfirm attempt, waiting for the fresh sign-in's code. + private static async Task ThroughC(HeimdallHost host, string? pendingLinkId = null) + { + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var b = await StepB(host, session, start, account); + var pending = host.Heimdall.RegisterPendingLink(pendingLinkId); + var c = await StepC(host, session, b.Next, pending); + return new Journey(account, session, start, b, pending, c); + } + + private static string Unprotect(HeimdallHost host, string cookie) => + host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1").Unprotect(cookie); + + // ---- 1. the link start carries a fresh bearer ---- + + [Fact] + public async Task The_account_page_starts_an_ordinary_sign_in_and_the_link_start_follows_with_the_fresh_bearer() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var signInStarts = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session); + + Assert.Equal(HttpStatusCode.Redirect, start.Response.StatusCode); + Assert.Equal(signInStarts + 1, host.Heimdall.StartBodies.Count); + using (var body = JsonDocument.Parse(host.Heimdall.StartBodies[^1])) + { + Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString()); + } + + Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]); + Assert.Null(host.Heimdall.StartAuthorizations[^1]); + Assert.NotNull(start.Cookie); + + var b = await StepB(host, session, start, account); + + Assert.Equal(signInStarts + 2, host.Heimdall.StartBodies.Count); + Assert.Equal("/v1/oauth/google/start", host.Heimdall.StartPaths[^1]); + Assert.Equal("Bearer " + b.Token, host.Heimdall.StartAuthorizations[^1]); + using var link = JsonDocument.Parse(host.Heimdall.StartBodies[^1]); + Assert.Equal("link", link.RootElement.GetProperty("mode").GetString()); + Assert.Equal("bifrost", link.RootElement.GetProperty("appSlug").GetString()); + Assert.Equal("browser_completion", link.RootElement.GetProperty("handoff").GetProperty("kind").GetString()); + Assert.StartsWith("https://localhost/auth/heimdall/complete?attempt=", link.RootElement.GetProperty("returnTo").GetString()); + Assert.NotEqual(start.Nonce, b.Next.Nonce); + Assert.Equal("https://heimdall.test/oauth/google/authorize?state=link-fixture", Location(b.Response)); + // The sign-in session is untouched: the link flow never replaces who is signed in. + Assert.Null(HeimdallHost.CookieValue(b.Response, BifrostCookies.Session)); + Assert.Equal(HttpStatusCode.Redirect, b.Response.StatusCode); + } + + [Fact] + public async Task A_link_start_attempt_without_a_code_asks_Heimdall_for_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var before = host.Heimdall.StartBodies.Count; + + var response = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = start.Nonce }, Jar(session, start)); + + Assert.Equal(before, host.Heimdall.StartBodies.Count); + Assert.Equal(Status + "?link=error", Location(response)); + } + + [Fact] + public async Task A_link_start_attempt_cannot_sign_in_a_session() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var other = HeimdallShapes.NewAccountId(); + + var done = await host.CompleteAsync(start, host.CodeFor(start, other)); + + Assert.False(done.SignedIn); + Assert.Null(await host.Account(other)); + } + + // ---- 2. only a signed-in user, only by POST with antiforgery ---- + + [Fact] + public async Task Without_a_session_a_link_post_starts_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var before = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session: null); + + Assert.Equal(before, host.Heimdall.StartBodies.Count); + Assert.Null(start.Cookie); + Assert.NotEqual(HttpStatusCode.OK, start.Response.StatusCode); + } + + [Fact] + public async Task A_get_never_starts_a_link() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + var before = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session, method: HttpMethod.Get); + + Assert.Equal(before, host.Heimdall.StartBodies.Count); + Assert.Null(start.Cookie); + } + + [Fact] + public async Task A_link_post_without_the_antiforgery_token_starts_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + var before = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session, antiforgery: false); + + Assert.Equal(before, host.Heimdall.StartBodies.Count); + Assert.Null(start.Cookie); + Assert.Equal(HttpStatusCode.BadRequest, start.Response.StatusCode); + } + + // ---- 3. a browser that is not the one that began the link ---- + + [Fact] + public async Task A_foreign_browser_holding_a_pending_link_id_confirms_nothing_and_calls_Heimdall_zero_times() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + var redeems = host.Heimdall.RedeemCalls; + var starts = host.Heimdall.StartBodies.Count; + + // No cookie at all, and a session of its own; then an unrelated sign-in attempt's cookie. + var strangerAccount = HeimdallShapes.NewAccountId(); + var stranger = await host.SignInSessionAsync(strangerAccount); + redeems = host.Heimdall.RedeemCalls; + starts = host.Heimdall.StartBodies.Count; + var noCookie = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = journey.B.Next.Nonce, ["pendingLinkId"] = journey.PendingLinkId }, + $"{BifrostCookies.Session}={stranger}"); + var unrelated = await host.StartAsync(); + starts = host.Heimdall.StartBodies.Count; + var wrongCookie = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = journey.B.Next.Nonce, ["pendingLinkId"] = journey.PendingLinkId }, + $"{BifrostCookies.Session}={stranger}; {unrelated.CookieName}={unrelated.Cookie}"); + var forgedCode = host.CodeFor(journey.C.Next, strangerAccount); + var confirmWithoutCookie = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = journey.C.Next.Nonce, ["code"] = forgedCode }, + $"{BifrostCookies.Session}={stranger}"); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(redeems, host.Heimdall.RedeemCalls); + Assert.Empty(host.Heimdall.ConfirmBodies); + foreach (var response in new[] { noCookie, wrongCookie, confirmWithoutCookie }) + { + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.DoesNotContain("Bearer", Location(response)); + } + } + + // ---- 4. a different Heimdall account ---- + + [Fact] + public async Task Completion_for_a_different_account_than_the_session_is_refused_with_no_link_call() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var starts = host.Heimdall.StartBodies.Count; + + var b = await StepB(host, session, start, HeimdallShapes.NewAccountId()); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Status + "?link=error", Location(b.Response)); + AssertAttemptCleared(b.Response, start); + } + + [Fact] + public async Task A_session_of_another_account_cannot_continue_the_link() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var intruder = HeimdallShapes.NewAccountId(); + var intruderSession = await host.SignInSessionAsync(intruder); + var starts = host.Heimdall.StartBodies.Count; + var redeems = host.Heimdall.RedeemCalls; + + var b = await StepB(host, intruderSession, start, intruder); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(redeems, host.Heimdall.RedeemCalls); + Assert.Equal(Status + "?link=error", Location(b.Response)); + } + + [Fact] + public async Task A_confirming_sign_in_for_another_account_confirms_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + var other = HeimdallShapes.NewAccountId(); + + var d = await StepD(host, journey.Session, journey.C.Next, other); + + Assert.Empty(host.Heimdall.ConfirmBodies); + Assert.Equal(Status + "?link=error", Location(d.Response)); + } + + private static void AssertAttemptCleared(HttpResponseMessage response, StartedAttempt attempt) + { + var header = HeimdallHost.SetCookieHeader(response, attempt.CookieName!); + Assert.NotNull(header); + Assert.Contains("1970", header); + } + + // ---- 5. outcomes ---- + + [Fact] + public async Task Success_confirms_once_with_the_pending_link_id_and_a_fresh_bearer_and_shows_linked() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + Assert.Empty(host.Heimdall.ConfirmBodies); + Assert.Equal(HttpStatusCode.Redirect, journey.C.Response.StatusCode); + Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]); + Assert.Null(host.Heimdall.StartAuthorizations[^1]); + + var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); + + Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Single(host.Heimdall.ConfirmBodies); + using var body = JsonDocument.Parse(host.Heimdall.ConfirmBodies[0]); + Assert.Equal(journey.PendingLinkId, body.RootElement.GetProperty("pendingLinkId").GetString()); + Assert.Equal("Bearer " + d.Token, host.Heimdall.ConfirmAuthorizations[0]); + Assert.NotEqual("Bearer " + journey.B.Token, host.Heimdall.ConfirmAuthorizations[0]); + AssertAttemptCleared(d.Response, journey.C.Next); + Assert.Null(HeimdallHost.CookieValue(d.Response, BifrostCookies.Session)); + var account = await host.Account(journey.Account); + Assert.Equal(MembershipStatus.Authenticated, account!.Membership!.Status); + } + + [Theory] + [InlineData(HttpStatusCode.Conflict, "identity_linked_elsewhere")] + [InlineData(HttpStatusCode.Gone, "expired")] + [InlineData(HttpStatusCode.Unauthorized, "error")] + [InlineData(HttpStatusCode.InternalServerError, "error")] + public async Task A_refused_confirm_redirects_with_its_own_fixed_outcome(HttpStatusCode status, string outcome) + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + host.Heimdall.ConfirmStatus = status; + + var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); + + Assert.Equal($"{Status}?link={outcome}", Location(d.Response)); + Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines)); + AssertAttemptCleared(d.Response, journey.C.Next); + } + + [Fact] + public async Task The_account_page_words_each_outcome_from_a_closed_set_and_ignores_anything_else() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + using var client = host.NewClient(); + + async Task Page(string link) + { + using var request = new HttpRequestMessage(HttpMethod.Get, Status + "?link=" + Uri.EscapeDataString(link)); + request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}"); + return await (await client.SendAsync(request)).Content.ReadAsStringAsync(); + } + + Assert.Contains("Google is now linked", await Page("linked")); + Assert.Contains("already linked to a different Heimdall account", await Page("identity_linked_elsewhere")); + Assert.Contains("expired before it was confirmed", await Page("expired")); + Assert.Contains("could not be completed", await Page("error")); + var hostile = await Page(""); + Assert.DoesNotContain("alert(1)", hostile); + Assert.DoesNotContain("Google is now linked", hostile); + } + + // ---- 6. google stays out of sign-in and member access ---- + + [Theory] + [InlineData("/auth/heimdall/google")] + [InlineData("/auth/heimdall/google?access=member")] + [InlineData("/auth/heimdall/Google")] + public async Task Google_is_refused_as_a_sign_in_provider_and_asks_Heimdall_for_nothing(string url) + { + using var host = new HeimdallHost(realSessions: true); + + var start = await host.StartAtAsync(url); + + Assert.Equal(HttpStatusCode.Redirect, start.Response.StatusCode); + Assert.Equal(ErrorRedirect, Location(start.Response)); + Assert.Empty(host.Heimdall.StartBodies); + } + + // ---- 7. nothing secret leaves the request ---- + + [Fact] + public async Task No_heimdall_token_or_pending_link_id_reaches_a_log_a_cookie_or_a_location() + { + using var host = new HeimdallHost(traceHttp: true, realSessions: true); + const string pendingCanary = "PENDING-CANARY-7f3a"; + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var b = await StepB(host, session, start, account); + var pending = host.Heimdall.RegisterPendingLink(pendingCanary); + var c = await StepC(host, session, b.Next, pending); + var d = await StepD(host, session, c.Next, account); + Assert.Equal(Status + "?link=linked", Location(d.Response)); + + var tokens = new[] { b.Token, d.Token } + .SelectMany(token => new[] { token, token.Split('.')[2], token.Split('.')[1] }) + .ToArray(); + var logs = string.Join("\n", host.Logs.Lines); + Assert.Contains("Bearer", string.Join("\n", host.Heimdall.StartAuthorizations.Where(x => x is not null))); + foreach (var secret in tokens.Append(pendingCanary)) + { + Assert.DoesNotContain(secret, logs); + foreach (var step in new[] { start.Response, b.Response, c.Response, d.Response }) + { + Assert.DoesNotContain(secret, Location(step) ?? string.Empty); + } + } + + // Every attempt cookie Bifrost set along the way, unprotected: purpose, account and pending id only. + foreach (var cookie in new[] { start.Cookie, b.Next.Cookie, c.Next.Cookie }) + { + var payload = Unprotect(host, cookie!); + foreach (var token in tokens) + { + Assert.DoesNotContain(token, payload); + } + + Assert.DoesNotContain("eyJ", payload); + } + + Assert.DoesNotContain(pendingCanary, Unprotect(host, start.Cookie!)); + Assert.DoesNotContain(pendingCanary, Unprotect(host, b.Next.Cookie!)); + Assert.Contains(account, Unprotect(host, b.Next.Cookie!)); + } + + // ---- 8. old attempt cookies keep signing in ---- + + [Fact] + public async Task An_attempt_cookie_written_before_purposes_existed_still_completes_a_normal_sign_in() + { + using var host = new HeimdallHost(realSessions: true); + var nonce = "legacy-nonce-" + Guid.NewGuid().ToString("N"); + var protector = host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1"); + var legacy = protector.Protect(JsonSerializer.Serialize(new Dictionary + { + ["Nonce"] = nonce, + ["Provider"] = "discord", + ["RequiresMemberAccess"] = false, + ["ReturnPath"] = "/App/Mill", + ["ExpiresAt"] = host.Clock.Now.AddMinutes(5) + })); + var account = HeimdallShapes.NewAccountId(); + var attempt = new StartedAttempt( + nonce, HeimdallAttemptCookie.SlotNames[0], legacy, $"https://localhost/auth/heimdall/complete?attempt={nonce}", null!); + + var done = await host.CompleteAsync(attempt, host.CodeFor(attempt, account)); + + Assert.True(done.SignedIn); + Assert.Equal("/App/Mill", Location(done.Response)); + Assert.NotNull(await host.Account(account)); + } + + // ---- 9. attempts are single use ---- + + [Fact] + public async Task Replaying_a_consumed_link_start_attempt_does_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var code = host.CodeFor(start, account); + var fields = new Dictionary { ["attempt"] = start.Nonce, ["code"] = code }; + var first = await host.PostCompleteAsync(fields, Jar(session, start)); + Assert.Contains("google", Location(first)); + var starts = host.Heimdall.StartBodies.Count; + + var replay = await host.PostCompleteAsync(fields, Jar(session, start)); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Status + "?link=error", Location(replay)); + } + + [Fact] + public async Task Replaying_a_consumed_confirm_attempt_confirms_nothing_twice() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + var code = host.CodeFor(journey.C.Next, journey.Account); + var fields = new Dictionary { ["attempt"] = journey.C.Next.Nonce, ["code"] = code }; + var first = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next)); + Assert.Equal(Status + "?link=linked", Location(first)); + + var replay = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next)); + + Assert.Single(host.Heimdall.ConfirmBodies); + Assert.Equal(Status + "?link=error", Location(replay)); + } + + [Fact] + public async Task Replaying_a_consumed_await_attempt_links_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + var again = await StepC(host, journey.Session, journey.B.Next, journey.PendingLinkId); + + // The replay can only begin another fresh sign-in; the pending id is spent at Heimdall by the first confirm. + var d = await StepD(host, journey.Session, again.Next, journey.Account); + var replayed = await StepD(host, journey.Session, journey.C.Next, journey.Account); + + Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Equal(Status + "?link=expired", Location(replayed.Response)); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index 67dd4fe..a051db0 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -202,6 +202,29 @@ public sealed class FakeHeimdall : HttpMessageHandler public List StartSecrets { get; } = []; + /// The path of every start, so a google link start can be told from a sign-in start. + public List StartPaths { get; } = []; + + /// The Authorization header of every start (null when absent). + public List StartAuthorizations { get; } = []; + + public List ConfirmBodies { get; } = []; + + public List ConfirmAuthorizations { get; } = []; + + /// When set, every links/confirm answers this status with Heimdall's error code for it. + public HttpStatusCode? ConfirmStatus { get; set; } + + private readonly HashSet _pendingLinks = new(StringComparer.Ordinal); + + /// Registers a pending link Heimdall will confirm once for any bearer. + public string RegisterPendingLink(string? id = null) + { + id ??= "pending-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant(); + _pendingLinks.Add(id); + return id; + } + /// The secret a policy start must carry in x-heimdall-app-secret; null means Heimdall asks for none. public string? RequiredAppSecret { get; set; } @@ -255,8 +278,29 @@ public sealed class FakeHeimdall : HttpMessageHandler { var body = await request.Content!.ReadAsStringAsync(cancellationToken); var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null; + var authorization = request.Headers.Authorization?.ToString(); StartBodies.Add(body); StartSecrets.Add(secret); + StartPaths.Add(path); + StartAuthorizations.Add(authorization); + var root = JsonDocument.Parse(body).RootElement; + if (root.TryGetProperty("mode", out var mode) && mode.GetString() == "link") + { + if (authorization is null) + { + return new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("""{"error":"link_session_required"}""", Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent( + """{"authorizationUrl":"https://heimdall.test/oauth/google/authorize?state=link-fixture"}""", Encoding.UTF8, "application/json") + }; + } + var wantsPolicy = JsonDocument.Parse(body).RootElement.TryGetProperty("entitlementPolicy", out _); if (wantsPolicy && RequiredAppSecret is not null && secret != RequiredAppSecret) { @@ -271,6 +315,35 @@ public sealed class FakeHeimdall : HttpMessageHandler return new HttpResponseMessage(StartStatus) { Content = content }; } + if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/links/confirm") + { + var body = await request.Content!.ReadAsStringAsync(cancellationToken); + var authorization = request.Headers.Authorization?.ToString(); + ConfirmBodies.Add(body); + ConfirmAuthorizations.Add(authorization); + static HttpResponseMessage Json(HttpStatusCode status, string json) => + new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + if (authorization is null) + { + return Json(HttpStatusCode.Unauthorized, """{"error":"invalid_link_session"}"""); + } + + if (ConfirmStatus is { } forced) + { + return forced switch + { + HttpStatusCode.Conflict => Json(forced, """{"error":"identity_linked_elsewhere"}"""), + HttpStatusCode.Gone => Json(forced, """{"error":"invalid_or_expired_pending_link"}"""), + _ => Json(forced, """{"error":"invalid_link_session","detail":"HEIMDALL FREE TEXT"}""") + }; + } + + var id = JsonDocument.Parse(body).RootElement.GetProperty("pendingLinkId").GetString()!; + return _pendingLinks.Remove(id) + ? Json(HttpStatusCode.Created, """{"status":"linked","provider":"google","accountId":"a","returnTo":"https://localhost/auth/heimdall/complete"}""") + : Json(HttpStatusCode.Gone, """{"error":"invalid_or_expired_pending_link"}"""); + } + if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/auth-completions/redeem") { RedeemCalls++; @@ -354,6 +427,8 @@ public sealed class HeimdallHost : TestWebApplicationFactory private readonly string _baseUrl; private readonly string _publicBaseUrl; private readonly bool _discordRoles; + private readonly bool _traceHttp; + private readonly bool _realSessions; public TestClock Clock { get; } = new(); @@ -368,8 +443,12 @@ public sealed class HeimdallHost : TestWebApplicationFactory string baseUrl = HeimdallShapes.Issuer, string publicBaseUrl = "https://localhost", bool discordRoles = true, - string appSlug = "bifrost") + string appSlug = "bifrost", + bool traceHttp = false, + bool realSessions = false) { + _traceHttp = traceHttp; + _realSessions = realSessions; _appSecret = appSecret; _appSlug = appSlug; _baseUrl = baseUrl; @@ -400,9 +479,27 @@ public sealed class HeimdallHost : TestWebApplicationFactory configuration.AddInMemoryCollection(new Dictionary { ["Heimdall:DiscordAllowedRoleIds:0"] = "role-1" }); } }); - builder.ConfigureLogging(logging => logging.AddProvider(Logs)); + builder.ConfigureLogging(logging => + { + logging.AddProvider(Logs); + if (_traceHttp) + { + // The framework's HttpClient logging writes request headers at Trace; it must never write a bearer there. + logging.AddFilter("System.Net.Http", LogLevel.Trace); + } + }); builder.ConfigureServices(services => { + if (_realSessions) + { + // The base factory authenticates every request as a test admin; a link needs the session cookie to be the identity. + services.Configure(options => + { + options.DefaultAuthenticateScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; + }); + } + services.RemoveAll(); services.AddSingleton(Clock); services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall); @@ -437,7 +534,12 @@ public sealed class HeimdallHost : TestWebApplicationFactory request.Headers.Add("Cookie", cookieHeader); } - var response = await client.SendAsync(request); + return AttemptFrom(await client.SendAsync(request), before); + } + + /// The attempt a response started at Heimdall, if the start count moved since . + public StartedAttempt AttemptFrom(HttpResponseMessage response, int before) + { if (Heimdall.StartBodies.Count == before) { return new StartedAttempt(string.Empty, null, null, string.Empty, response); @@ -489,6 +591,73 @@ public sealed class HeimdallHost : TestWebApplicationFactory return new CompletionResult(response, !string.IsNullOrEmpty(session)); } + /// A browser session: a normal Heimdall sign-in, answered with the session cookie it earned. + public async Task SignInSessionAsync(string accountId, string provider = "discord") + { + var attempt = await StartAsync(provider: provider); + var done = await CompleteAsync(attempt, CodeFor(attempt, accountId)); + return CookieValue(done.Response, BifrostCookies.Session) ?? throw new InvalidOperationException("sign-in set no session"); + } + + /// The raw POST of the complete page, whatever fields and cookies the caller names. + public async Task PostCompleteAsync(IDictionary fields, string? cookieHeader) + { + using var client = NewClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/heimdall/complete") + { + Content = new FormUrlEncodedContent(fields) + }; + if (cookieHeader is not null) + { + request.Headers.Add("Cookie", cookieHeader); + } + + return await client.SendAsync(request); + } + + public const string LinkPostUrl = "/Membership/Status?handler=LinkGoogle"; + + /// + /// The account page's link button as a browser posts it: the page is fetched for its antiforgery token and cookie + /// (when ), then the form is posted with the session. Without a session nothing is fetched. + /// + public async Task StartLinkAsync(string? session, bool antiforgery = true, HttpMethod? method = null) + { + using var client = NewClient(); + var cookies = new List(); + var fields = new Dictionary(); + if (session is not null) + { + cookies.Add($"{BifrostCookies.Session}={session}"); + } + + if (antiforgery && session is not null) + { + using var get = new HttpRequestMessage(HttpMethod.Get, "/Membership/Status"); + get.Headers.Add("Cookie", string.Join("; ", cookies)); + using var page = await client.SendAsync(get); + var html = await page.Content.ReadAsStringAsync(); + var token = System.Text.RegularExpressions.Regex.Match( + html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value; + fields["__RequestVerificationToken"] = token; + cookies.Add($"{BifrostCookies.Antiforgery}={CookieValue(page, BifrostCookies.Antiforgery)}"); + } + + var before = Heimdall.StartBodies.Count; + using var request = new HttpRequestMessage(method ?? HttpMethod.Post, LinkPostUrl); + if (request.Method == HttpMethod.Post) + { + request.Content = new FormUrlEncodedContent(fields); + } + + if (cookies.Count > 0) + { + request.Headers.Add("Cookie", string.Join("; ", cookies)); + } + + return AttemptFrom(await client.SendAsync(request), before); + } + public Task CompleteAsync(StartedAttempt attempt, string code) => CompleteAsync(attempt.Nonce, code, attempt.Cookie, attempt.CookieName); From 4c9937f881c8df24d97c8f8f6b8c8e798907635a Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 15:21:40 +0000 Subject: [PATCH 040/102] Bifrost: explicit Google link by fresh Heimdall sign-in, no stored token The account page's POST starts an ordinary sign-in whose attempt (a purpose, the expected account id, a pending link id; never a token) is only the proof for a link. Each Heimdall call that needs a bearer follows its own fresh sign-in; the bearer lives in that request. Google stays out of AllowedProviders and member access. Authorization is redacted from HttpClient logging. Outcomes redirect to a fixed closed set of wording. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Heimdall/HeimdallAuthService.cs | 301 +++++++++++++++++- .../Features/Heimdall/HeimdallCompletePage.cs | 10 +- .../Pages/Membership/Status.cshtml | 13 + .../Pages/Membership/Status.cshtml.cs | 48 ++- src/Bifrost.Web/Program.cs | 30 +- 5 files changed, 380 insertions(+), 22 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 076f788..b24b4ab 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -41,6 +41,14 @@ public sealed class HeimdallAuthService( "twitch" }; + /// Whether a provider may sign a person in (and so be asked for at /auth/heimdall/{provider}). Google is identity only: it can be linked, never signed in with. + public static bool IsSignInProvider(string provider) => AllowedProviders.Contains(provider); + + /// The headers HttpClient logging must never write: the app secret and any bearer. + public static bool RedactsHeader(string header) => + string.Equals(header, AppSecretHeader, StringComparison.OrdinalIgnoreCase) + || string.Equals(header, "Authorization", StringComparison.OrdinalIgnoreCase); + private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Bifrost.Heimdall.Attempt.v1"); /// Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app. @@ -96,7 +104,11 @@ public sealed class HeimdallAuthService( LocalReturnPath returnPath, bool requireMemberAccess, IEnumerable> requestCookies, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + HeimdallAttemptPurpose purpose = HeimdallAttemptPurpose.SignIn, + string? expectedAccountId = null, + string? pendingLinkId = null, + string? replaceSlot = null) { if (!AllowedProviders.Contains(provider)) { @@ -144,11 +156,40 @@ public sealed class HeimdallAuthService( provider, requireMemberAccess, returnPath, - timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime); + timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime, + purpose, + expectedAccountId, + pendingLinkId); return new HeimdallAuthStartResult( - new Uri(payload.AuthorizationUrl, UriKind.Absolute), ChooseSlot(requestCookies), Protect(attempt)); + new Uri(payload.AuthorizationUrl, UriKind.Absolute), replaceSlot ?? ChooseSlot(requestCookies), Protect(attempt)); } + /// The provider a link's fresh sign-in uses: the one the person signed in with, else Discord. + public static string LinkSignInProvider(string? sessionProvider) => + sessionProvider is not null && AllowedProviders.Contains(sessionProvider) ? sessionProvider.ToLowerInvariant() : "discord"; + + /// + /// The account page's link button: an ordinary Heimdall sign-in whose attempt says it is only the proof for a + /// link, and which person it must prove. The session is never replaced by it. + /// + public Task StartLinkAsync( + string sessionAccountId, + string? sessionProvider, + string callbackBaseUrl, + IEnumerable> requestCookies, + CancellationToken cancellationToken) => + StartAsync( + LinkSignInProvider(sessionProvider), + callbackBaseUrl, + LocalReturnPath.Parse(LinkReturnPath), + requireMemberAccess: false, + requestCookies, + cancellationToken, + HeimdallAttemptPurpose.LinkStart, + sessionAccountId); + + public const string LinkReturnPath = "/Membership/Status"; + /// /// The slot the new attempt's cookie takes: the first slot that holds no live attempt, else the one whose /// attempt expires first. Parallel starts carry the same request cookies, so they choose the same slot and @@ -195,15 +236,13 @@ public sealed class HeimdallAuthService( return null; } - public async Task CompleteAsync( - string callbackBaseUrl, - string? nonce, - string? cookieValue, - string? code, - CancellationToken cancellationToken) + /// What the attempt this browser holds for the nonce is for, or null when it holds none. + public HeimdallAttemptPurpose? PurposeOf(string? cookieValue, string? nonce) => Unprotect(cookieValue, nonce)?.Purpose; + + /// Redeems the code of an attempt and verifies the token it carries. The token is returned to the caller's stack frame and nowhere else. + private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken)> RedeemVerifiedAsync( + string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) { - var attempt = Unprotect(cookieValue, nonce) - ?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser."); if (string.IsNullOrWhiteSpace(code)) { throw new HeimdallAuthException("Heimdall completion carried no code."); @@ -221,6 +260,24 @@ public sealed class HeimdallAuthService( } var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken); + return (identity, redeemed.AccessToken); + } + + public async Task CompleteAsync( + string callbackBaseUrl, + string? nonce, + string? cookieValue, + string? code, + CancellationToken cancellationToken) + { + var attempt = Unprotect(cookieValue, nonce) + ?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser."); + if (attempt.Purpose != HeimdallAttemptPurpose.SignIn) + { + throw new HeimdallAuthException("Heimdall completion is not a sign-in attempt."); + } + + var (identity, _) = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken); if (attempt.RequiresMemberAccess && !identity.HasMemberAccess) { throw new HeimdallAuthException("Heimdall did not grant Bifrost member access."); @@ -236,6 +293,155 @@ public sealed class HeimdallAuthService( attempt.ReturnPath); } + /// + /// One step of a Google link, chosen by the purpose of the attempt this browser holds. The browser's session must be + /// the account the link was begun for, checked before Heimdall is asked anything and again against the identity the + /// fresh sign-in proves. Each step consumes the held attempt: the next step is a new attempt in the same slot, or none. + /// + public async Task ContinueLinkAsync( + string callbackBaseUrl, + string? nonce, + string cookieName, + string? cookieValue, + string? code, + string? pendingLinkId, + string? sessionAccountId, + CancellationToken cancellationToken) + { + var attempt = Unprotect(cookieValue, nonce) + ?? throw new HeimdallAuthException("Heimdall link has no attempt for this browser."); + if (string.IsNullOrEmpty(attempt.ExpectedAccountId) + || !string.Equals(sessionAccountId, attempt.ExpectedAccountId, StringComparison.Ordinal)) + { + throw new HeimdallAuthException("Heimdall link belongs to another session."); + } + + switch (attempt.Purpose) + { + case HeimdallAttemptPurpose.LinkStart: + { + var (_, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + var authorizationUrl = await StartGoogleLinkAsync(callbackBaseUrl, attempt, bearer, cancellationToken); + return new HeimdallLinkStep(authorizationUrl.Url, cookieName, authorizationUrl.CookieValue, null); + } + + case HeimdallAttemptPurpose.LinkAwaitGoogle: + { + if (string.IsNullOrWhiteSpace(pendingLinkId)) + { + throw new HeimdallAuthException("Heimdall link carried no pending link."); + } + + var next = await StartAsync( + LinkSignInProvider(attempt.Provider), + callbackBaseUrl, + attempt.ReturnPath, + requireMemberAccess: false, + [], + cancellationToken, + HeimdallAttemptPurpose.LinkConfirm, + attempt.ExpectedAccountId, + pendingLinkId, + replaceSlot: cookieName); + return new HeimdallLinkStep(next.AuthorizationUrl, cookieName, next.CookieValue, null); + } + + case HeimdallAttemptPurpose.LinkConfirm: + { + var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + var outcome = await ConfirmLinkAsync(attempt.PendingLinkId!, bearer, cancellationToken); + if (outcome == HeimdallLinkOutcome.Linked) + { + // Membership follows the verified identity exactly as at sign-in; the session principal is not replaced. + await UpsertUserAccountAsync(identity, attempt.Provider, grantMemberAccess: false, cancellationToken); + } + + return new HeimdallLinkStep(null, cookieName, null, outcome); + } + + default: + throw new HeimdallAuthException("Heimdall completion is not a link attempt."); + } + } + + /// Redeems a link attempt's code and requires it to prove the account the link was begun for. + private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken)> RedeemOwnerAsync( + string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) + { + var proven = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken); + if (!string.Equals(proven.Identity.AccountId, attempt.ExpectedAccountId, StringComparison.Ordinal)) + { + throw new HeimdallAuthException("Heimdall link was proven by another account."); + } + + return proven; + } + + private async Task<(Uri Url, string CookieValue)> StartGoogleLinkAsync( + string callbackBaseUrl, HeimdallAttempt attempt, string bearer, CancellationToken cancellationToken) + { + var options = heimdallOptions.Value; + var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); + using var message = new HttpRequestMessage(HttpMethod.Post, "/v1/oauth/google/start") + { + Content = JsonContent.Create(new HeimdallStartRequest( + options.AppSlug, "link", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), null)) + }; + message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer); + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new HeimdallAuthException(await RefusedStartMessageAsync(response, cancellationToken)); + } + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + if (payload is null || string.IsNullOrWhiteSpace(payload.AuthorizationUrl)) + { + throw new HeimdallAuthException("Heimdall link start returned no authorization URL."); + } + + var awaiting = new HeimdallAttempt( + nonce, + attempt.Provider, + false, + attempt.ReturnPath, + timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime, + HeimdallAttemptPurpose.LinkAwaitGoogle, + attempt.ExpectedAccountId, + null); + return (new Uri(payload.AuthorizationUrl, UriKind.Absolute), Protect(awaiting)); + } + + private async Task ConfirmLinkAsync(string pendingLinkId, string bearer, CancellationToken cancellationToken) + { + try + { + using var message = new HttpRequestMessage( + HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/links/confirm") + { + Content = JsonContent.Create(new HeimdallConfirmLinkRequest(pendingLinkId)) + }; + message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer); + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + switch (response.StatusCode) + { + case HttpStatusCode.Created: + var linked = await response.Content.ReadFromJsonAsync(cancellationToken); + return linked?.Status == "linked" ? HeimdallLinkOutcome.Linked : HeimdallLinkOutcome.Error; + case HttpStatusCode.Conflict: + return HeimdallLinkOutcome.IdentityLinkedElsewhere; + case HttpStatusCode.Gone: + return HeimdallLinkOutcome.Expired; + default: + return HeimdallLinkOutcome.Error; + } + } + catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException && !cancellationToken.IsCancellationRequested) + { + return HeimdallLinkOutcome.Error; + } + } + private async Task RedeemAsync(string code, CancellationToken cancellationToken) { try @@ -260,7 +466,8 @@ public sealed class HeimdallAuthService( private string Protect(HeimdallAttempt attempt) => _protector.Protect(JsonSerializer.Serialize(new AttemptWire( - attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt))); + attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt, + attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId))); /// The live attempt a cookie value protects when it belongs to this nonce, else null. private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) => @@ -277,13 +484,14 @@ public sealed class HeimdallAuthService( try { var wire = JsonSerializer.Deserialize(_protector.Unprotect(cookieValue)); - if (wire is null || wire.ExpiresAt <= timeProvider.GetUtcNow()) + if (wire is null || wire.ExpiresAt <= timeProvider.GetUtcNow() || !Enum.IsDefined(wire.Purpose)) { return null; } return new HeimdallAttempt( - wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt); + wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt, + wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId); } catch (Exception error) when (error is CryptographicException or JsonException or FormatException) { @@ -296,7 +504,10 @@ public sealed class HeimdallAuthService( string Provider, bool RequiresMemberAccess, string ReturnPath, - DateTimeOffset ExpiresAt); + DateTimeOffset ExpiresAt, + HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, + string? ExpectedAccountId = null, + string? PendingLinkId = null); private async Task UpsertUserAccountAsync( HeimdallVerifiedIdentity identity, @@ -448,7 +659,65 @@ public sealed record HeimdallAttempt( string Provider, bool RequiresMemberAccess, LocalReturnPath ReturnPath, - DateTimeOffset ExpiresAt); + DateTimeOffset ExpiresAt, + HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, + string? ExpectedAccountId = null, + string? PendingLinkId = null); + +/// +/// What a held attempt is for. SignIn is the default, so a cookie written before purposes existed reads as a sign-in. +/// The link purposes are the fresh sign-ins and the Google round trip of an explicit link; they never sign anyone in. +/// +public enum HeimdallAttemptPurpose +{ + SignIn = 0, + LinkStart = 1, + LinkAwaitGoogle = 2, + LinkConfirm = 3 +} + +public enum HeimdallLinkOutcome +{ + Linked, + IdentityLinkedElsewhere, + Expired, + Error +} + +/// +/// Where a link step sends the browser: to with a new attempt cookie in the held slot, or, when the +/// link is finished, back to the account page with . +/// +public static class HeimdallLinkOutcomes +{ + public static string Redirect(HeimdallLinkOutcome outcome) => HeimdallAuthService.LinkReturnPath + "?link=" + Code(outcome); + + public static string Code(HeimdallLinkOutcome outcome) => outcome switch + { + HeimdallLinkOutcome.Linked => "linked", + HeimdallLinkOutcome.IdentityLinkedElsewhere => "identity_linked_elsewhere", + HeimdallLinkOutcome.Expired => "expired", + _ => "error" + }; + + /// The words for an outcome code: a closed set, never Heimdall's text. Anything else gets none. + public static string? Wording(string? code) => code switch + { + "linked" => "Google is now linked to your Heimdall account.", + "identity_linked_elsewhere" => "That Google identity is already linked to a different Heimdall account. Nothing was linked.", + "expired" => "The Google link expired before it was confirmed. Nothing was linked; start the link again.", + "error" => "The Google link could not be completed. Nothing was linked.", + _ => null + }; +} + +public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome); + +public sealed record HeimdallConfirmLinkRequest( + [property: JsonPropertyName("pendingLinkId")] string PendingLinkId); + +public sealed record HeimdallConfirmLinkResponse( + [property: JsonPropertyName("status")] string? Status); public sealed record HeimdallStartRequest( [property: JsonPropertyName("appSlug")] string AppSlug, diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs index 662fb16..b7d67b2 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs @@ -3,7 +3,8 @@ namespace Bifrost.Web.Features.Heimdall; /// /// The page Heimdall's "Return to the app" lands on. The completion code arrives in the URL fragment, which /// the server never sees, so the page's script reads it, drops it from the address bar, and posts it with -/// the attempt to the same path. The page is static: it never contains a code. +/// the attempt to the same path. A Google link returns a pending link id the same way instead of a code. The page +/// is static: it never contains a code or an id. /// public static class HeimdallCompletePage { @@ -22,21 +23,24 @@ public static class HeimdallCompletePage + diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml b/src/Bifrost.Web/Pages/Membership/Status.cshtml index 2c1ab8f..8f683a4 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml @@ -24,6 +24,19 @@

@Model.StatusMessage

+ @if (Model.LinkMessage is not null) + { +
@Model.LinkMessage
+ } + + @if (Model.CanLinkGoogle) + { +
+ + You will sign in to Heimdall again, then to Google. Google links your account; it does not sign you in. +
+ } + @if (Model.Invitation is not null) {
diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs index d579dd3..13b47ca 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs @@ -1,17 +1,62 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; +using System.Security.Claims; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace Bifrost.Web.Pages.Membership; [Authorize] public sealed class StatusModel( ICurrentBifrostActorAccessor actorAccessor, - BifrostDbContext dbContext) : PageModel + BifrostDbContext dbContext, + HeimdallAuthService heimdallAuthService, + IOptions hostOptions, + ILogger logger) : PageModel { + /// The wording of the link outcome the page was sent back with: a closed set, null for anything else. + public string? LinkMessage { get; private set; } + + /// Whether this session belongs to a Heimdall account a Google login can be attached to. + public bool CanLinkGoogle => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 }; + + /// + /// Begins an explicit Google link: an ordinary Heimdall sign-in for the account already signed in here, kept as + /// an attempt that is only the proof for the link. POST with antiforgery only; nothing is started without a session. + /// + public async Task OnPostLinkGoogleAsync(CancellationToken cancellationToken) + { + var accountId = User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId); + if (string.IsNullOrEmpty(accountId)) + { + return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) }); + } + + try + { + var start = await heimdallAuthService.StartLinkAsync( + accountId, + User.FindFirstValue(BifrostClaimTypes.AuthProvider), + hostOptions.Value.PublicBaseUrl, + Request.Cookies, + cancellationToken); + Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options()); + return Redirect(start.AuthorizationUrl.ToString()); + } + catch (HeimdallAuthException error) + { + logger.LogWarning("Heimdall link start refused: {Reason}", error.Message); + return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) }); + } + } + public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; public MembershipInvitation? Invitation { get; private set; } @@ -45,6 +90,7 @@ public sealed class StatusModel( public async Task OnGetAsync(CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); + LinkMessage = HeimdallLinkOutcomes.Wording(Request.Query["link"].ToString()); if (Actor.UserAccount is null) { diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index be3df54..ef92be8 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -152,7 +152,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHttpClient((serviceProvider, client) => HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) - .RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase)); + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); builder.Services.AddSingleton(); builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); @@ -826,8 +826,32 @@ app.MapPost("/auth/heimdall/complete", async ( var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); var attempt = form?["attempt"].ToString(); var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt); + var linking = held is not null + && heimdallAuthService.PurposeOf(held.Value.Value, attempt) is not (null or HeimdallAttemptPurpose.SignIn); try { + if (linking) + { + // A link step never signs anyone in: the session in this browser is the one being linked. + var step = await heimdallAuthService.ContinueLinkAsync( + hostOptions.Value.PublicBaseUrl, + attempt, + held!.Value.Name, + held.Value.Value, + form?["code"].ToString(), + form?["pendingLinkId"].ToString(), + httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId), + cancellationToken); + if (step.Next is not null) + { + httpContext.Response.Cookies.Append(step.CookieName, step.CookieValue!, HeimdallAttemptCookie.Options()); + return Results.Redirect(step.Next.ToString()); + } + + httpContext.Response.Cookies.Delete(step.CookieName, HeimdallAttemptCookie.DeleteOptions()); + return Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(step.Outcome!.Value)); + } + var signIn = await heimdallAuthService.CompleteAsync( hostOptions.Value.PublicBaseUrl, attempt, @@ -846,7 +870,9 @@ app.MapPost("/auth/heimdall/complete", async ( httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions()); } - return Results.Redirect(HeimdallAuthService.ErrorRedirect); + return linking + ? Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(HeimdallLinkOutcome.Error)) + : Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); From 1fbe16eb73fda97f13c50e11e500379a099955c2 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 15:46:34 +0000 Subject: [PATCH 041/102] Complete page pin: the form also carries pendingLinkId Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index 9f91425..c92d99c 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -1510,7 +1510,7 @@ public sealed class HeimdallCompletionTests var inputs = Regex.Matches(form.Groups[2].Value, @"]*)>"); Assert.All(inputs, x => Assert.Contains("type=\"hidden\"", x.Groups[1].Value)); var names = inputs.Select(x => Regex.Match(x.Groups[1].Value, @"\bname=""([^""]*)""").Groups[1].Value).ToArray(); - Assert.Equal(new[] { "attempt", "code" }, names); + Assert.Equal(new[] { "attempt", "code", "pendingLinkId" }, names); // The page's own field names and action, filled as its script fills them, complete a sign-in. var attempt = await host.StartAsync(); From f9d0c24f6660c7cfe7a0a1d5a2c4d3eb90b841e8 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 16:26:25 +0000 Subject: [PATCH 042/102] Take the link's cookie slot from the started attempt; drop unused IsSignInProvider The await step passed replaceSlot to StartAsync but named the slot from its own cookieName, so replaceSlot decided nothing. The step now returns the slot the started attempt chose, one owner. Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index b24b4ab..8ab2add 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -41,9 +41,6 @@ public sealed class HeimdallAuthService( "twitch" }; - /// Whether a provider may sign a person in (and so be asked for at /auth/heimdall/{provider}). Google is identity only: it can be linked, never signed in with. - public static bool IsSignInProvider(string provider) => AllowedProviders.Contains(provider); - /// The headers HttpClient logging must never write: the app secret and any bearer. public static bool RedactsHeader(string header) => string.Equals(header, AppSecretHeader, StringComparison.OrdinalIgnoreCase) @@ -343,7 +340,7 @@ public sealed class HeimdallAuthService( attempt.ExpectedAccountId, pendingLinkId, replaceSlot: cookieName); - return new HeimdallLinkStep(next.AuthorizationUrl, cookieName, next.CookieValue, null); + return new HeimdallLinkStep(next.AuthorizationUrl, next.CookieName, next.CookieValue, null); } case HeimdallAttemptPurpose.LinkConfirm: From 1298e7db1e9b89dc38373c4813ac6dfc7cf21071 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 16:26:25 +0000 Subject: [PATCH 043/102] Pin the Google link guards: own slot beside a parallel attempt, undefined purpose, empty pending link id, non-linked 201 Co-Authored-By: Claude Sonnet 5.5 --- .../HeimdallGoogleLinkTests.cs | 111 ++++++++++++++++++ .../Support/HeimdallTestKit.cs | 16 ++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs index deaf751..5de3eec 100644 --- a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs @@ -490,4 +490,115 @@ public sealed class HeimdallGoogleLinkTests Assert.Equal(Status + "?link=linked", Location(d.Response)); Assert.Equal(Status + "?link=expired", Location(replayed.Response)); } + + // ---- 10. pins for guards the first pass left unproven ---- + + [Fact] + public async Task A_link_continues_in_its_own_slot_and_leaves_a_parallel_sign_in_attempt_alive() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var parallel = await host.StartAsync(); + var parallelCookie = $"{parallel.CookieName}={parallel.Cookie}"; + var start = await host.StartLinkAsync(session, extraCookies: parallelCookie); + // The parallel attempt holds slot 0, so the link lives in the other slot. + Assert.Equal(HeimdallAttemptCookie.SlotNames[0], parallel.CookieName); + Assert.Equal(HeimdallAttemptCookie.SlotNames[1], start.CookieName); + + var b = await StepB(host, session, start, account); + var awaiting = new Step(b.Response, b.Next, b.Token); + var pending = host.Heimdall.RegisterPendingLink(); + var c = await StepC(host, session, awaiting.Next, pending); + var d = await StepD(host, session, c.Next, account); + + // Every continuation wrote the link's own slot and none touched the parallel attempt's slot. + foreach (var response in new[] { b.Response, c.Response }) + { + Assert.NotNull(HeimdallHost.SetCookieHeader(response, start.CookieName!)); + Assert.Null(HeimdallHost.SetCookieHeader(response, parallel.CookieName!)); + } + + Assert.Equal(start.CookieName, b.Next.CookieName); + Assert.Equal(start.CookieName, c.Next.CookieName); + Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Null(HeimdallHost.SetCookieHeader(d.Response, parallel.CookieName!)); + + var signIn = await host.CompleteWithHeaderAsync( + parallel.Nonce, host.CodeFor(parallel, HeimdallShapes.NewAccountId()), parallelCookie); + Assert.True(signIn.SignedIn); + } + + [Theory] + [InlineData("9")] + [InlineData("-1")] + [InlineData("4")] + public async Task An_attempt_cookie_with_an_undefined_purpose_is_no_attempt_and_asks_Heimdall_for_nothing(string purpose) + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var nonce = "crafted-" + Guid.NewGuid().ToString("N"); + var protector = host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1"); + var crafted = protector.Protect( + $$"""{"Nonce":"{{nonce}}","Provider":"discord","RequiresMemberAccess":false,"ReturnPath":"/App/Mill","ExpiresAt":"{{host.Clock.Now.AddMinutes(5):O}}","Purpose":{{purpose}},"ExpectedAccountId":"{{account}}","PendingLinkId":"p"}"""); + var attempt = new StartedAttempt( + nonce, HeimdallAttemptCookie.SlotNames[0], crafted, $"https://localhost/auth/heimdall/complete?attempt={nonce}", null!); + var starts = host.Heimdall.StartBodies.Count; + var redeems = host.Heimdall.RedeemCalls; + + var response = await host.PostCompleteAsync( + new Dictionary + { + ["attempt"] = nonce, ["code"] = host.CodeFor(attempt, account), ["pendingLinkId"] = "p" + }, + Jar(session, attempt)); + + Assert.Equal(ErrorRedirect, Location(response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(redeems, host.Heimdall.RedeemCalls); + Assert.Empty(host.Heimdall.ConfirmBodies); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task An_await_step_without_a_pending_link_id_is_refused_before_Heimdall_hears_of_it(string pendingLinkId) + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var b = await StepB(host, session, start, account); + var starts = host.Heimdall.StartBodies.Count; + var redeems = host.Heimdall.RedeemCalls; + + var c = await StepC(host, session, b.Next, pendingLinkId); + + Assert.Equal(Status + "?link=error", Location(c.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(redeems, host.Heimdall.RedeemCalls); + Assert.Empty(host.Heimdall.ConfirmBodies); + AssertAttemptCleared(c.Response, b.Next); + } + + [Theory] + [InlineData("")] + [InlineData("{}")] + [InlineData("""{"status":"pending"}""")] + [InlineData("""{"status":"Linked"}""")] + [InlineData("not json")] + public async Task A_201_confirm_that_does_not_say_linked_is_not_shown_as_linked(string body) + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + host.Heimdall.ConfirmStatus = HttpStatusCode.Created; + host.Heimdall.ConfirmBody = body; + + var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); + + Assert.Equal(Status + "?link=error", Location(d.Response)); + Assert.Single(host.Heimdall.ConfirmBodies); + AssertAttemptCleared(d.Response, journey.C.Next); + } } diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index a051db0..a074ede 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -215,6 +215,9 @@ public sealed class FakeHeimdall : HttpMessageHandler /// When set, every links/confirm answers this status with Heimdall's error code for it. public HttpStatusCode? ConfirmStatus { get; set; } + /// When set with , the body of that answer verbatim (empty string included). + public string? ConfirmBody { get; set; } + private readonly HashSet _pendingLinks = new(StringComparer.Ordinal); /// Registers a pending link Heimdall will confirm once for any bearer. @@ -330,6 +333,11 @@ public sealed class FakeHeimdall : HttpMessageHandler if (ConfirmStatus is { } forced) { + if (ConfirmBody is not null) + { + return Json(forced, ConfirmBody); + } + return forced switch { HttpStatusCode.Conflict => Json(forced, """{"error":"identity_linked_elsewhere"}"""), @@ -621,7 +629,8 @@ public sealed class HeimdallHost : TestWebApplicationFactory /// The account page's link button as a browser posts it: the page is fetched for its antiforgery token and cookie /// (when ), then the form is posted with the session. Without a session nothing is fetched. ///
- public async Task StartLinkAsync(string? session, bool antiforgery = true, HttpMethod? method = null) + public async Task StartLinkAsync( + string? session, bool antiforgery = true, HttpMethod? method = null, string? extraCookies = null) { using var client = NewClient(); var cookies = new List(); @@ -643,6 +652,11 @@ public sealed class HeimdallHost : TestWebApplicationFactory cookies.Add($"{BifrostCookies.Antiforgery}={CookieValue(page, BifrostCookies.Antiforgery)}"); } + if (extraCookies is not null) + { + cookies.Add(extraCookies); + } + var before = Heimdall.StartBodies.Count; using var request = new HttpRequestMessage(method ?? HttpMethod.Post, LinkPostUrl); if (request.Method == HttpMethod.Post) From 1739d5552a9745dedffc2ff703b31a59151fd822 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 19:23:43 +0000 Subject: [PATCH 044/102] Refuse production startup when the Heimdall app secret is blank A missing secret silently switched Discord and Patreon member sign-in off. Outside Development and Testing the validator now fails naming Heimdall:AppSecretFile and Heimdall:AppSecret, never a value. Co-Authored-By: Claude Sonnet 5.5 --- docs/heimdall-app-secret-file.md | 3 +- .../Shared/StartupConfigurationValidator.cs | 7 ++++ tests/Bifrost.Web.Tests/HostTrustTests.cs | 34 ++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/heimdall-app-secret-file.md b/docs/heimdall-app-secret-file.md index 2f8c283..bee017c 100644 --- a/docs/heimdall-app-secret-file.md +++ b/docs/heimdall-app-secret-file.md @@ -8,7 +8,8 @@ - File set but missing, unreadable, a directory, or empty: startup fails in every environment. The message names `Heimdall:AppSecretFile` and the path, never the secret. - Only `Heimdall__AppSecret` set: still works; startup logs a deprecation warning (without the value). -- Neither set: Discord and Patreon sign-in stay off, as before. +- Neither set: startup fails outside Development and Testing, naming `Heimdall:AppSecretFile` and `Heimdall:AppSecret` + (`StartupConfigurationValidator`). Development and Testing still start, with Discord and Patreon sign-in off. Under systemd, pass the file with `LoadCredential=heimdall-app-secret:` and `Environment=Heimdall__AppSecretFile=%d/heimdall-app-secret`. diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 2ab16cf..58fe2fb 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -105,6 +105,13 @@ public sealed class StartupConfigurationValidator( failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled."); } + // The options are already resolved, so a secret from Heimdall:AppSecretFile or Heimdall:AppSecret lands in AppSecret. + // Blank would silently switch Discord and Patreon member sign-in off. + if (string.IsNullOrWhiteSpace(heimdallOptions.Value.AppSecret)) + { + failures.Add("Heimdall:AppSecretFile (or Heimdall:AppSecret) must supply the Heimdall app secret outside development."); + } + if (stripeOptions.Value.EnableCheckout) { if (!stripeOptions.Value.IsCheckoutConfigured) diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 717062d..7abadb8 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -145,6 +145,36 @@ public sealed class HostTrustTests : IClassFixture Assert.DoesNotContain(canary, refusal.Message); } + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Production_without_a_heimdall_app_secret_refuses_startup_naming_the_options(string blank) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", appSecret: blank)); + + Assert.Contains("Heimdall:AppSecretFile", refusal.Message); + Assert.Contains("Heimdall:AppSecret", refusal.Message); + } + + [Fact] + public void Production_with_a_heimdall_app_secret_passes_and_the_refusal_never_echoes_it() + { + Validate(keysPath: "/keys", appSecret: ValidAppSecret); + + var refusal = Assert.Throws( + () => Validate(keysPath: null, appSecret: ValidAppSecret)); + Assert.DoesNotContain(ValidAppSecret, refusal.Message); + } + + [Theory] + [InlineData("Development")] + [InlineData("Testing")] + public void Development_and_Testing_start_without_a_heimdall_app_secret(string environment) + { + Validate(keysPath: null, environment: environment, appSecret: ""); + } + [Fact] public void Production_without_network_tag_secret_fails_and_never_echoes_values() { @@ -315,12 +345,14 @@ public sealed class HostTrustTests : IClassFixture } private const string ValidTagSecret = "tag-secret-0123456789-0123456789-abcdef"; + private const string ValidAppSecret = "app-secret-5c0e91d4"; private static void Validate( string? keysPath, string environment = "Production", string? connectionString = "Host=db", string? tagSecret = ValidTagSecret, + string appSecret = ValidAppSecret, int ipv4Length = 32, int ipv6Length = 56, string[]? allowedOrigins = null, @@ -344,7 +376,7 @@ public sealed class HostTrustTests : IClassFixture hostEnvironment, Options.Create(new GitHubOAuthOptions { ClientId = "id", ClientSecret = "secret" }), Options.Create(new GitHubAppOptions { EnableWebhookSync = false }), - Options.Create(new HeimdallOptions { EnablePatronSupportIntake = false }), + Options.Create(new HeimdallOptions { EnablePatronSupportIntake = false, AppSecret = appSecret }), Options.Create(new StripeOptions()), Options.Create(new BifrostHostOptions { From 1771f157895b3f3457eef197180629bd9e1c32b1 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 19:27:57 +0000 Subject: [PATCH 045/102] Soul fixes: deploy doc says production refuses a blank secret; name the echo test for what it checks Co-Authored-By: Claude Sonnet 5.5 --- docs/deploy-main-to-live.md | 9 +++++---- tests/Bifrost.Web.Tests/HostTrustTests.cs | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/deploy-main-to-live.md b/docs/deploy-main-to-live.md index 9e50e98..2e635cb 100644 --- a/docs/deploy-main-to-live.md +++ b/docs/deploy-main-to-live.md @@ -60,9 +60,10 @@ New since the live release, and not read by the validator: stops startup in every environment, with a message that names the option and path and never the value. Ops steps: `docs/heimdall-app-secret-file.md`. - **`Heimdall__AppSecret`** (deprecated; use `Heimdall__AppSecretFile`; startup logs a warning when it is set): required for - Discord and Patreon sign-in. Without it `HeimdallOptions.IsDiscordConfigured` and - `IsPatreonConfigured` are false and those sign-ins stop working (GitHub sign-in is unaffected); startup does not fail, - so a missing key shows only at the live check. The value is Heimdall's app secret for slug `bifrost` + Discord and Patreon sign-in. Outside Development and Testing startup fails when neither this nor + `Heimdall__AppSecretFile` supplies a non-blank secret (the message names both options, never a value), because + `HeimdallOptions.IsDiscordConfigured` and `IsPatreonConfigured` would otherwise be false and those sign-ins silently + stop working (GitHub sign-in is unaffected). The value is Heimdall's app secret for slug `bifrost` (`GC_ACCESS_APP_BIFROST_SHARED_SECRET` on the Heimdall side). Bifrost sends it as `x-heimdall-app-secret`, redacted from logs. - **`Bifrost__Oidc__*`**: optional. Absent, the OIDC issuer is not mapped and discovery answers 404. Present, it must be @@ -135,7 +136,7 @@ All must hold before step 1. A missing one means the window is not used. not a workstation. 6. **Env diff done.** Every row of the Configuration table is marked present by the reader, `ConceptVotes__NetworkTagSecret` included and at least 32 characters (a count, not the value), and the `Heimdall__AppSecret` line is in `bifrost.env` - (mode 600, owner `bifrost`) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved. + (mode 600, owner `bifrost`; a build with the empty-secret refusal does not start without it or `Heimdall__AppSecretFile`) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved. 7. **Quiet.** No motion closing and no known vote in flight inside the window. Check the motions page. Members told that everyone is signed out once (see "What moves"). 8. **Watchdog.** `bifrost-watchdog-notice.timer` fires one minute after boot and one minute after each run diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 7abadb8..8ad0a0a 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -158,7 +158,7 @@ public sealed class HostTrustTests : IClassFixture } [Fact] - public void Production_with_a_heimdall_app_secret_passes_and_the_refusal_never_echoes_it() + public void Production_with_a_heimdall_app_secret_passes_and_other_refusals_never_echo_it() { Validate(keysPath: "/keys", appSecret: ValidAppSecret); From f04b20feed75f8e8484022f960998b295ae3f94a Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 22:44:53 +0000 Subject: [PATCH 046/102] docs(guest-accounts): re-read references at main e58e953; OIDC chat tier is already any non-Suspended account, owed change is Left/Erased Docs only. Refreshes stale file and line references, drops the claim that OIDC must move to Status == Active (main already admits any non-Suspended account with a live training-notice receipt), names the new IsActiveMember callers, and updates the provider-list and Heimdall PR #6 notes. Co-Authored-By: Claude Sonnet 5.5 --- docs/guest-accounts.md | 47 ++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/docs/guest-accounts.md b/docs/guest-accounts.md index 5471e47..9638563 100644 --- a/docs/guest-accounts.md +++ b/docs/guest-accounts.md @@ -10,7 +10,8 @@ sletz (Faust developer). Aqua decides on `#aquasynth` access, so the first grant 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`). +(`src/app-profiles.ts`, `docs/app-profiles/bifrost.md`). File and line references re-read at Bifrost main `e58e953` and Heimdall main +`afa1b74` on 2026-11-12; where main moved the subject, the text says so. Nothing in this design has landed on main. ## The shape in one paragraph @@ -25,14 +26,17 @@ user stay attached. - `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. + (`CurrentBifrostActor.cs:17`, `Status == Active`) is the one test behind `/App`, bridge transport (`BridgeActionService.cs:492`), motion votes + (`Membership.EffectiveVotingWeight`, `BifrostModels.cs:361`, read at `MotionGovernanceService.cs:72`), concept votes (`VoterIdentity.For`, + `VoterIdentity.cs:20`, member versus account class), Mind moderation (`MindIntakeService.cs:241`) and the fork desk's subject check + (`OidcEndpoints.ActiveMemberIdAsync`, `OidcEndpoints.cs:183-194`). The OIDC chat tier is not behind it: main lets any account that is + not `Suspended` and holds a live training-notice receipt sign in to chat (`OidcEndpoints.cs:55-65` and `ChatRefusalAsync`, `:164-180`, + called at `:127` and `:144`; `docs/oidc-issuer.md` l.72-77), and the back-channel logout reconciler (`OidcLogoutReconciler.cs`, 30 s + interval, l.34) ends sessions of suspended or deleted subjects, never of a chat-tier account for lacking membership. - `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. +- Invites are `MembershipInvitation`, keyed by GitHub login (`MembershipSynchronizationService.cs:39-41`, + `Members/Index.cshtml.cs:69-71`). 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 @@ -47,9 +51,10 @@ Two authorities cannot both say "may this account vote". Today `Status == Active - `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. +- OIDC needs no change for guests: main already admits any non-`Suspended` account with a live training-notice receipt to chat (see + What exists today), so a guest, user or patron holds a chat session, and a member demoted to `Suspended` loses it. The change this + design owes is the two new statuses: `Left` and `Erased` must be refused at the same three places (`OidcEndpoints.cs:62`, + `ChatRefusalAsync` `:174`) and ended by the reconciler, which today looks for `Suspended` only. That 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`. @@ -94,19 +99,21 @@ issuer may not have). The issuer may still restrict `ExpectedProvider`; open for 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 +Gap, drift found while reading, still true on main: Bifrost's `AllowedProviders` is `discord, patreon, twitch` +(`HeimdallAuthService.cs:37-42`), but Heimdall's `bifrost` app profile allows `discord, patreon, google` +(`app-profiles.ts:152`, `bifrostProfile`). Twitch cannot start in Heimdall and Google is refused by Bifrost as a sign-in provider. +Main's only Google path is the explicit link by an already signed-in member (`HeimdallAttemptPurpose.LinkAwaitGoogle`, +`HeimdallAuthService.cs:672`, PRs #81 and #82); it never signs anyone in, so it does not close the gap. 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 +A guest may: sign in, hold a Bifrost cookie, hold a chat session through OIDC (after accepting the training notice, as any chat account does on main, `ConsentEndpoints.HasLiveReceiptAsync`, `ConsentEndpoints.cs:146`), 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); + `Active` (`BifrostModels.cs:361-363`), 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 @@ -186,8 +193,8 @@ no refresh; the actor is read from the database each request (`CurrentBifrostAct 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 +from granted rooms through the same effector as grants. Once the reconciler and the chat refusals name `Left` (see Decision), +it ends the MAS session within its 30 s interval, 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. @@ -265,12 +272,12 @@ No such publisher or witness exists; Bifrost has no `.cc` export for accounts to - 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. +- Heimdall has no erase path (re-checked at `afa1b74`: no account-delete or unlink route in `src`, and `docs/answer-collector-consent.md`, merged as Heimdall PR #6, redacts by exit and says nothing of guests). 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 +- Tie to the answer-collector consent doc (Heimdall PR #6, merged; `docs/answer-collector-consent.md`): 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 (l.197). 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. From 25ce117f16d2d3c9ba31a1d59fad2e5c7c046895 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 22:44:54 +0000 Subject: [PATCH 047/102] docs(feedback-receiver): re-read references at main e58e953 Docs only. Mouth line numbers (shifted by about 100 lines of mouth code), bridge.md, packages.lock.json refs refreshed; Heimdall PR #6 is merged. Co-Authored-By: Claude Sonnet 5.5 --- docs/feedback-receiver.md | 49 +++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/docs/feedback-receiver.md b/docs/feedback-receiver.md index 5342b09..355296f 100644 --- a/docs/feedback-receiver.md +++ b/docs/feedback-receiver.md @@ -6,7 +6,7 @@ Status: **design, nothing implemented.** Written for `heimdall-body:follow_up:ch builds on are Heimdall's drafts on guests and hiding (`/opt/commons/handoff/heimdall-feedback-pages-rulings.md`, cited as R4a to R6f). Draft; do not merge. -Read at Bifrost main `b1725a1` (2026-11-12): `tools/persona-matrix-mouth.mjs`, `tools/persona-chat-documents.mjs`, +Read at Bifrost main `b1725a1` (2026-11-12), line references re-read at `e58e953` (2026-11-12): `tools/persona-matrix-mouth.mjs`, `tools/persona-chat-documents.mjs`, `src/Bifrost.Web/Features/{Oidc,Desk,Registration,Shared}/*`, `Domain/BifrostModels.cs`, `Program.cs`, `docs/oidc-issuer.md`, `docs/ratatoskr-voting-auth.md`; the deployed trees under `/srv/bifrost` were last read at an earlier main (section 9). I could not read Eureka's receiver notes in #minds, and `huginn-feedback-kinds-proposal.md` was not in `/opt/commons/handoff` when I looked. Where @@ -38,31 +38,31 @@ authority and never becomes `answerer`. ## 2. The mouth's pending/ack path, read from source -Verified in `tools/persona-matrix-mouth.mjs` and `tools/persona-chat-documents.mjs` at main `b1725a1`: +Verified in `tools/persona-matrix-mouth.mjs` and `tools/persona-chat-documents.mjs`; line numbers re-read at main `e58e953` (2026-11-12), where the mouth has grown by about 100 lines since `b1725a1` and nothing else in this section changed: -- **Pending is Matrix mail only, held in memory.** `pending` (mouth l.144) is filled in one place, `syncOnce` - (l.441; `pending.push(...made)` l.496), from Matrix `/sync` events. No function accepts a delivery from elsewhere. +- **Pending is Matrix mail only, held in memory.** `pending` (mouth l.156) is filled in one place, `syncOnce` + (l.543; `pending.push(...made)` l.602), from Matrix `/sync` events. No function accepts a delivery from elsewhere. - **A request is non-consuming; an ack consumes.** The pending requests carry `afterSeq`; the mouth answers - `pendingAfter(afterSeq)` (l.614) paged to 256 KiB (`pageOf`, l.944-972; `limits.replyBytes`, documents l.53). `chat_ack` carries `throughSeq`; `ack` - (l.556-561) drops every delivery at or below it, releases stored images (`images?.acked`) and advances the Matrix cursor. An ack posts nothing to - Matrix: her public `m.read` receipt follows a separate `chat_read` document (header l.11-13, `chat_read` branch l.1027). The endpoint serves a closed list of kinds - (`served`, l.992) and answers any other loudly with `chat_refusal` `UnknownKind` (l.999-1001); a request for another Persona gets `UnknownPersona`. -- **At-least-once.** The Matrix cursor is persisted only when every delivery of a batch is acked (`commit`, l.513-521), so a - restart before the ack re-delivers from Matrix. `seqCounter` (l.143) is persisted whenever a round made deliveries (l.508), so + `pendingAfter(afterSeq)` (l.720) paged to 256 KiB (`pageOf`, l.1055-1084; `limits.replyBytes`, documents l.53). `chat_ack` carries `throughSeq`; `ack` + (l.662-665) drops every delivery at or below it, releases stored images (`images?.acked`) and advances the Matrix cursor. An ack posts nothing to + Matrix: her public `m.read` receipt follows a separate `chat_read` document (header l.10-13, `chat_read` branch l.1138). The endpoint serves a closed list of kinds + (`served`, l.1103) and answers any other loudly with `chat_refusal` `UnknownKind` (l.1112); a request for another Persona gets `UnknownPersona`. +- **At-least-once.** The Matrix cursor is persisted only when every delivery of a batch is acked (`commit`, l.619-627), so a + restart before the ack re-delivers from Matrix. `seqCounter` (l.155) is persisted whenever a round made deliveries (l.614), so sequence numbers never repeat across a restart. -- **Backpressure.** At 500 unacked (`MAX_PENDING`, l.58) `syncOnce` stops reading (l.447). A truncated round (pending cap l.475, reply-read cap l.476) - leaves the cursor where it was (l.501). -- **A delivery that will not encode is skipped, not held.** `pageOf` logs and omits it (l.949-951); the caller's +- **Backpressure.** At 500 unacked (`MAX_PENDING`, l.63) `syncOnce` stops reading (l.550). A truncated round (pending cap l.578, reply-read cap l.579) + leaves the cursor where it was (l.607). +- **A delivery that will not encode is skipped, not held.** `pageOf` logs and omits it (l.1060-1062); the caller's next ack covers its seq, so it is dropped. Harmless for a chat line, wrong for a durable record. -- **The endpoint is loopback RUDP.** `startChatEndpoint` (l.980) refuses a non-loopback host (l.981). It is another process +- **The endpoint is loopback RUDP.** `startChatEndpoint` (l.1091) refuses a non-loopback host (l.1092). It is another process from Bifrost.Web, which has no way to hand the mouth anything. - **The delivery tuple is closed and versioned.** `deliveryTuple` is twelve fields (the twelfth is an optional stored-image reference), the pending answer `gamecult.persona.chat_pending.v2` (`persona-chat-documents.mjs` l.216-224). v3 adds `basis` and `form` (14 fields) and a room tail (`chat_pending.v3`, - asked for by `chat_pending_request.v2`); v4 adds `reply_to` (15 fields, `chat_pending_request.v3`, served only with `CHAT_PENDING_V4=1`, l.1074). No version has a source or + asked for by `chat_pending_request.v2`); v4 adds `reply_to` (15 fields, `chat_pending_request.v3`, served only with `CHAT_PENDING_V4=1`, `replyLinkFromEnv` l.1185). No version has a source or kind field. A delivery cannot say it is not a Matrix message. -- **A fetch kind exists, off by default.** `chat_fetch_request` is served only with `CHAT_FETCH=1` (l.992, l.1076): an optional served kind is a precedent for adding kinds +- **A fetch kind exists, off by default.** `chat_fetch_request` is served only with `CHAT_FETCH=1` (`served`, l.1103; `fetchFromEnv`, l.1187): an optional served kind is a precedent for adding kinds to `served`, and the way a kind is withheld until its consumer is ready. -- **The consumer is not in this repo.** The mouth's header (l.8-11) says the heartbeat polls with a non-consuming +- **The consumer is not in this repo.** The mouth's header (l.10-11) says the heartbeat polls with a non-consuming request and decides who runs. I could not confirm that from source. What follows from it: @@ -77,11 +77,11 @@ PR #24 section 3 names the carrier: a new delivery document, `gamecult.persona.f the mouth's pending/ack path. Source adds one constraint on how it rides: - **It cannot be a variant inside `chat_pending`.** Each version is a closed positional tuple (twelve, fourteen or fifteen fields) encoded by - `pageOf` (l.949), so a different tuple in that page breaks every current consumer, and a text frame in `text` is forgeable by any Matrix member. + `pageOf` (l.1060), so a different tuple in that page breaks every current consumer, and a text frame in `text` is forgeable by any Matrix member. - **Option B, kept in shape:** the delivery document has its own request, page and ack kinds (`feedback_pending_request`, `feedback_pending`, `feedback_ack`), its own seq space and a durable cursor over the receiver's store. The Matrix kinds, the Matrix cursor and `MAX_PENDING` are untouched. Per the decision below the kinds are served by the receiver over - HTTPS, not added to the mouth's `served` list (l.992). Confidence medium: the Persona-side consumer, which I cannot see, must learn them. -- **Why main's changes reinforce B.** A chat `ack` releases stored images and advances the Matrix `/sync` cursor (l.556-561). A feedback ack must do neither, so it cannot + HTTPS, not added to the mouth's `served` list (l.1103). Confidence medium: the Persona-side consumer, which I cannot see, must learn them. +- **Why main's changes reinforce B.** A chat `ack` releases stored images and advances the Matrix `/sync` cursor (l.662-665). A feedback ack must do neither, so it cannot share the `chat_ack` kind or its `ack` function. The chat pending answer is already versioned three times, so a new field in that tuple is a new document version for every consumer, which a feedback record must not force. - Rejected: injecting records into `pending` as synthetic Matrix deliveries (no inlet, forgeable, lost on restart); @@ -117,7 +117,7 @@ An answer to a question that has settled is kept and the page says so (PR #24 se PR #24 section 7a rules **route 2** for people: Ratatoskr presents an OIDC access token that Bifrost issued, and Bifrost verifies it itself. Meta ruled on 2026-11-12 that Minds authenticate to the receiver through an OIDC client with an audience, as the desk does (4b). This section records what -Bifrost's source does on main `b1725a1`. +Bifrost's source does on main `e58e953` (the Oidc, Desk and Registration files cited here are unchanged since `b1725a1`, bar `DeskEndpoints.cs:49`, which registers `UnixSocketDeskDoor`). ### What main does with tokens @@ -318,9 +318,9 @@ not the mouth, and not the `bifrost-feedback` user of `bifrost-persona-feedback. - **The C# runtime fit is "needs work" (Eyes).** `CultLib` C# (`src/GameCult.Caching` and `GameCult.Caching.MessagePack`, about 10.6k lines, netstandard2.1, MIT) fits net8.0, but: - **No published package exists**: nuget.org has none, the `cultlib-v` tags stop at 1.0.32, and packaging is a PowerShell local feed (`CultLib` `docs/nuget-packaging.md`). Bifrost has no `nuget.config` and no vendor directory. **The consumption route (checkout `ProjectReference`, local feed, or vendoring) is an open fork for Eureka and Libby.** - - **Version conflict (predicted, not run):** `Caching.MessagePack` needs System.Text.Json 9.0.10; Bifrost's Direct pin is `[8.0.5, )` (`packages.lock.json:97-101`), so restore likely fails with NU1605. + - **Version conflict (predicted, not run):** `Caching.MessagePack` needs System.Text.Json 9.0.10; Bifrost's Direct pin is `[8.0.5, )` (`packages.lock.json:108-112`), so restore likely fails with NU1605. - **It drags CultMath** (MIT AND MPL-2.0 AND Apache-2.0, shaders) into a web service. - - **Writer precedent:** the Node sidecar already writes `/srv/bifrost/persona-feedback/deliveries.cc` (`docs/bridge.md:285`), so the receiver is the second `.cc` writer in the app and the first in C#. + - **Writer precedent:** the Node sidecar already writes `/srv/bifrost/persona-feedback/deliveries.cc` (`docs/bridge.md:294`), so the receiver is the second `.cc` writer in the app and the first in C#. - **Gaps owed to Libby (CultLib steward); no local workaround is built here:** (1) no consumable C# package; (2) the System.Text.Json pin conflict; (3) the CultMath transitive closure; (4) `src/GameCult.Caching/README.md:310` says single-writer, no competing writers, which contradicts the store-composition contract's lock; (5) no documented stale `.tmp` / crash procedure; (6) no documented backup-while-writing contract for a pair of stores; (7) no cross-runtime test pairing a Linux C# writer with a Rust reader. @@ -360,6 +360,5 @@ Rulings of 2026-11-12 closed these, and they are no longer forks: whether erase - **The Persona side must learn the three feedback kinds** and pull them over HTTPS with its bearer (section 2); the heartbeat that polls the mouth is outside this repo and is not the carrier. - **Deploy is behind main.** The deployed mouth is `/srv/bifrost/persona-chat/runtime/current`, release `e396305`, read at an earlier main; main has since added the image path, reply links, `CHAT_FETCH` and the v3/v4 pending answers. The deployed web app is `/srv/bifrost/app/current` at `67072b2`, which predates the desk, the chat-tier issuer change and the consent gate on main. I did not re-read `/srv/bifrost` for this pass. Anything in this design runs on a release that does not exist yet. -- **Unread:** Eureka's receiver notes in #minds, `huginn-feedback-kinds-proposal.md`, MAS's side of the session, and Heimdall PR #6 (the - answer-collector consent doc; the page's "guest answers are collected for nothing but delivery" is unchecked against it). +- **Unread:** Eureka's receiver notes in #minds, `huginn-feedback-kinds-proposal.md`, MAS's side of the session, and Heimdall PR #6 (merged, `docs/answer-collector-consent.md`; it drops a sender with no linked member at l.197 and does not mention guests; the page's "guest answers are collected for nothing but delivery" is unchecked against it). - The receiver writes nothing to Matrix and nothing to Huginn. From d29f8014f9447836e081e5dd64f43deca04376a5 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 22:46:53 +0000 Subject: [PATCH 048/102] docs(guest-accounts): steward rulings: stored Role, form-posted invite code (no token in a URL), provider lists narrowed to the intersection, no collector member_id for guests Co-Authored-By: Claude Sonnet 5.5 --- docs/guest-accounts.md | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/guest-accounts.md b/docs/guest-accounts.md index 9638563..14c4d99 100644 --- a/docs/guest-accounts.md +++ b/docs/guest-accounts.md @@ -13,6 +13,15 @@ Read at Bifrost `bf8ff62` (`src/Bifrost.Web/Domain/BifrostModels.cs`, `Features/ (`src/app-profiles.ts`, `docs/app-profiles/bifrost.md`). File and line references re-read at Bifrost main `e58e953` and Heimdall main `afa1b74` on 2026-11-12; where main moved the subject, the text says so. Nothing in this design has landed on main. +## Rulings of the steward (Heimdall, 2026-11-12) + +These four are the Bifrost steward's; they replace the recommendations further down. Eureka's, Aqua's and Meta's items are not ruled here. + +1. **Name and shape: a stored `Membership.Role` (enum `AccountRole`), not a boolean.** One stored field is one reading, and an account with no role is denied by default (least privilege). `MemberRole` keeps its name and meaning; renaming it is not needed, and nothing existing is removed. +2. **Invite: a single-use code redeemed from a form, never a token in a URL.** The issuer is shown the code once; the guest pastes it into `POST /invite/guest` (a body, not a path or query), so it reaches no log, referrer or browser history. Hashed at rest, seven days, one use, receipted on issue, accept and revoke. The issuer may bind `ExpectedProvider` (optional, narrows the invite). The sign-in return path carries only the Heimdall attempt, never the code. +3. **Provider lists: Bifrost narrows to the intersection with Heimdall's `bifrost` profile, `discord, patreon`; no widening.** Twitch cannot start in Heimdall, so dropping it removes nothing live; it logs a deprecation warning for one release before the entry goes (deprecate before remove). Google stays link-only (PRs #81, #82) and becomes a sign-in provider only when an issued invite needs it, by a profile change in Heimdall first. Which provider sletz uses is still the issuer's to learn. +4. **Collector: a guest has no `member_id` and none of their messages are collected.** A sender with no linked member is already dropped (`docs/answer-collector-consent.md` l.197 in Heimdall). A `member_id`, and a consent record under it, exists only on promotion to `member`. + ## The shape in one paragraph A guest is a `UserAccount` like any other: same `Id` (the OIDC `sub`), same `NormalizedBifrostIdentity` (the Matrix @@ -73,7 +82,7 @@ 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. | +| `CodeHash` | SHA-256 of a 256-bit random code. The code itself is shown to the issuer once 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. | @@ -81,14 +90,14 @@ approval, not to a guest). Fields: | `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. +The code is a bearer: whoever redeems it first is the guest. That is the cost of not needing sletz's +provider handle in advance, and it is why the code is single-use and short-lived and why the accept is shown +to the issuer. Ruled over binding the invite to a provider handle (which needs a Discord or Google id the +issuer may not have); the issuer may still set `ExpectedProvider` (see Rulings). It is carried in a form body, never in a URL. ## Sign-in via Heimdall -1. Guest opens `/invite/guest/`. Bifrost checks hash, expiry, unused, unrevoked, and stores the invite id in +1. Guest opens `/invite/guest`, pastes the code, and posts it. 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`). @@ -283,14 +292,9 @@ No such publisher or witness exists; Bifrost has no `.cc` export for accounts to ## 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. 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). +1. **Name and shape**: ruled (stored `Role`), see Rulings. +2. **Bearer link or provider-bound invite?** Ruled (single-use code from a form), see Rulings. +3. **Which Heimdall provider will sletz sign in with?** The lists are ruled (see Rulings); this is the issuer's to learn before the first invite. +4. **`member_id` for the answer collector**: ruled (none for a guest), see Rulings. 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 3b0a614ee900c9f77e33c10383460f48c83eec3e Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 22:47:05 +0000 Subject: [PATCH 049/102] docs(feedback-receiver): steward rulings: Ratatoskr client (2a, shared client, own audience) and explicit short token lifetimes Co-Authored-By: Claude Sonnet 5.5 --- docs/feedback-receiver.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/feedback-receiver.md b/docs/feedback-receiver.md index 355296f..89f1947 100644 --- a/docs/feedback-receiver.md +++ b/docs/feedback-receiver.md @@ -19,6 +19,13 @@ withhold an answer from its Mind in an emergency, with a logged receipt (section client with an audience, as the desk does (section 4b). Eureka's decisions on forks 2 to 5 of 2026-11-12 (`/opt/commons/handoff/bifrost-10-forks-2-5-recs-20261112.md`) are folded into sections 2, 4b, 6a, 7a and 9 and close those forks, except where section 9 says a point is still open. +## Rulings of the steward (Heimdall, 2026-11-12) + +Two of this doc's forks are the Bifrost steward's. Meta's, Eureka's and Libby's items are not ruled here. + +1. **Ratatoskr's client for people: reading 2a, with no third client.** The one public PKCE client the desk-credential cut seeds for Ratatoskr (only when `Bifrost:Oidc:Clients:Ratatoskr` is configured, so absent config changes nothing) also gets a scope `feedback.answer` that puts audience `bifrost-feedback-page` on the token. Authorization code with PKCE only, exact redirect URIs, no refresh token, the token only in an `Authorization` header and never in a URL, a query or a log. The audience is distinct from the Mind's `bifrost-feedback`, so a person's token never opens a Mind endpoint and a Mind's never opens the page (least privilege). Reading 2b, a Matrix token checked at the homeserver, is refused: it is a bearer for the person's whole Matrix account. The receiver writes a text-free receipt per accepted submission (receipts). +2. **Token lifetimes are explicit, short, and set in the issuer change.** `feedback.answer` access token: 15 minutes, because the Bifrost session cookie re-issues it silently. The Mind's `client_credentials` token: 10 minutes, one call to renew. No refresh token for either. The desk's lifetime is its own decision and is not touched here. Until the issuer change lands the OpenIddict default applies; this doc no longer relies on it. + ## 1. What the receiver owns | Concern | Owner | Receiver's part | @@ -217,7 +224,7 @@ line or a log. The seeder stores the hash Bifrost holds; the plaintext is handed | Receipt | none for a desk token (`OidcEndpoints.cs:104`) | none: a Mind is not a person entering text | Rejected (Eureka): reusing the mouth's signed envelope per request. It is motion-bound (`verb` is commit/reveal, `MotionId` in the signed text) and would need a new verb set. -Token lifetime is OpenIddict's default, believed one hour and unverified; set it explicitly in the issuer change. +Token lifetimes: ruled in Rulings of the steward (15 and 10 minutes, set explicitly in the issuer change). ## 5. Account-kind lookup From 96665a778b7c590dff6e04f7dd2ec4eafa47c765 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 03:28:33 +0000 Subject: [PATCH 050/102] docs(feedback-receiver): drop stale draft marker; steward merges the design Co-Authored-By: Claude Sonnet 5.5 --- docs/feedback-receiver.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/feedback-receiver.md b/docs/feedback-receiver.md index 89f1947..c0baad9 100644 --- a/docs/feedback-receiver.md +++ b/docs/feedback-receiver.md @@ -4,7 +4,6 @@ Status: **design, nothing implemented.** Written for `heimdall-body:follow_up:ch `chat-s3549-3549`. It is the receiver half of Ratatoskr's Mind feedback pages. The page spec is Ratatoskr PR #24, `docs/mind-feedback-pages.md` at b146000d, which I take as final here; its section 7a names this doc. The rulings it builds on are Heimdall's drafts on guests and hiding (`/opt/commons/handoff/heimdall-feedback-pages-rulings.md`, cited as R4a to R6f). -Draft; do not merge. Read at Bifrost main `b1725a1` (2026-11-12), line references re-read at `e58e953` (2026-11-12): `tools/persona-matrix-mouth.mjs`, `tools/persona-chat-documents.mjs`, `src/Bifrost.Web/Features/{Oidc,Desk,Registration,Shared}/*`, `Domain/BifrostModels.cs`, `Program.cs`, `docs/oidc-issuer.md`, From 59ecaa606b36432995c46cb9ec610e88f4d92023 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 19:35:40 +0000 Subject: [PATCH 051/102] Linked accounts page: list, link any admitted provider, unlink with step-up proof A member sees their Heimdall logins, links Discord, Patreon or Google, and unlinks one with a fresh Google step-up sign-in. Bifrost keeps no Heimdall token: each action is a fresh Heimdall sign-in started by Bifrost whose bearer lives inside the completion request. The list is kept five minutes in this process as non-secret fields only. The Status page's Google-only link button and handler are cut; the Linked accounts page owns link starts. HeimdallStartRequest gains stepUp. Heimdall's member routes use a client without the app secret. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Heimdall/HeimdallAuthService.cs | 333 ++++++-- .../Features/Heimdall/HeimdallCompletePage.cs | 6 + .../Features/Heimdall/HeimdallMemberClient.cs | 112 +++ .../Features/Heimdall/HeimdallRefusal.cs | 42 ++ .../Heimdall/LinkedAccountsSnapshots.cs | 35 + .../Pages/Membership/LinkedAccounts.cshtml | 104 +++ .../Pages/Membership/LinkedAccounts.cshtml.cs | 126 ++++ .../Pages/Membership/Status.cshtml | 15 +- .../Pages/Membership/Status.cshtml.cs | 47 +- src/Bifrost.Web/Program.cs | 6 + .../Fixtures/complete-page-harness.cjs | 4 + .../HeimdallCompletePageScriptTests.cs | 22 + .../HeimdallGoogleLinkTests.cs | 42 +- .../Bifrost.Web.Tests/LinkedAccountsTests.cs | 710 ++++++++++++++++++ .../NoThirdPartyAssetTests.cs | 1 + .../Support/HeimdallTestKit.cs | 170 ++++- 16 files changed, 1611 insertions(+), 164 deletions(-) create mode 100644 src/Bifrost.Web/Features/Heimdall/HeimdallMemberClient.cs create mode 100644 src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs create mode 100644 src/Bifrost.Web/Features/Heimdall/LinkedAccountsSnapshots.cs create mode 100644 src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml create mode 100644 src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs create mode 100644 tests/Bifrost.Web.Tests/LinkedAccountsTests.cs diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 8ab2add..96db4ab 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -20,7 +20,9 @@ namespace Bifrost.Web.Features.Heimdall; /// Heimdall sign-in. Start issues an attempt that lives only in a Data Protection-protected cookie in the /// browser that began it. The browser that Heimdall authenticated presents the completion code from its /// URL fragment; Bifrost redeems it at Heimdall itself and signs in only when that browser holds the -/// attempt's cookie and the redeemed return target names the same attempt. +/// attempt's cookie and the redeemed return target names the same attempt. The Linked accounts page's steps (list, +/// link, unlink) are the same machine with other attempt purposes: each is a fresh Heimdall sign-in whose bearer is +/// used inside that one request and dropped. /// public sealed class HeimdallAuthService( HttpClient httpClient, @@ -29,7 +31,9 @@ public sealed class HeimdallAuthService( BifrostIdentityService bifrostIdentityService, IOptions heimdallOptions, IDataProtectionProvider dataProtectionProvider, - TimeProvider timeProvider) + TimeProvider timeProvider, + HeimdallMemberClient memberClient, + LinkedAccountsSnapshots snapshots) { public const string ErrorRedirect = "/?auth=heimdall-error"; public const string AppSecretHeader = "x-heimdall-app-secret"; @@ -58,38 +62,16 @@ public sealed class HeimdallAuthService( } } - private const int RefusalBodyLimit = 4096; - /// - /// Names the HTTP status and, when the first 4 KiB of the body carry one, Heimdall's error code. The code - /// is shown only if it is a short snake_case token, so nothing else from the body (free text, tokens) - /// reaches a log. A body that cannot be read or parsed leaves the status alone: a refusal never becomes a 500. + /// Names the HTTP status and, when the body carries one, Heimdall's error code (a short snake_case token only, + /// see ), so nothing else from the body reaches a log. /// private static async Task RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var detail = $"HTTP {(int)response.StatusCode}"; - try - { - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - var head = new byte[RefusalBodyLimit]; - var length = 0; - while (length < head.Length && await stream.ReadAsync(head.AsMemory(length), cancellationToken) is > 0 and var read) - { - length += read; - } - - using var body = JsonDocument.Parse(head.AsMemory(0, length)); - if (body.RootElement.ValueKind == JsonValueKind.Object - && body.RootElement.TryGetProperty("error", out var error) - && error.ValueKind == JsonValueKind.String - && error.GetString() is { Length: > 0 and <= 64 } code - && code.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) - { - detail += ", " + code; - } - } - catch (Exception) when (!cancellationToken.IsCancellationRequested) + if (await HeimdallRefusal.CodeAsync(response, cancellationToken) is { } code) { + detail += ", " + code; } return $"Heimdall rejected the auth start ({detail})."; @@ -105,9 +87,13 @@ public sealed class HeimdallAuthService( HeimdallAttemptPurpose purpose = HeimdallAttemptPurpose.SignIn, string? expectedAccountId = null, string? pendingLinkId = null, - string? replaceSlot = null) + string? replaceSlot = null, + bool stepUp = false, + string? target = null) { - if (!AllowedProviders.Contains(provider)) + // The public sign-in routes offer AllowedProviders; the proofs of the Linked accounts page may also use Google + // (a step-up proof only Google can give a bifrost sign-in, an ordinary one for an account that holds Google). + if (!(purpose == HeimdallAttemptPurpose.SignIn ? AllowedProviders : ProofProviders).Contains(provider)) { throw new HeimdallAuthException("Unsupported Heimdall provider."); } @@ -128,14 +114,15 @@ public sealed class HeimdallAuthService( "sign_in", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), - entitlementPolicy); + entitlementPolicy, + stepUp ? true : null); // Headers only: a refusal body is read through RefusedStartMessageAsync's own 4 KiB bound, not buffered whole. using var startMessage = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{provider}/start") { Content = JsonContent.Create(request) }; - using var response = await httpClient.SendAsync(startMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using var response = await SendStartAsync(startMessage, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -156,36 +143,121 @@ public sealed class HeimdallAuthService( timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime, purpose, expectedAccountId, - pendingLinkId); + pendingLinkId, + target); return new HeimdallAuthStartResult( new Uri(payload.AuthorizationUrl, UriKind.Absolute), replaceSlot ?? ChooseSlot(requestCookies), Protect(attempt)); } - /// The provider a link's fresh sign-in uses: the one the person signed in with, else Discord. - public static string LinkSignInProvider(string? sessionProvider) => - sessionProvider is not null && AllowedProviders.Contains(sessionProvider) ? sessionProvider.ToLowerInvariant() : "discord"; + /// A start that cannot reach Heimdall is a refusal like any other, never a 500. + private async Task SendStartAsync(HttpRequestMessage message, CancellationToken cancellationToken) + { + try + { + return await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + throw new HeimdallAuthException("Heimdall could not be reached for the auth start."); + } + } /// - /// The account page's link button: an ordinary Heimdall sign-in whose attempt says it is only the proof for a - /// link, and which person it must prove. The session is never replaced by it. + /// The provider an ordinary proof sign-in uses: the one the person signed in with when the account still holds it, + /// else the first held one of Discord, Patreon, Google. With no list to go by, the session's provider, else Discord. + /// Signing in through a provider the account no longer holds would only mint an unrelated Heimdall account. /// - public Task StartLinkAsync( + public static string ProofProvider(string? sessionProvider, IReadOnlyCollection? held) + { + var session = sessionProvider is not null && ProofProviders.Contains(sessionProvider) ? sessionProvider.ToLowerInvariant() : null; + if (held is { Count: > 0 }) + { + foreach (var candidate in new[] { session, "discord", "patreon", "google" }) + { + if (candidate is not null && held.Contains(candidate, StringComparer.OrdinalIgnoreCase)) + { + return candidate; + } + } + } + + return session ?? "discord"; + } + + /// + /// The page's proof sign-ins. Each is a Heimdall sign-in for the account already signed in here, kept as an attempt + /// that says what it is the proof for (list, link, unlink) and which person it must prove. The session is never replaced. + /// + private Task StartProofAsync( + HeimdallAttemptPurpose purpose, + string provider, string sessionAccountId, - string? sessionProvider, string callbackBaseUrl, IEnumerable> requestCookies, + bool stepUp, + string? target, CancellationToken cancellationToken) => StartAsync( - LinkSignInProvider(sessionProvider), + provider, callbackBaseUrl, LocalReturnPath.Parse(LinkReturnPath), requireMemberAccess: false, requestCookies, cancellationToken, - HeimdallAttemptPurpose.LinkStart, - sessionAccountId); + purpose, + sessionAccountId, + stepUp: stepUp, + target: target); - public const string LinkReturnPath = "/Membership/Status"; + /// Reads the account's logins: an ordinary sign-in, then GET /v1/me/identities inside its completion. + public Task StartListAsync( + string sessionAccountId, string? sessionProvider, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) => + StartProofAsync( + HeimdallAttemptPurpose.ListProof, ProofProvider(sessionProvider, snapshots.Get(sessionAccountId)?.Select(x => x.Provider).ToArray()), + sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken); + + /// Links another provider: an ordinary sign-in as proof, then the link start for with its bearer. + public Task StartLinkAsync( + string sessionAccountId, string? sessionProvider, string targetProvider, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) + { + if (!LinkableProviders.Contains(targetProvider)) + { + throw new HeimdallAuthException("Unsupported provider to link."); + } + + return StartProofAsync( + HeimdallAttemptPurpose.LinkStart, ProofProvider(sessionProvider, snapshots.Get(sessionAccountId)?.Select(x => x.Provider).ToArray()), + sessionAccountId, callbackBaseUrl, requestCookies, false, targetProvider.ToLowerInvariant(), cancellationToken); + } + + /// + /// Unlinks one login: a step-up sign-in through Google, then POST /v1/me/identities/{id}/unlink with its bearer. The + /// identity must be on the account's current snapshot and the account must hold Google; otherwise no proof can be had + /// and Heimdall is not asked. + /// + public Task StartUnlinkAsync( + string sessionAccountId, string identityId, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) => + StartProofAsync( + HeimdallAttemptPurpose.UnlinkProof, StepUpProvider, sessionAccountId, callbackBaseUrl, requestCookies, true, identityId, cancellationToken); + + /// + /// The providers Heimdall's bifrost profile admits (src/app-profiles.ts, identityProviders): a link or a proof can use them. + /// Twitch is offered for sign-in here but the profile refuses it, so it is not linkable. + /// + public static readonly IReadOnlyList LinkableProviders = ["discord", "patreon", "google"]; + + private static readonly HashSet ProofProviders = new(LinkableProviders, StringComparer.OrdinalIgnoreCase); + + /// + /// The one provider whose step-up a bifrost sign-in can use (docs/service-contract.md, step-up: Discord and Patreon cannot + /// re-authenticate, Twitch and YouTube are not in the bifrost profile). Heimdall answers 503 until a Google client is configured. + /// + public const string StepUpProvider = "google"; + + public const string LinkReturnPath = "/Membership/LinkedAccounts"; ///
/// The slot the new attempt's cookie takes: the first slot that holds no live attempt, else the one whose @@ -237,7 +309,7 @@ public sealed class HeimdallAuthService( public HeimdallAttemptPurpose? PurposeOf(string? cookieValue, string? nonce) => Unprotect(cookieValue, nonce)?.Purpose; /// Redeems the code of an attempt and verifies the token it carries. The token is returned to the caller's stack frame and nowhere else. - private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken)> RedeemVerifiedAsync( + private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken, HeimdallRedeemResponse Redeemed)> RedeemVerifiedAsync( string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(code)) @@ -257,7 +329,7 @@ public sealed class HeimdallAuthService( } var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken); - return (identity, redeemed.AccessToken); + return (identity, redeemed.AccessToken, redeemed); } public async Task CompleteAsync( @@ -274,7 +346,7 @@ public sealed class HeimdallAuthService( throw new HeimdallAuthException("Heimdall completion is not a sign-in attempt."); } - var (identity, _) = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken); + var (identity, _, _) = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken); if (attempt.RequiresMemberAccess && !identity.HasMemberAccess) { throw new HeimdallAuthException("Heimdall did not grant Bifrost member access."); @@ -291,9 +363,10 @@ public sealed class HeimdallAuthService( } /// - /// One step of a Google link, chosen by the purpose of the attempt this browser holds. The browser's session must be - /// the account the link was begun for, checked before Heimdall is asked anything and again against the identity the - /// fresh sign-in proves. Each step consumes the held attempt: the next step is a new attempt in the same slot, or none. + /// One step of a Linked accounts action, chosen by the purpose of the attempt this browser holds. The browser's session + /// must be the account the action was begun for, checked before Heimdall is asked anything and again against the identity + /// the fresh sign-in proves. Each step consumes the held attempt: the next step is a new attempt in the same slot, or none. + /// The bearer a step redeems is used inside the step and dropped. /// public async Task ContinueLinkAsync( string callbackBaseUrl, @@ -318,7 +391,7 @@ public sealed class HeimdallAuthService( case HeimdallAttemptPurpose.LinkStart: { var (_, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); - var authorizationUrl = await StartGoogleLinkAsync(callbackBaseUrl, attempt, bearer, cancellationToken); + var authorizationUrl = await StartLinkProviderAsync(callbackBaseUrl, attempt, bearer, cancellationToken); return new HeimdallLinkStep(authorizationUrl.Url, cookieName, authorizationUrl.CookieValue, null); } @@ -330,7 +403,7 @@ public sealed class HeimdallAuthService( } var next = await StartAsync( - LinkSignInProvider(attempt.Provider), + attempt.Provider, callbackBaseUrl, attempt.ReturnPath, requireMemberAccess: false, @@ -351,19 +424,79 @@ public sealed class HeimdallAuthService( { // Membership follows the verified identity exactly as at sign-in; the session principal is not replaced. await UpsertUserAccountAsync(identity, attempt.Provider, grantMemberAccess: false, cancellationToken); + snapshots.Remove(identity.AccountId); } return new HeimdallLinkStep(null, cookieName, null, outcome); } + case HeimdallAttemptPurpose.LinkAwaitDirect: + { + // A provider other than Google attaches in Heimdall's callback; the browser returns with a link completion. + var (identity, _, redeemed) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken); + if (redeemed.Mode != "link" || !string.Equals(redeemed.Provider, attempt.Target, StringComparison.Ordinal)) + { + throw new HeimdallAuthException("Heimdall completion is not this link."); + } + + await UpsertUserAccountAsync(identity, attempt.Target!, grantMemberAccess: false, cancellationToken); + snapshots.Remove(identity.AccountId); + return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.Linked); + } + + case HeimdallAttemptPurpose.ListProof: + { + var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + var listed = await memberClient.ListAsync(bearer, cancellationToken); + if (listed.Call == HeimdallMemberCall.Ok) + { + snapshots.Put(identity.AccountId, listed.Identities!); + } + + return new HeimdallLinkStep(null, cookieName, null, listed.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Listed : OutcomeOf(listed.Call)); + } + + case HeimdallAttemptPurpose.UnlinkProof: + { + var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + var unlinked = await memberClient.UnlinkAsync(bearer, attempt.Target ?? string.Empty, cancellationToken); + switch (unlinked.Call) + { + case HeimdallMemberCall.Ok: + snapshots.Put(identity.AccountId, unlinked.Identities!); + break; + case HeimdallMemberCall.IdentityNotFound: + snapshots.Remove(identity.AccountId); + break; + } + + return new HeimdallLinkStep(null, cookieName, null, unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call)); + } + default: throw new HeimdallAuthException("Heimdall completion is not a link attempt."); } } + private static HeimdallLinkOutcome OutcomeOf(HeimdallMemberCall call) => call switch + { + HeimdallMemberCall.SignInRequired => HeimdallLinkOutcome.SignInRequired, + HeimdallMemberCall.StepUpRequired => HeimdallLinkOutcome.StepUpRequired, + HeimdallMemberCall.IdentityNotFound => HeimdallLinkOutcome.IdentityNotFound, + HeimdallMemberCall.LastIdentity => HeimdallLinkOutcome.LastIdentity, + _ => HeimdallLinkOutcome.Error + }; + /// Redeems a link attempt's code and requires it to prove the account the link was begun for. private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken)> RedeemOwnerAsync( string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) + { + var (identity, bearer, _) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken); + return (identity, bearer); + } + + private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken, HeimdallRedeemResponse Redeemed)> RedeemOwnerWithBodyAsync( + string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) { var proven = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken); if (!string.Equals(proven.Identity.AccountId, attempt.ExpectedAccountId, StringComparison.Ordinal)) @@ -374,18 +507,29 @@ public sealed class HeimdallAuthService( return proven; } - private async Task<(Uri Url, string CookieValue)> StartGoogleLinkAsync( + /// + /// Starts the link to the attempt's target provider with the member's bearer. Google attaches in two phases (the + /// callback writes a pending link, a second fresh sign-in confirms it); the other admitted providers attach in the callback. + /// A cookie written before the target existed names no provider and is a Google link. + /// + private async Task<(Uri Url, string CookieValue)> StartLinkProviderAsync( string callbackBaseUrl, HeimdallAttempt attempt, string bearer, CancellationToken cancellationToken) { + var target = attempt.Target ?? "google"; + if (!LinkableProviders.Contains(target)) + { + throw new HeimdallAuthException("Unsupported provider to link."); + } + var options = heimdallOptions.Value; var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); - using var message = new HttpRequestMessage(HttpMethod.Post, "/v1/oauth/google/start") + using var message = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{target}/start") { Content = JsonContent.Create(new HeimdallStartRequest( - options.AppSlug, "link", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), null)) + options.AppSlug, "link", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), null, null)) }; message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer); - using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using var response = await SendStartAsync(message, cancellationToken); if (!response.IsSuccessStatusCode) { throw new HeimdallAuthException(await RefusedStartMessageAsync(response, cancellationToken)); @@ -403,9 +547,10 @@ public sealed class HeimdallAuthService( false, attempt.ReturnPath, timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime, - HeimdallAttemptPurpose.LinkAwaitGoogle, + target == "google" ? HeimdallAttemptPurpose.LinkAwaitGoogle : HeimdallAttemptPurpose.LinkAwaitDirect, attempt.ExpectedAccountId, - null); + null, + target); return (new Uri(payload.AuthorizationUrl, UriKind.Absolute), Protect(awaiting)); } @@ -464,7 +609,7 @@ public sealed class HeimdallAuthService( private string Protect(HeimdallAttempt attempt) => _protector.Protect(JsonSerializer.Serialize(new AttemptWire( attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt, - attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId))); + attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId, attempt.Target))); /// The live attempt a cookie value protects when it belongs to this nonce, else null. private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) => @@ -488,7 +633,7 @@ public sealed class HeimdallAuthService( return new HeimdallAttempt( wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt, - wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId); + wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId, wire.Target); } catch (Exception error) when (error is CryptographicException or JsonException or FormatException) { @@ -504,7 +649,8 @@ public sealed class HeimdallAuthService( DateTimeOffset ExpiresAt, HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, string? ExpectedAccountId = null, - string? PendingLinkId = null); + string? PendingLinkId = null, + string? Target = null); private async Task UpsertUserAccountAsync( HeimdallVerifiedIdentity identity, @@ -659,51 +805,84 @@ public sealed record HeimdallAttempt( DateTimeOffset ExpiresAt, HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, string? ExpectedAccountId = null, - string? PendingLinkId = null); + string? PendingLinkId = null, + string? Target = null); /// /// What a held attempt is for. SignIn is the default, so a cookie written before purposes existed reads as a sign-in. -/// The link purposes are the fresh sign-ins and the Google round trip of an explicit link; they never sign anyone in. +/// The other purposes belong to the Linked accounts page: the fresh sign-ins that prove an account for a list, a link or +/// an unlink, and the provider round trips of a link. They never sign anyone in. Values are persisted in cookies: append only. /// public enum HeimdallAttemptPurpose { SignIn = 0, LinkStart = 1, LinkAwaitGoogle = 2, - LinkConfirm = 3 + LinkConfirm = 3, + ListProof = 4, + UnlinkProof = 5, + LinkAwaitDirect = 6 } +/// How a Linked accounts action ended. is its closed wire form. public enum HeimdallLinkOutcome { Linked, IdentityLinkedElsewhere, Expired, - Error + Error, + Listed, + Unlinked, + LastIdentity, + StepUpRequired, + IdentityNotFound, + SignInRequired, + ProofUnavailable, + ListExpired } /// -/// Where a link step sends the browser: to with a new attempt cookie in the held slot, or, when the -/// link is finished, back to the account page with . +/// Where a Linked accounts step sends the browser: to with a new attempt cookie in the held +/// slot, or, when the action is finished, back to the page with the outcome's code in ?result=. /// public static class HeimdallLinkOutcomes { - public static string Redirect(HeimdallLinkOutcome outcome) => HeimdallAuthService.LinkReturnPath + "?link=" + Code(outcome); + public const string ResultParameter = "result"; + + public static string Redirect(HeimdallLinkOutcome outcome) => + HeimdallAuthService.LinkReturnPath + "?" + ResultParameter + "=" + Code(outcome); public static string Code(HeimdallLinkOutcome outcome) => outcome switch { HeimdallLinkOutcome.Linked => "linked", HeimdallLinkOutcome.IdentityLinkedElsewhere => "identity_linked_elsewhere", HeimdallLinkOutcome.Expired => "expired", + HeimdallLinkOutcome.Listed => "listed", + HeimdallLinkOutcome.Unlinked => "unlinked", + HeimdallLinkOutcome.LastIdentity => "last_identity", + HeimdallLinkOutcome.StepUpRequired => "step_up_required", + HeimdallLinkOutcome.IdentityNotFound => "identity_not_found", + HeimdallLinkOutcome.SignInRequired => "sign_in_required", + HeimdallLinkOutcome.ProofUnavailable => "proof_unavailable", + HeimdallLinkOutcome.ListExpired => "list_expired", _ => "error" }; - /// The words for an outcome code: a closed set, never Heimdall's text. Anything else gets none. + /// The words for an outcome code: a closed set, never Heimdall's text and never the query's. Anything else gets none. public static string? Wording(string? code) => code switch { - "linked" => "Google is now linked to your Heimdall account.", - "identity_linked_elsewhere" => "That Google identity is already linked to a different Heimdall account. Nothing was linked.", - "expired" => "The Google link expired before it was confirmed. Nothing was linked; start the link again.", - "error" => "The Google link could not be completed. Nothing was linked.", + "linked" => "The login is now linked to your Heimdall account.", + "identity_linked_elsewhere" => "That login belongs to another account. Nothing was linked.", + "expired" => "The link expired before it was confirmed. Nothing was linked; start the link again.", + "listed" => null, + "unlinked" => "The login was unlinked.", + "last_identity" => "That is the last login on your account, and Heimdall will not remove it. Nothing was changed.", + "step_up_required" => "Heimdall did not accept the sign-in as fresh proof. Nothing was changed; try again.", + "identity_not_found" => "That login is no longer on your account. Nothing was changed; the list below is read again when you ask.", + "sign_in_required" => "Heimdall no longer accepts that sign-in. Nothing was changed; try again.", + "proof_unavailable" => "Fresh proof could not be started, so nothing was changed. Unlinking needs a fresh Google sign-in, and it is not available right now.", + "list_expired" => "The list was out of date. Nothing was changed; read your linked logins again.", + "error" => "That could not be completed. Nothing was changed.", _ => null }; } @@ -722,7 +901,9 @@ public sealed record HeimdallStartRequest( [property: JsonPropertyName("returnTo")] string ReturnTo, [property: JsonPropertyName("handoff")] HeimdallHandoff Handoff, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy); + [property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [property: JsonPropertyName("stepUp")] bool? StepUp = null); public sealed record HeimdallHandoff( [property: JsonPropertyName("kind")] string Kind); @@ -745,7 +926,9 @@ public sealed record HeimdallRedeemRequest( public sealed record HeimdallRedeemResponse( [property: JsonPropertyName("returnTo")] string? ReturnTo, [property: JsonPropertyName("accessToken")] string? AccessToken, - [property: JsonPropertyName("account")] HeimdallAccount? Account); + [property: JsonPropertyName("account")] HeimdallAccount? Account, + [property: JsonPropertyName("mode")] string? Mode = null, + [property: JsonPropertyName("provider")] string? Provider = null); public sealed record HeimdallAccount( [property: JsonPropertyName("id")] string Id); diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs index b7d67b2..4488d7b 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs @@ -33,6 +33,12 @@ public static class HeimdallCompletePage var pendingLinkId = fragment.get("heimdall_pending_link_id"); var failed = fragment.get("heimdall_status") === "error"; history.replaceState(null, "", location.pathname + location.search); + // A provider other than Google attaches in Heimdall's callback, which refuses an identity another account + // holds. Only this one refusal is carried on, as a fixed result the page words itself; nothing from the fragment is shown. + if (failed && attempt && fragment.get("heimdall_error") === "link_initiator_mismatch") { + location.replace("/Membership/LinkedAccounts?result=identity_linked_elsewhere"); + return; + } if (failed || !(code || pendingLinkId) || !attempt) { location.replace("/?auth=heimdall-error"); return; diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallMemberClient.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallMemberClient.cs new file mode 100644 index 0000000..04a29d9 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallMemberClient.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Bifrost.Web.Configuration; + +namespace Bifrost.Web.Features.Heimdall; + +/// One login a Heimdall account holds, as the Linked accounts page shows it. No provider user id, scope or email. +public sealed record LinkedIdentitySummary(string Id, string Provider, string? DisplayName, DateTimeOffset? LinkedAt); + +public enum HeimdallMemberCall +{ + Ok, + SignInRequired, + StepUpRequired, + IdentityNotFound, + LastIdentity, + Error +} + +public sealed record HeimdallMemberResult(HeimdallMemberCall Call, IReadOnlyList? Identities); + +/// +/// Heimdall's member routes (docs/service-contract.md: GET /v1/me/identities, POST /v1/me/identities/{id}/unlink). Each +/// takes the member's bearer as an argument, uses it for that one request and keeps nothing. Its HttpClient carries no +/// app secret: these routes act only for a member who is present, so a Bifrost fault cannot reach them without one. +/// +public sealed class HeimdallMemberClient(HttpClient httpClient) +{ + private const int MaxIdentities = 32; + private const int MaxDisplayName = 80; + + public static void ConfigureClient(HttpClient client, HeimdallOptions options) => + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); + + public Task ListAsync(string bearer, CancellationToken cancellationToken) => + SendAsync(HttpMethod.Get, "/v1/me/identities", bearer, cancellationToken); + + public Task UnlinkAsync(string bearer, string identityId, CancellationToken cancellationToken) => + SendAsync(HttpMethod.Post, $"/v1/me/identities/{Uri.EscapeDataString(identityId)}/unlink", bearer, cancellationToken); + + private async Task SendAsync(HttpMethod method, string path, string bearer, CancellationToken cancellationToken) + { + try + { + using var message = new HttpRequestMessage(method, path); + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (response.StatusCode == HttpStatusCode.OK) + { + return new HeimdallMemberResult(HeimdallMemberCall.Ok, await ReadIdentitiesAsync(response, cancellationToken)); + } + + var code = await HeimdallRefusal.CodeAsync(response, cancellationToken); + var call = (response.StatusCode, code) switch + { + (HttpStatusCode.Unauthorized, _) => HeimdallMemberCall.SignInRequired, + (HttpStatusCode.Forbidden, "step_up_required") => HeimdallMemberCall.StepUpRequired, + (HttpStatusCode.NotFound, "identity_not_found") => HeimdallMemberCall.IdentityNotFound, + (HttpStatusCode.Conflict, "last_identity") => HeimdallMemberCall.LastIdentity, + _ => HeimdallMemberCall.Error + }; + return new HeimdallMemberResult(call, null); + } + catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException + && !cancellationToken.IsCancellationRequested) + { + return new HeimdallMemberResult(HeimdallMemberCall.Error, null); + } + } + + /// The identities array of a 200 answer, keeping only the fields the page shows; a malformed entry is dropped. + private static async Task> ReadIdentitiesAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + using var body = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + var list = new List(); + if (body.RootElement.ValueKind != JsonValueKind.Object + || !body.RootElement.TryGetProperty("identities", out var identities) + || identities.ValueKind != JsonValueKind.Array) + { + return list; + } + + foreach (var item in identities.EnumerateArray().Take(MaxIdentities)) + { + var id = Text(item, "id"); + var provider = Text(item, "provider"); + if (!Guid.TryParse(id, out _) + || provider is not { Length: > 0 and <= 32 } + || !provider.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) + { + continue; + } + + var name = Text(item, "displayName") ?? Text(item, "username"); + if (name is { Length: > MaxDisplayName }) + { + name = name[..MaxDisplayName]; + } + + DateTimeOffset? linkedAt = DateTimeOffset.TryParse(Text(item, "linkedAt"), out var parsed) ? parsed : null; + list.Add(new LinkedIdentitySummary(id!, provider, name, linkedAt)); + } + + return list; + } + + private static string? Text(JsonElement item, string name) => + item.ValueKind == JsonValueKind.Object && item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs new file mode 100644 index 0000000..fea10aa --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +namespace Bifrost.Web.Features.Heimdall; + +/// Reads the one thing Bifrost takes from a Heimdall refusal body: its error code, and only if it is a safe token. +public static class HeimdallRefusal +{ + private const int BodyLimit = 4096; + + /// + /// The error code in the first 4 KiB of a body when it is a short snake_case token, else null. A body that cannot be + /// read or parsed yields null: a refusal never becomes a 500, and free text or tokens never reach a log or a page. + /// + public static async Task CodeAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var head = new byte[BodyLimit]; + var length = 0; + while (length < head.Length && await stream.ReadAsync(head.AsMemory(length), cancellationToken) is > 0 and var read) + { + length += read; + } + + using var body = JsonDocument.Parse(head.AsMemory(0, length)); + if (body.RootElement.ValueKind == JsonValueKind.Object + && body.RootElement.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.String + && error.GetString() is { Length: > 0 and <= 64 } code + && code.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) + { + return code; + } + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + } + + return null; + } +} diff --git a/src/Bifrost.Web/Features/Heimdall/LinkedAccountsSnapshots.cs b/src/Bifrost.Web/Features/Heimdall/LinkedAccountsSnapshots.cs new file mode 100644 index 0000000..08a21bb --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/LinkedAccountsSnapshots.cs @@ -0,0 +1,35 @@ +using System.Collections.Concurrent; + +namespace Bifrost.Web.Features.Heimdall; + +/// +/// What the Linked accounts page last read from Heimdall for an account: id, provider, display name and link date of +/// each login, nothing else, for . It lives in this process only, keyed by the Heimdall account id, +/// and is never given to the browser; it holds no bearer. The page renders from it because the list is fetched inside a +/// completion request, which redirects. After a restart it is empty and the page offers to read the list again. +/// +public sealed class LinkedAccountsSnapshots(TimeProvider timeProvider) +{ + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5); + + private readonly ConcurrentDictionary Identities, DateTimeOffset ExpiresAt)> _held = new(StringComparer.Ordinal); + + public void Put(string accountId, IReadOnlyList identities) + { + var now = timeProvider.GetUtcNow(); + foreach (var (key, value) in _held) + { + if (value.ExpiresAt <= now) + { + _held.TryRemove(key, out _); + } + } + + _held[accountId] = (identities, now + Lifetime); + } + + public IReadOnlyList? Get(string accountId) => + _held.TryGetValue(accountId, out var value) && value.ExpiresAt > timeProvider.GetUtcNow() ? value.Identities : null; + + public void Remove(string accountId) => _held.TryRemove(accountId, out _); +} diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml new file mode 100644 index 0000000..75ee5c2 --- /dev/null +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml @@ -0,0 +1,104 @@ +@page +@model Bifrost.Web.Pages.Membership.LinkedAccountsModel +@{ + ViewData["Title"] = "Linked accounts"; +} + +
+ Heimdall +

Linked accounts

+

+ The provider logins that sign in to this account. Heimdall holds them and decides what may change; Bifrost keeps none of + your Heimdall sign-in, so each action sends you through Heimdall once more. +

+ + @if (Model.ResultMessage is not null) + { +
@Model.ResultMessage
+ } + + @if (!Model.HasHeimdallAccount) + { +
+ This session is not a Heimdall account, so there are no provider logins to manage here. Sign in with Discord or Patreon + through Heimdall to see them. +
+ } + else + { +

Your logins

+ @if (Model.Identities is null) + { +
+ + You will sign in to Heimdall again, which reads the list. It is kept for five minutes, then asked for again. +
+ } + else + { +
    + @foreach (var identity in Model.Identities) + { +
  • + @LinkedAccountsModel.ProviderName(identity.Provider) + @if (identity.DisplayName is not null) + { + @identity.DisplayName + } + @if (identity.LinkedAt is { } linkedAt) + { + linked @linkedAt.UtcDateTime.ToString("dd MMM yyyy") UTC + } + @if (Model.CanProve) + { +
    + + +
    + } +
  • + } +
+ @if (Model.Identities.Count == 1) + { +

This is your only login. Heimdall will not remove the last one.

+ } + + @if (!Model.CanProve) + { +

+ Unlinking needs a fresh Google sign-in as proof. Discord and Patreon cannot give that proof, and no Google login + is linked to this account, so nothing can be unlinked from here yet. Linking Google first makes it possible + once Heimdall has Google set up. +

+ } + else + { +

Unlinking asks you to sign in with Google again as proof, then removes only the login you picked.

+ } + +
+ +
+ } + +

Link another login

+

+ You will sign in to Heimdall again, then to the provider you pick. If that login already belongs to another account, + nothing is linked and nothing is merged. +

+
+ @foreach (var provider in Model.LinkableProviders) + { +
+ + +
+ } +
+ } + +
+
diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs new file mode 100644 index 0000000..b247e30 --- /dev/null +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs @@ -0,0 +1,126 @@ +using System.Security.Claims; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Pages.Membership; + +/// +/// The member's provider logins (docs/linked-accounts.md). Bifrost keeps no Heimdall token, so every action here begins a +/// fresh Heimdall sign-in for the account already signed in and finishes inside that sign-in's completion +/// (POST /auth/heimdall/complete); this page only starts them and shows what the last read found. Every handler is a +/// POST with antiforgery. Nothing from the query or the form is ever shown: the outcome is a code from a closed set. +/// +[Authorize] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public sealed class LinkedAccountsModel( + HeimdallAuthService heimdallAuthService, + LinkedAccountsSnapshots snapshots, + IOptions hostOptions, + ILogger logger) : PageModel +{ + private static readonly Dictionary ProviderNames = new(StringComparer.Ordinal) + { + ["discord"] = "Discord", + ["patreon"] = "Patreon", + ["google"] = "Google" + }; + + private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null; + + /// The wording of the outcome the page was sent back with: a closed set, null for anything else. + public string? ResultMessage { get; private set; } + + /// Whether this session is a Heimdall account. A Bifrost-native GitHub session has none, so there is nothing to list. + public bool HasHeimdallAccount => AccountId is not null; + + /// The logins the last read found, or null when there is none or it has expired. + public IReadOnlyList? Identities { get; private set; } + + /// Whether a fresh Google sign-in can be had for this account: unlinking needs one, and only Google can give it. + public bool CanProve => Identities?.Any(x => x.Provider == HeimdallAuthService.StepUpProvider) == true; + + public IReadOnlyList LinkableProviders => HeimdallAuthService.LinkableProviders; + + public static string ProviderName(string provider) => ProviderNames.GetValueOrDefault(provider, provider); + + public void OnGet() + { + ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString()); + Identities = AccountId is { } id ? snapshots.Get(id) : null; + } + + /// Reads the account's logins: an ordinary Heimdall sign-in, then the list inside its completion. + public Task OnPostRefreshAsync(CancellationToken cancellationToken) => + BeginAsync( + (id, ct) => heimdallAuthService.StartListAsync(id, SessionProvider, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), + HeimdallLinkOutcome.Error, + cancellationToken); + + /// Links another provider. The provider is one Heimdall's bifrost profile admits; anything else starts nothing. + public Task OnPostLinkAsync(string? provider, CancellationToken cancellationToken) + { + if (provider is null || !HeimdallAuthService.LinkableProviders.Contains(provider)) + { + return Task.FromResult(Done(HeimdallLinkOutcome.Error)); + } + + return BeginAsync( + (id, ct) => heimdallAuthService.StartLinkAsync(id, SessionProvider, provider, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), + HeimdallLinkOutcome.Error, + cancellationToken); + } + + /// + /// Unlinks one login with fresh proof. The identity must be on the list this account was last shown and the account must + /// hold a Google login to prove with; otherwise Heimdall is not asked, and the page says so. + /// + public Task OnPostUnlinkAsync(string? identityId, CancellationToken cancellationToken) + { + var shown = AccountId is { } id ? snapshots.Get(id) : null; + if (shown is null || identityId is null || !shown.Any(x => x.Id == identityId)) + { + return Task.FromResult(Done(HeimdallLinkOutcome.ListExpired)); + } + + if (!shown.Any(x => x.Provider == HeimdallAuthService.StepUpProvider)) + { + return Task.FromResult(Done(HeimdallLinkOutcome.ProofUnavailable)); + } + + return BeginAsync( + (account, ct) => heimdallAuthService.StartUnlinkAsync(account, identityId, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), + HeimdallLinkOutcome.ProofUnavailable, + cancellationToken); + } + + private string? SessionProvider => User.FindFirstValue(BifrostClaimTypes.AuthProvider); + + private IActionResult Done(HeimdallLinkOutcome outcome) => + RedirectToPage(null, new Dictionary { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) }); + + private async Task BeginAsync( + Func> start, HeimdallLinkOutcome onRefusal, CancellationToken cancellationToken) + { + if (AccountId is not { } accountId) + { + return Done(HeimdallLinkOutcome.Error); + } + + try + { + var started = await start(accountId, cancellationToken); + Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options()); + return Redirect(started.AuthorizationUrl.ToString()); + } + catch (HeimdallAuthException error) + { + logger.LogWarning("Linked accounts start refused: {Reason}", error.Message); + return Done(onRefusal); + } + } +} diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml b/src/Bifrost.Web/Pages/Membership/Status.cshtml index 8f683a4..6e3110f 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml @@ -24,17 +24,12 @@

@Model.StatusMessage

- @if (Model.LinkMessage is not null) + @if (Model.CanManageLogins) { -
@Model.LinkMessage
- } - - @if (Model.CanLinkGoogle) - { -
- - You will sign in to Heimdall again, then to Google. Google links your account; it does not sign you in. -
+
+ Linked accounts + See, add and remove the provider logins that sign in to this account. +
} @if (Model.Invitation is not null) diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs index 13b47ca..ffc5a22 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs @@ -1,61 +1,21 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; using System.Security.Claims; -using Bifrost.Web.Configuration; -using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; namespace Bifrost.Web.Pages.Membership; [Authorize] public sealed class StatusModel( ICurrentBifrostActorAccessor actorAccessor, - BifrostDbContext dbContext, - HeimdallAuthService heimdallAuthService, - IOptions hostOptions, - ILogger logger) : PageModel + BifrostDbContext dbContext) : PageModel { - /// The wording of the link outcome the page was sent back with: a closed set, null for anything else. - public string? LinkMessage { get; private set; } - - /// Whether this session belongs to a Heimdall account a Google login can be attached to. - public bool CanLinkGoogle => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 }; - - /// - /// Begins an explicit Google link: an ordinary Heimdall sign-in for the account already signed in here, kept as - /// an attempt that is only the proof for the link. POST with antiforgery only; nothing is started without a session. - /// - public async Task OnPostLinkGoogleAsync(CancellationToken cancellationToken) - { - var accountId = User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId); - if (string.IsNullOrEmpty(accountId)) - { - return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) }); - } - - try - { - var start = await heimdallAuthService.StartLinkAsync( - accountId, - User.FindFirstValue(BifrostClaimTypes.AuthProvider), - hostOptions.Value.PublicBaseUrl, - Request.Cookies, - cancellationToken); - Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options()); - return Redirect(start.AuthorizationUrl.ToString()); - } - catch (HeimdallAuthException error) - { - logger.LogWarning("Heimdall link start refused: {Reason}", error.Message); - return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) }); - } - } + /// Whether this session belongs to a Heimdall account whose logins the Linked accounts page can manage. + public bool CanManageLogins => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 }; public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; @@ -90,7 +50,6 @@ public sealed class StatusModel( public async Task OnGetAsync(CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); - LinkMessage = HeimdallLinkOutcomes.Wording(Request.Query["link"].ToString()); if (Actor.UserAccount is null) { diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index ef92be8..6124f40 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -93,6 +93,7 @@ builder.Services.AddAntiforgery(options => builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); + options.Conventions.AuthorizePage("/Membership/LinkedAccounts"); options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName); options.Conventions.AllowAnonymousToFolder("/Mill"); }); @@ -153,6 +154,11 @@ builder.Services.AddScoped((serviceProvider, client) => HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); +// Heimdall's member routes take only the member's bearer: this client carries no app secret. +builder.Services.AddHttpClient((serviceProvider, client) => + HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); diff --git a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs index 4ae3eae..6feea08 100644 --- a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs +++ b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs @@ -64,6 +64,10 @@ console.log( run("link-return-with-decoy-query-id", "?attempt=NONCE&heimdall_pending_link_id=QPEND", "#heimdall_status=success&heimdall_pending_link_id=PEND1"), run("link-error-with-pending-id", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied&heimdall_pending_link_id=PEND1"), run("link-pending-id-in-query-only", "?attempt=NONCE&heimdall_pending_link_id=QPEND", ""), + run("link-refused-elsewhere", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=link_initiator_mismatch"), + run("link-refused-elsewhere-no-attempt", "", "#heimdall_status=error&heimdall_error=link_initiator_mismatch"), + run("other-refusal-text", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=%3Cb%3Ex&heimdall_error_description=EVIL"), + run("refusal-in-query-only", "?attempt=NONCE&heimdall_error=link_initiator_mismatch", ""), run("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""), ]), ); diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs index ba2b1fb..c79be6d 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs @@ -112,6 +112,28 @@ public sealed class HeimdallCompletePageScriptTests Assert.Equal(ErrorTarget, runs["link-error-with-pending-id"].Events[^1]); } + [Fact] + public async Task A_link_refused_because_the_login_belongs_to_another_account_goes_to_the_page_with_a_fixed_result_and_submits_nothing() + { + var runs = await RunScenariosAsync(); + const string Target = "replace:/Membership/LinkedAccounts?result=identity_linked_elsewhere"; + + var run = runs["link-refused-elsewhere"]; + Assert.Null(run.Submit); + Assert.Equal(Target, run.Events[^1]); + Assert.DoesNotContain("submit", run.Events); + Assert.Contains(run.Events, x => x.StartsWith("replaceState:", StringComparison.Ordinal) && !x.Contains('#')); + + // Without an attempt, from the query only, or for any other refusal text, it is the ordinary error exit. + foreach (var name in new[] { "link-refused-elsewhere-no-attempt", "other-refusal-text", "refusal-in-query-only" }) + { + Assert.Null(runs[name].Submit); + Assert.Equal(ErrorTarget, runs[name].Events[^1]); + } + + Assert.DoesNotContain("EVIL", string.Join("\n", runs["other-refusal-text"].Events)); + } + [Theory] [InlineData("error-status-with-code")] [InlineData("no-code")] diff --git a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs index 5de3eec..d45e883 100644 --- a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs @@ -16,7 +16,7 @@ namespace Bifrost.Web.Tests; ///
public sealed class HeimdallGoogleLinkTests { - private const string Status = "/Membership/Status"; + private const string LinkedPage = "/Membership/LinkedAccounts"; private const string ErrorRedirect = "/?auth=heimdall-error"; private sealed record Step(HttpResponseMessage Response, StartedAttempt Next, string Token); @@ -126,7 +126,7 @@ public sealed class HeimdallGoogleLinkTests new Dictionary { ["attempt"] = start.Nonce }, Jar(session, start)); Assert.Equal(before, host.Heimdall.StartBodies.Count); - Assert.Equal(Status + "?link=error", Location(response)); + Assert.Equal(LinkedPage + "?result=error", Location(response)); } [Fact] @@ -238,7 +238,7 @@ public sealed class HeimdallGoogleLinkTests var b = await StepB(host, session, start, HeimdallShapes.NewAccountId()); Assert.Equal(starts, host.Heimdall.StartBodies.Count); - Assert.Equal(Status + "?link=error", Location(b.Response)); + Assert.Equal(LinkedPage + "?result=error", Location(b.Response)); AssertAttemptCleared(b.Response, start); } @@ -258,7 +258,7 @@ public sealed class HeimdallGoogleLinkTests Assert.Equal(starts, host.Heimdall.StartBodies.Count); Assert.Equal(redeems, host.Heimdall.RedeemCalls); - Assert.Equal(Status + "?link=error", Location(b.Response)); + Assert.Equal(LinkedPage + "?result=error", Location(b.Response)); } [Fact] @@ -271,7 +271,7 @@ public sealed class HeimdallGoogleLinkTests var d = await StepD(host, journey.Session, journey.C.Next, other); Assert.Empty(host.Heimdall.ConfirmBodies); - Assert.Equal(Status + "?link=error", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=error", Location(d.Response)); } private static void AssertAttemptCleared(HttpResponseMessage response, StartedAttempt attempt) @@ -295,7 +295,7 @@ public sealed class HeimdallGoogleLinkTests var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); - Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=linked", Location(d.Response)); Assert.Single(host.Heimdall.ConfirmBodies); using var body = JsonDocument.Parse(host.Heimdall.ConfirmBodies[0]); Assert.Equal(journey.PendingLinkId, body.RootElement.GetProperty("pendingLinkId").GetString()); @@ -320,7 +320,7 @@ public sealed class HeimdallGoogleLinkTests var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); - Assert.Equal($"{Status}?link={outcome}", Location(d.Response)); + Assert.Equal($"{LinkedPage}?result={outcome}", Location(d.Response)); Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines)); AssertAttemptCleared(d.Response, journey.C.Next); } @@ -334,18 +334,18 @@ public sealed class HeimdallGoogleLinkTests async Task Page(string link) { - using var request = new HttpRequestMessage(HttpMethod.Get, Status + "?link=" + Uri.EscapeDataString(link)); + using var request = new HttpRequestMessage(HttpMethod.Get, LinkedPage + "?result=" + Uri.EscapeDataString(link)); request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}"); return await (await client.SendAsync(request)).Content.ReadAsStringAsync(); } - Assert.Contains("Google is now linked", await Page("linked")); - Assert.Contains("already linked to a different Heimdall account", await Page("identity_linked_elsewhere")); + Assert.Contains("now linked to your Heimdall account", await Page("linked")); + Assert.Contains("belongs to another account", await Page("identity_linked_elsewhere")); Assert.Contains("expired before it was confirmed", await Page("expired")); Assert.Contains("could not be completed", await Page("error")); var hostile = await Page(""); Assert.DoesNotContain("alert(1)", hostile); - Assert.DoesNotContain("Google is now linked", hostile); + Assert.DoesNotContain("now linked to your", hostile); } // ---- 6. google stays out of sign-in and member access ---- @@ -379,7 +379,7 @@ public sealed class HeimdallGoogleLinkTests var pending = host.Heimdall.RegisterPendingLink(pendingCanary); var c = await StepC(host, session, b.Next, pending); var d = await StepD(host, session, c.Next, account); - Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=linked", Location(d.Response)); var tokens = new[] { b.Token, d.Token } .SelectMany(token => new[] { token, token.Split('.')[2], token.Split('.')[1] }) @@ -457,7 +457,7 @@ public sealed class HeimdallGoogleLinkTests var replay = await host.PostCompleteAsync(fields, Jar(session, start)); Assert.Equal(starts, host.Heimdall.StartBodies.Count); - Assert.Equal(Status + "?link=error", Location(replay)); + Assert.Equal(LinkedPage + "?result=error", Location(replay)); } [Fact] @@ -468,12 +468,12 @@ public sealed class HeimdallGoogleLinkTests var code = host.CodeFor(journey.C.Next, journey.Account); var fields = new Dictionary { ["attempt"] = journey.C.Next.Nonce, ["code"] = code }; var first = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next)); - Assert.Equal(Status + "?link=linked", Location(first)); + Assert.Equal(LinkedPage + "?result=linked", Location(first)); var replay = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next)); Assert.Single(host.Heimdall.ConfirmBodies); - Assert.Equal(Status + "?link=error", Location(replay)); + Assert.Equal(LinkedPage + "?result=error", Location(replay)); } [Fact] @@ -487,8 +487,8 @@ public sealed class HeimdallGoogleLinkTests var d = await StepD(host, journey.Session, again.Next, journey.Account); var replayed = await StepD(host, journey.Session, journey.C.Next, journey.Account); - Assert.Equal(Status + "?link=linked", Location(d.Response)); - Assert.Equal(Status + "?link=expired", Location(replayed.Response)); + Assert.Equal(LinkedPage + "?result=linked", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=expired", Location(replayed.Response)); } // ---- 10. pins for guards the first pass left unproven ---- @@ -521,7 +521,7 @@ public sealed class HeimdallGoogleLinkTests Assert.Equal(start.CookieName, b.Next.CookieName); Assert.Equal(start.CookieName, c.Next.CookieName); - Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=linked", Location(d.Response)); Assert.Null(HeimdallHost.SetCookieHeader(d.Response, parallel.CookieName!)); var signIn = await host.CompleteWithHeaderAsync( @@ -532,7 +532,7 @@ public sealed class HeimdallGoogleLinkTests [Theory] [InlineData("9")] [InlineData("-1")] - [InlineData("4")] + [InlineData("7")] public async Task An_attempt_cookie_with_an_undefined_purpose_is_no_attempt_and_asks_Heimdall_for_nothing(string purpose) { using var host = new HeimdallHost(realSessions: true); @@ -575,7 +575,7 @@ public sealed class HeimdallGoogleLinkTests var c = await StepC(host, session, b.Next, pendingLinkId); - Assert.Equal(Status + "?link=error", Location(c.Response)); + Assert.Equal(LinkedPage + "?result=error", Location(c.Response)); Assert.Equal(starts, host.Heimdall.StartBodies.Count); Assert.Equal(redeems, host.Heimdall.RedeemCalls); Assert.Empty(host.Heimdall.ConfirmBodies); @@ -597,7 +597,7 @@ public sealed class HeimdallGoogleLinkTests var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); - Assert.Equal(Status + "?link=error", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=error", Location(d.Response)); Assert.Single(host.Heimdall.ConfirmBodies); AssertAttemptCleared(d.Response, journey.C.Next); } diff --git a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs new file mode 100644 index 0000000..a58d58f --- /dev/null +++ b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs @@ -0,0 +1,710 @@ +using System.Net; +using System.Text.Json; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +/// +/// The Linked accounts page from the member's side, against a fake Heimdall. Bifrost keeps no Heimdall token: a list, a link +/// and an unlink each follow a fresh Heimdall sign-in begun by Bifrost, and the bearer lives inside that one completion request. +/// +public sealed class LinkedAccountsTests +{ + private const string Page = "/Membership/LinkedAccounts"; + private const string ErrorRedirect = "/?auth=heimdall-error"; + + private static string Jar(string session, StartedAttempt attempt) => + $"{BifrostCookies.Session}={session}; {attempt.CookieName}={attempt.Cookie}"; + + private static string? Location(HttpResponseMessage response) => response.Headers.Location?.OriginalString; + + private static string Unprotect(HeimdallHost host, string cookie) => + host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1").Unprotect(cookie); + + /// The fresh sign-in's completion as the browser posts it: Heimdall minted a code for . + private static async Task Finish( + HeimdallHost host, string session, StartedAttempt attempt, string proofAccount, string? token = null, + string mode = "sign_in", string provider = "discord", string? sessionOverride = null) + { + var code = host.CodeFor(attempt, proofAccount, token, mode: mode, provider: provider); + return await host.PostCompleteAsync( + new Dictionary { ["attempt"] = attempt.Nonce, ["code"] = code }, Jar(sessionOverride ?? session, attempt)); + } + + private static string StepUpToken(HeimdallHost host, string account) => + host.Token(account, mutate: claims => claims["step_up_at"] = host.Clock.Now.ToUnixTimeSeconds()); + + private sealed record Member(string Account, string Session); + + /// A signed-in member whose fake Heimdall account holds these providers, and whose list has been read once. + private static async Task ListedMember(HeimdallHost host, params string[] providers) + { + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + foreach (var provider in providers) + { + host.Heimdall.AddIdentity(provider, "Name of " + provider); + } + + var start = await host.StartListAsync(session); + var done = await Finish(host, session, start, account); + Assert.Equal(Page + "?result=listed", Location(done)); + return new Member(account, session); + } + + // ---- who may use the page ---- + + [Fact] + public async Task An_anonymous_visitor_is_challenged_and_nothing_is_started() + { + using var host = new HeimdallHost(realSessions: true); + + var (get, _) = await host.GetLinkedAccountsAsync(session: null); + var post = await host.PostLinkedAccountsAsync(session: null, "Refresh"); + + Assert.Equal(HttpStatusCode.Redirect, get.StatusCode); + Assert.Contains("/auth/sign-in", Location(get)); + Assert.NotEqual(HttpStatusCode.OK, post.Response.StatusCode); + Assert.Empty(host.Heimdall.StartBodies); + } + + [Fact] + public async Task The_page_is_no_store_and_a_member_without_a_list_is_offered_one() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + + var (response, html) = await host.GetLinkedAccountsAsync(session); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("no-store", response.Headers.CacheControl?.ToString()); + Assert.Contains("Show my linked logins", html); + Assert.Contains("Link Discord", html); + Assert.Contains("Link Patreon", html); + Assert.Contains("Link Google", html); + Assert.DoesNotContain("Link Twitch", html); + Assert.DoesNotContain("Unlink", html); + } + + [Fact] + public async Task A_session_with_no_Heimdall_account_has_nothing_to_list_and_starts_nothing() + { + using var host = new HeimdallHost(); + + var (_, html) = await host.GetLinkedAccountsAsync(session: null); + var start = await host.StartListAsync(session: null); + + Assert.Contains("not a Heimdall account", html); + Assert.DoesNotContain("Link Discord", html); + Assert.Empty(host.Heimdall.StartBodies); + Assert.Null(start.Cookie); + } + + [Theory] + [InlineData("Refresh")] + [InlineData("Link")] + [InlineData("Unlink")] + public async Task Every_post_needs_the_antiforgery_token_and_a_get_starts_nothing(string handler) + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + var form = new Dictionary { ["provider"] = "discord", ["identityId"] = Guid.NewGuid().ToString() }; + var starts = host.Heimdall.StartBodies.Count; + + var without = await host.PostLinkedAccountsAsync(session, handler, form, antiforgery: false); + var get = await host.PostLinkedAccountsAsync(session, handler, form, method: HttpMethod.Get); + + Assert.Equal(HttpStatusCode.BadRequest, without.Response.StatusCode); + Assert.Null(without.Cookie); + Assert.Null(get.Cookie); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + + // ---- the list ---- + + [Fact] + public async Task The_list_is_a_fresh_ordinary_sign_in_then_GET_identities_with_its_bearer_and_the_page_shows_it() + { + using var host = new HeimdallHost(traceHttp: true, realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord", "Discord Dana"); + host.Heimdall.AddIdentity("google", "Google Gale"); + var signIns = host.Heimdall.StartBodies.Count; + + var start = await host.StartListAsync(session); + + Assert.Equal(signIns + 1, host.Heimdall.StartBodies.Count); + using (var body = JsonDocument.Parse(host.Heimdall.StartBodies[^1])) + { + Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString()); + Assert.False(body.RootElement.TryGetProperty("stepUp", out _)); + } + + Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]); + Assert.Empty(host.Heimdall.ListAuthorizations); + + var token = host.Token(account); + var done = await Finish(host, session, start, account, token); + + Assert.Equal(Page + "?result=listed", Location(done)); + Assert.Equal(["Bearer " + token], host.Heimdall.ListAuthorizations); + Assert.Null(HeimdallHost.CookieValue(done, BifrostCookies.Session)); + var (_, html) = await host.GetLinkedAccountsAsync(session); + Assert.Contains("Discord Dana", html); + Assert.Contains("Google Gale", html); + Assert.Contains("13 Nov 2026", html); + // Only what the page shows was kept: nothing of the fields a login also carries. + foreach (var canary in new[] { "PUID-CANARY", "username-canary", "scope-canary" }) + { + Assert.DoesNotContain(canary, html); + } + } + + [Fact] + public async Task No_bearer_reaches_a_cookie_a_log_a_location_or_the_page() + { + using var host = new HeimdallHost(traceHttp: true, realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord"); + host.Heimdall.AddIdentity("google"); + var listStart = await host.StartListAsync(session); + var listToken = host.Token(account); + var listed = await Finish(host, session, listStart, account, listToken); + var unlinkStart = await host.StartUnlinkAsync(session, (string)host.Heimdall.Identities[0]["id"]!); + var stepUpToken = StepUpToken(host, account); + var unlinked = await Finish(host, session, unlinkStart, account, stepUpToken); + Assert.Equal(Page + "?result=unlinked", Location(unlinked)); + var (_, html) = await host.GetLinkedAccountsAsync(session); + + var logs = string.Join("\n", host.Logs.Lines); + var secrets = new[] { listToken, stepUpToken } + .SelectMany(token => new[] { token, token.Split('.')[2], token.Split('.')[1] }) + .ToArray(); + Assert.Contains("Bearer", string.Join("\n", host.Heimdall.ListAuthorizations)); + foreach (var secret in secrets) + { + Assert.DoesNotContain(secret, logs); + Assert.DoesNotContain(secret, html); + foreach (var response in new[] { listStart.Response, listed, unlinkStart.Response, unlinked }) + { + Assert.DoesNotContain(secret, Location(response) ?? string.Empty); + Assert.DoesNotContain(secret, string.Join("\n", response.Headers.SelectMany(x => x.Value))); + } + + foreach (var cookie in new[] { listStart.Cookie, unlinkStart.Cookie }) + { + var payload = Unprotect(host, cookie!); + Assert.DoesNotContain(secret, payload); + Assert.DoesNotContain("eyJ", payload); + } + } + + // The completion itself sets no cookie but the attempt's deletion. + foreach (var response in new[] { listed, unlinked }) + { + Assert.All( + response.Headers.TryGetValues("Set-Cookie", out var set) ? set : [], + header => Assert.StartsWith(HeimdallAttemptCookie.Prefix, header, StringComparison.Ordinal)); + } + } + + [Fact] + public async Task A_list_is_per_account_and_expires_after_five_minutes() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var stranger = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + + Assert.Contains("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); + var (_, other) = await host.GetLinkedAccountsAsync(stranger); + Assert.DoesNotContain("Name of discord", other); + Assert.Contains("Show my linked logins", other); + + host.Clock.Advance(LinkedAccountsSnapshots.Lifetime + TimeSpan.FromSeconds(1)); + + var (_, later) = await host.GetLinkedAccountsAsync(member.Session); + Assert.DoesNotContain("Name of discord", later); + Assert.Contains("Show my linked logins", later); + } + + [Theory] + [InlineData(HttpStatusCode.Unauthorized, "sign_in_required")] + [InlineData(HttpStatusCode.InternalServerError, "error")] + public async Task A_list_Heimdall_refuses_shows_its_fixed_outcome_and_no_list(HttpStatusCode status, string outcome) + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord"); + host.Heimdall.ListStatus = status; + var start = await host.StartListAsync(session); + + var done = await Finish(host, session, start, account); + + Assert.Equal($"{Page}?result={outcome}", Location(done)); + Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines)); + Assert.Contains("Show my linked logins", (await host.GetLinkedAccountsAsync(session)).Html); + } + + [Fact] + public async Task A_Heimdall_that_cannot_be_reached_for_the_list_changes_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord"); + var start = await host.StartListAsync(session); + host.Heimdall.MemberFailure = new HttpRequestException("connection refused"); + + var done = await Finish(host, session, start, account); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Contains("Show my linked logins", (await host.GetLinkedAccountsAsync(session)).Html); + } + + [Fact] + public async Task A_Heimdall_that_is_down_at_the_list_start_is_a_refusal_not_a_server_error() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + host.Heimdall.StartStatus = HttpStatusCode.ServiceUnavailable; + + var start = await host.StartListAsync(session); + + Assert.Equal(HttpStatusCode.Redirect, start.Response.StatusCode); + Assert.Equal(Page + "?result=error", Location(start.Response)); + Assert.Null(start.Cookie); + } + + // ---- unlink ---- + + [Fact] + public async Task Unlink_starts_a_step_up_sign_in_with_the_app_secret_and_the_unlink_carries_that_sign_ins_bearer() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var discord = (string)host.Heimdall.Identities[0]["id"]!; + var listToken = host.Heimdall.ListAuthorizations.Single(); + var starts = host.Heimdall.StartBodies.Count; + + var start = await host.StartUnlinkAsync(member.Session, discord); + + Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count); + Assert.Equal("/v1/oauth/google/start", host.Heimdall.StartPaths[^1]); + Assert.Equal(HeimdallHost.AppSecret, host.Heimdall.StartSecrets[^1]); + Assert.Null(host.Heimdall.StartAuthorizations[^1]); + using (var body = JsonDocument.Parse(host.Heimdall.StartBodies[^1])) + { + Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString()); + Assert.True(body.RootElement.GetProperty("stepUp").GetBoolean()); + } + + Assert.Empty(host.Heimdall.Unlinks); + var token = StepUpToken(host, member.Account); + var done = await Finish(host, member.Session, start, member.Account, token, provider: "google"); + + Assert.Equal(Page + "?result=unlinked", Location(done)); + var unlink = Assert.Single(host.Heimdall.Unlinks); + Assert.Equal(discord, unlink.Id); + Assert.Equal("Bearer " + token, unlink.Authorization); + Assert.NotEqual(listToken, unlink.Authorization); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + Assert.DoesNotContain("Name of discord", html); + Assert.Contains("Name of google", html); + Assert.Contains("Heimdall will not remove the last one", html); + Assert.Contains("The login was unlinked.", (await host.GetLinkedAccountsAsync(member.Session, "?result=unlinked")).Html); + } + + [Fact] + public async Task The_last_login_is_refused_by_Heimdall_and_the_page_still_shows_it() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "google"); + var only = (string)host.Heimdall.Identities[0]["id"]!; + var start = await host.StartUnlinkAsync(member.Session, only); + + var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google"); + + Assert.Equal(Page + "?result=last_identity", Location(done)); + Assert.Single(host.Heimdall.Unlinks); + Assert.Single(host.Heimdall.Identities); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + Assert.Contains("Name of google", html); + Assert.Contains("will not remove it. Nothing was changed.", (await host.GetLinkedAccountsAsync(member.Session, "?result=last_identity")).Html); + } + + [Fact] + public async Task A_sign_in_Heimdall_does_not_count_as_fresh_proof_changes_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + + // An ordinary token: no step_up_at claim, so Heimdall answers 403 step_up_required. + var done = await Finish(host, member.Session, start, member.Account, host.Token(member.Account), provider: "google"); + + Assert.Equal(Page + "?result=step_up_required", Location(done)); + Assert.Equal(2, host.Heimdall.Identities.Count); + Assert.Contains("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound, "identity_not_found")] + [InlineData(HttpStatusCode.Unauthorized, "sign_in_required")] + [InlineData(HttpStatusCode.InternalServerError, "error")] + public async Task Each_refused_unlink_has_its_own_fixed_outcome_and_leaks_no_Heimdall_text(HttpStatusCode status, string outcome) + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + host.Heimdall.UnlinkStatus = status; + + var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google"); + + Assert.Equal($"{Page}?result={outcome}", Location(done)); + Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines)); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + Assert.DoesNotContain("HEIMDALL FREE TEXT", html); + // Not found says the list is stale: it is dropped. Anything else leaves what was shown. + Assert.Equal(status != HttpStatusCode.NotFound, html.Contains("Name of discord")); + } + + [Fact] + public async Task A_Heimdall_that_cannot_be_reached_for_the_unlink_changes_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + host.Heimdall.MemberFailure = new HttpRequestException("connection refused"); + + var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google"); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Contains("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); + } + + [Fact] + public async Task Without_a_Google_login_no_proof_can_be_had_and_the_page_says_so_without_asking_Heimdall() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "patreon"); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + var starts = host.Heimdall.StartBodies.Count; + + var post = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + + Assert.DoesNotContain(">Unlink<", html); + Assert.Contains("fresh Google sign-in", html); + Assert.Contains("no Google login", html); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=proof_unavailable", Location(post.Response)); + Assert.Null(post.Cookie); + } + + [Fact] + public async Task A_Heimdall_with_no_Google_client_refuses_the_step_up_start_and_the_page_says_proof_is_unavailable_once() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + host.Heimdall.StartStatus = HttpStatusCode.ServiceUnavailable; + host.Heimdall.StartBody = """{"error":"provider_not_configured"}"""; + var starts = host.Heimdall.StartBodies.Count; + + var post = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + + Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count); + Assert.Null(post.Cookie); + Assert.Equal(Page + "?result=proof_unavailable", Location(post.Response)); + Assert.Contains("Fresh proof could not be started", (await host.GetLinkedAccountsAsync(member.Session, "?result=proof_unavailable")).Html); + Assert.Equal(2, host.Heimdall.Identities.Count); + } + + [Theory] + [InlineData(null)] + [InlineData("not-a-guid")] + [InlineData("00000000-0000-0000-0000-000000000001")] + public async Task An_identity_that_is_not_on_the_list_the_member_was_shown_starts_nothing(string? identityId) + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var starts = host.Heimdall.StartBodies.Count; + + var post = await host.PostLinkedAccountsAsync( + member.Session, "Unlink", identityId is null ? null : new Dictionary { ["identityId"] = identityId }); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=list_expired", Location(post.Response)); + } + + [Fact] + public async Task An_unlink_after_the_list_expired_starts_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var id = (string)host.Heimdall.Identities[0]["id"]!; + host.Clock.Advance(LinkedAccountsSnapshots.Lifetime + TimeSpan.FromSeconds(1)); + var starts = host.Heimdall.StartBodies.Count; + + var post = await host.StartUnlinkAsync(member.Session, id); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=list_expired", Location(post.Response)); + } + + // ---- an attempt belongs to the account and browser that began it ---- + + [Fact] + public async Task An_unlink_attempt_held_by_another_accounts_session_does_nothing_and_asks_Heimdall_for_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + var otherAccount = HeimdallShapes.NewAccountId(); + var otherSession = await host.SignInSessionAsync(otherAccount); + var redeems = host.Heimdall.RedeemCalls; + + var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google", + sessionOverride: otherSession); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Equal(redeems, host.Heimdall.RedeemCalls); + Assert.Empty(host.Heimdall.Unlinks); + Assert.Equal(2, host.Heimdall.Identities.Count); + } + + [Fact] + public async Task A_proof_by_another_account_unlinks_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + var stranger = HeimdallShapes.NewAccountId(); + + var done = await Finish(host, member.Session, start, stranger, StepUpToken(host, stranger), provider: "google"); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Empty(host.Heimdall.Unlinks); + } + + [Fact] + public async Task A_list_proof_by_another_account_lists_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord"); + var start = await host.StartListAsync(session); + + var done = await Finish(host, session, start, HeimdallShapes.NewAccountId()); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Empty(host.Heimdall.ListAuthorizations); + } + + [Fact] + public async Task A_consumed_unlink_attempt_cannot_be_replayed_into_a_second_unlink() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "patreon", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + var fields = new Dictionary + { + ["attempt"] = start.Nonce, + ["code"] = host.CodeFor(start, member.Account, StepUpToken(host, member.Account), provider: "google") + }; + var first = await host.PostCompleteAsync(fields, Jar(member.Session, start)); + Assert.Equal(Page + "?result=unlinked", Location(first)); + + var replay = await host.PostCompleteAsync(fields, Jar(member.Session, start)); + + Assert.Equal(Page + "?result=error", Location(replay)); + Assert.Single(host.Heimdall.Unlinks); + } + + // ---- link another provider ---- + + [Theory] + [InlineData("discord")] + [InlineData("patreon")] + [InlineData("google")] + public async Task Linking_each_admitted_provider_proves_the_account_then_starts_that_providers_link_with_the_bearer(string provider) + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var starts = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session, provider); + + Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count); + Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]); + Assert.Null(host.Heimdall.StartAuthorizations[^1]); + var token = host.Token(account); + var step = await Finish(host, session, start, account, token); + + Assert.Equal(starts + 2, host.Heimdall.StartBodies.Count); + Assert.Equal($"/v1/oauth/{provider}/start", host.Heimdall.StartPaths[^1]); + Assert.Equal("Bearer " + token, host.Heimdall.StartAuthorizations[^1]); + using var link = JsonDocument.Parse(host.Heimdall.StartBodies[^1]); + Assert.Equal("link", link.RootElement.GetProperty("mode").GetString()); + Assert.Equal("bifrost", link.RootElement.GetProperty("appSlug").GetString()); + Assert.False(link.RootElement.TryGetProperty("stepUp", out _)); + Assert.Equal($"https://heimdall.test/oauth/{provider}/authorize?state=link-fixture", Location(step)); + } + + [Theory] + [InlineData("twitch")] + [InlineData("github")] + [InlineData("DISCORD2")] + [InlineData("")] + public async Task A_provider_Heimdalls_bifrost_profile_does_not_admit_starts_nothing(string provider) + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + var starts = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session, provider); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=error", Location(start.Response)); + Assert.Null(start.Cookie); + } + + [Fact] + public async Task A_provider_that_attaches_in_the_callback_comes_back_with_a_link_completion_and_is_linked_once() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session, "patreon"); + var awaiting = host.AttemptFrom((await ContinueResponse(host, session, start, account)), host.Heimdall.StartBodies.Count - 1); + Assert.Contains("\"Purpose\":6", Unprotect(host, awaiting.Cookie!)); + Assert.Contains("\"Target\":\"patreon\"", Unprotect(host, awaiting.Cookie!)); + host.Heimdall.AddIdentity("discord"); + var redeems = host.Heimdall.RedeemCalls; + + var done = await Finish(host, session, awaiting, account, mode: "link", provider: "patreon"); + + Assert.Equal(Page + "?result=linked", Location(done)); + Assert.Equal(redeems + 1, host.Heimdall.RedeemCalls); + Assert.Contains("now linked to your Heimdall account", (await host.GetLinkedAccountsAsync(session, "?result=linked")).Html); + Assert.Null(HeimdallHost.CookieValue(done, BifrostCookies.Session)); + var replay = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = awaiting.Nonce, ["code"] = "gone" }, Jar(session, awaiting)); + Assert.Equal(Page + "?result=error", Location(replay)); + } + + private static async Task ContinueResponse(HeimdallHost host, string session, StartedAttempt start, string account) => + await Finish(host, session, start, account); + + [Theory] + [InlineData("sign_in", "patreon")] + [InlineData("link", "discord")] + public async Task A_direct_link_completion_for_another_mode_or_provider_links_nothing(string mode, string provider) + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session, "patreon"); + var before = host.Heimdall.StartBodies.Count; + var awaiting = host.AttemptFrom(await ContinueResponse(host, session, start, account), before); + + var done = await Finish(host, session, awaiting, account, mode: mode, provider: provider); + + Assert.Equal(Page + "?result=error", Location(done)); + } + + [Fact] + public async Task A_direct_link_completion_proven_by_another_account_links_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session, "discord"); + var before = host.Heimdall.StartBodies.Count; + var awaiting = host.AttemptFrom(await ContinueResponse(host, session, start, account), before); + + var done = await Finish(host, session, awaiting, HeimdallShapes.NewAccountId(), mode: "link", provider: "discord"); + + Assert.Equal(Page + "?result=error", Location(done)); + } + + [Fact] + public async Task A_link_attempt_cookie_written_before_targets_existed_still_links_Google() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var nonce = "legacy-" + Guid.NewGuid().ToString("N"); + var protector = host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1"); + var legacy = protector.Protect(JsonSerializer.Serialize(new Dictionary + { + ["Nonce"] = nonce, ["Provider"] = "discord", ["RequiresMemberAccess"] = false, ["ReturnPath"] = "/Membership/Status", + ["ExpiresAt"] = host.Clock.Now.AddMinutes(5), ["Purpose"] = 1, ["ExpectedAccountId"] = account + })); + var attempt = new StartedAttempt( + nonce, HeimdallAttemptCookie.SlotNames[0], legacy, $"https://localhost/auth/heimdall/complete?attempt={nonce}", null!); + + var step = await Finish(host, session, attempt, account); + + Assert.Equal("/v1/oauth/google/start", host.Heimdall.StartPaths[^1]); + Assert.Equal("https://heimdall.test/oauth/google/authorize?state=link-fixture", Location(step)); + } + + // ---- the page and its neighbours ---- + + [Fact] + public async Task Status_points_a_Heimdall_member_to_the_page_and_no_longer_starts_a_link_itself() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + using var client = host.NewClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/Membership/Status"); + request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}"); + + var html = await (await client.SendAsync(request)).Content.ReadAsStringAsync(); + + Assert.Contains(Page, html); + Assert.DoesNotContain("handler=LinkGoogle", html); + Assert.DoesNotContain("LinkGoogle", html); + } + + [Theory] + [InlineData("linked")] + [InlineData("identity_linked_elsewhere")] + [InlineData("expired")] + [InlineData("unlinked")] + [InlineData("last_identity")] + [InlineData("step_up_required")] + [InlineData("identity_not_found")] + [InlineData("sign_in_required")] + [InlineData("proof_unavailable")] + [InlineData("list_expired")] + [InlineData("error")] + public void Every_outcome_code_has_words_and_nothing_else_does(string code) + { + Assert.False(string.IsNullOrWhiteSpace(HeimdallLinkOutcomes.Wording(code))); + Assert.Null(HeimdallLinkOutcomes.Wording(code + " ")); + Assert.Null(HeimdallLinkOutcomes.Wording("" + code)); + Assert.Contains( + Enum.GetValues(), outcome => HeimdallLinkOutcomes.Code(outcome) == code); + } + + [Fact] + public async Task Link_refusals_say_the_login_belongs_to_another_account_without_naming_it() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + + var (_, html) = await host.GetLinkedAccountsAsync(session, "?result=identity_linked_elsewhere"); + + Assert.Contains("That login belongs to another account. Nothing was linked.", html); + Assert.DoesNotContain("merge", html.Replace("nothing is merged", string.Empty), StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs b/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs index 5deb1a8..1b8d306 100644 --- a/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs +++ b/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs @@ -105,6 +105,7 @@ public sealed partial class NoThirdPartyAssetTests(TestWebApplicationFactory fac ("/auth/sign-in", null, true), ("/auth/bifrost/register", null, true), ("/auth/bifrost/consent", identity, false), + ("/Membership/LinkedAccounts", identity, false), }; foreach (var (path, who, anonymous) in pages) diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index a074ede..d491d0c 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -133,11 +133,13 @@ public static class HeimdallShapes string accessToken, string[]? looseCapabilities = null, string[]? looseFacts = null, - string looseName = "Loose Name") => new() + string looseName = "Loose Name", + string mode = "sign_in", + string provider = "discord") => new() { ["status"] = "success", - ["provider"] = "discord", - ["mode"] = "sign_in", + ["provider"] = provider, + ["mode"] = mode, ["appSlug"] = "bifrost", ["account"] = new Dictionary { ["id"] = accountId, ["displayName"] = looseName }, ["entitlements"] = new Dictionary @@ -220,6 +222,115 @@ public sealed class FakeHeimdall : HttpMessageHandler private readonly HashSet _pendingLinks = new(StringComparer.Ordinal); + /// The logins the fake account holds, as GET /v1/me/identities answers them (with the fields Bifrost must never keep). + public List> Identities { get; } = []; + + /// Adds a login to the fake account and returns its id. + public string AddIdentity(string provider, string? displayName = "A Name", string? id = null) + { + id ??= Guid.NewGuid().ToString(); + Identities.Add(new Dictionary + { + ["id"] = id, + ["provider"] = provider, + ["providerUserId"] = "PUID-CANARY-" + id[..8], + ["username"] = "username-canary-" + id[..8], + ["displayName"] = displayName, + ["linkedAt"] = "2026-11-13T12:00:00.000Z", + ["scopes"] = new[] { "scope-canary" } + }); + return id; + } + + public List ListAuthorizations { get; } = []; + + /// When set, GET /v1/me/identities answers this status with an error body. + public HttpStatusCode? ListStatus { get; set; } + + /// The Authorization header and identity id of every unlink. + public List<(string? Authorization, string Id)> Unlinks { get; } = []; + + /// When set, every unlink answers this status with Heimdall's error code for it (the fake's own rules are bypassed). + public HttpStatusCode? UnlinkStatus { get; set; } + + /// Throws instead of answering a member route, as an unreachable Heimdall does. + public Exception? MemberFailure { get; set; } + + private HttpResponseMessage MemberRoute(HttpRequestMessage request, string path) + { + static HttpResponseMessage Json(HttpStatusCode status, string json) => + new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + if (MemberFailure is not null) + { + throw MemberFailure; + } + + var authorization = request.Headers.Authorization?.ToString(); + object Listing() => new { accountId = "a", identities = Identities }; + if (request.Method == HttpMethod.Get && path == "/v1/me/identities") + { + ListAuthorizations.Add(authorization); + if (authorization is null) + { + return Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""); + } + + return ListStatus switch + { + HttpStatusCode.Unauthorized => Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""), + { } listed => Json(listed, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}"""), + _ => Json(HttpStatusCode.OK, JsonSerializer.Serialize(Listing())) + }; + } + + var segments = path.Split('/'); + if (request.Method == HttpMethod.Post && segments is ["", "v1", "me", "identities", var id, "unlink"]) + { + Unlinks.Add((authorization, id)); + if (authorization is null) + { + return Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""); + } + + if (UnlinkStatus is { } forced) + { + return forced switch + { + HttpStatusCode.Unauthorized => Json(forced, """{"error":"sign_in_required"}"""), + HttpStatusCode.Forbidden => Json(forced, """{"error":"step_up_required"}"""), + HttpStatusCode.NotFound => Json(forced, """{"error":"identity_not_found"}"""), + HttpStatusCode.Conflict => Json(forced, """{"error":"last_identity"}"""), + _ => Json(forced, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}""") + }; + } + + // Heimdall's rule: the bearer must carry a step_up_at claim. (The 600 second window is Heimdall's own and not modelled.) + var payload = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(authorization["Bearer ".Length..].Split('.')[1])); + if (!payload.Contains("\"step_up_at\"", StringComparison.Ordinal)) + { + return Json(HttpStatusCode.Forbidden, """{"error":"step_up_required"}"""); + } + + var held = Identities.FirstOrDefault(x => (string?)x["id"] == id); + if (held is null) + { + return Json(HttpStatusCode.NotFound, """{"error":"identity_not_found"}"""); + } + + if (Identities.Count == 1) + { + return Json(HttpStatusCode.Conflict, """{"error":"last_identity"}"""); + } + + Identities.Remove(held); + return Json( + HttpStatusCode.OK, + JsonSerializer.Serialize(new { unlinked = new { id, provider = held["provider"], providerUserId = held["providerUserId"] }, identities = Identities })); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + /// Registers a pending link Heimdall will confirm once for any bearer. public string RegisterPendingLink(string? id = null) { @@ -297,10 +408,11 @@ public sealed class FakeHeimdall : HttpMessageHandler }; } + var linkProvider = path.Split('/')[3]; return new HttpResponseMessage(HttpStatusCode.Created) { Content = new StringContent( - """{"authorizationUrl":"https://heimdall.test/oauth/google/authorize?state=link-fixture"}""", Encoding.UTF8, "application/json") + $$"""{"authorizationUrl":"https://heimdall.test/oauth/{{linkProvider}}/authorize?state=link-fixture"}""", Encoding.UTF8, "application/json") }; } @@ -318,6 +430,11 @@ public sealed class FakeHeimdall : HttpMessageHandler return new HttpResponseMessage(StartStatus) { Content = content }; } + if (path.StartsWith("/v1/me/identities", StringComparison.Ordinal)) + { + return MemberRoute(request, path); + } + if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/links/confirm") { var body = await request.Content!.ReadAsStringAsync(cancellationToken); @@ -512,6 +629,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory services.AddSingleton(Clock); services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall); services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); + services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); }); } @@ -561,10 +679,10 @@ public sealed class HeimdallHost : TestWebApplicationFactory /// Registers a completion code as Heimdall would mint it after authenticating this attempt. public string CodeFor(StartedAttempt attempt, string accountId, string? accessToken = null, string[]? looseCapabilities = null, - string[]? looseFacts = null, string looseName = "Loose Name", string? returnTo = null) + string[]? looseFacts = null, string looseName = "Loose Name", string? returnTo = null, string mode = "sign_in", string provider = "discord") { var completion = HeimdallShapes.Completion( - returnTo ?? attempt.ReturnTo, accountId, accessToken ?? Token(accountId), looseCapabilities, looseFacts, looseName); + returnTo ?? attempt.ReturnTo, accountId, accessToken ?? Token(accountId), looseCapabilities, looseFacts, looseName, mode, provider); return Heimdall.RegisterCode(completion); } @@ -623,18 +741,33 @@ public sealed class HeimdallHost : TestWebApplicationFactory return await client.SendAsync(request); } - public const string LinkPostUrl = "/Membership/Status?handler=LinkGoogle"; + public const string LinkedAccountsUrl = "/Membership/LinkedAccounts"; + + /// The Linked accounts page as a browser reads it, with its session when there is one. + public async Task<(HttpResponseMessage Response, string Html)> GetLinkedAccountsAsync(string? session, string query = "") + { + using var client = NewClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, LinkedAccountsUrl + query); + if (session is not null) + { + request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}"); + } + + var response = await client.SendAsync(request); + return (response, await response.Content.ReadAsStringAsync()); + } /// - /// The account page's link button as a browser posts it: the page is fetched for its antiforgery token and cookie - /// (when ), then the form is posted with the session. Without a session nothing is fetched. + /// One of the page's buttons as a browser posts it: the page is fetched for its antiforgery token and cookie (when + /// ), then the form is posted with the session. Without a session nothing is fetched. /// - public async Task StartLinkAsync( - string? session, bool antiforgery = true, HttpMethod? method = null, string? extraCookies = null) + public async Task PostLinkedAccountsAsync( + string? session, string handler, IDictionary? form = null, bool antiforgery = true, HttpMethod? method = null, + string? extraCookies = null) { using var client = NewClient(); var cookies = new List(); - var fields = new Dictionary(); + var fields = new Dictionary(form ?? new Dictionary()); if (session is not null) { cookies.Add($"{BifrostCookies.Session}={session}"); @@ -642,7 +775,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory if (antiforgery && session is not null) { - using var get = new HttpRequestMessage(HttpMethod.Get, "/Membership/Status"); + using var get = new HttpRequestMessage(HttpMethod.Get, LinkedAccountsUrl); get.Headers.Add("Cookie", string.Join("; ", cookies)); using var page = await client.SendAsync(get); var html = await page.Content.ReadAsStringAsync(); @@ -658,7 +791,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory } var before = Heimdall.StartBodies.Count; - using var request = new HttpRequestMessage(method ?? HttpMethod.Post, LinkPostUrl); + using var request = new HttpRequestMessage(method ?? HttpMethod.Post, $"{LinkedAccountsUrl}?handler={handler}"); if (request.Method == HttpMethod.Post) { request.Content = new FormUrlEncodedContent(fields); @@ -672,6 +805,15 @@ public sealed class HeimdallHost : TestWebApplicationFactory return AttemptFrom(await client.SendAsync(request), before); } + public Task StartLinkAsync( + string? session, string provider = "google", bool antiforgery = true, HttpMethod? method = null, string? extraCookies = null) => + PostLinkedAccountsAsync(session, "Link", new Dictionary { ["provider"] = provider }, antiforgery, method, extraCookies); + + public Task StartListAsync(string? session) => PostLinkedAccountsAsync(session, "Refresh"); + + public Task StartUnlinkAsync(string? session, string identityId) => + PostLinkedAccountsAsync(session, "Unlink", new Dictionary { ["identityId"] = identityId }); + public Task CompleteAsync(StartedAttempt attempt, string code) => CompleteAsync(attempt.Nonce, code, attempt.Cookie, attempt.CookieName); From b65bec65ce08ff96a1cdd151e6dfa19bef2bd5d4 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 19:43:59 +0000 Subject: [PATCH 052/102] Linked accounts: docs, README line, proof-provider and no-store tests Co-Authored-By: Claude Sonnet 5.5 --- README.md | 1 + docs/jurisdiction-map.md | 2 + docs/linked-accounts.md | 49 +++++++++++++++++++ .../Bifrost.Web.Tests/LinkedAccountsTests.cs | 17 ++++++- 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 docs/linked-accounts.md diff --git a/README.md b/README.md index 8aaa457..3cdcc75 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core - Razor Pages app under `src/Bifrost.Web` - PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations` - Bifrost-native identity registration plus GitHub OAuth and Heimdall-linked provider identities, with invite/approval-based membership gating +- a Linked accounts page where a member sees, links and unlinks their Heimdall provider logins, with unlink gated on a fresh Google step-up proof ([docs/linked-accounts.md](docs/linked-accounts.md)) - explicit member roles and admin-managed patron or contributor tier snapshots - patron support event recording with derived patron point summaries and tier refresh for voting weight - shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links diff --git a/docs/jurisdiction-map.md b/docs/jurisdiction-map.md index a251b00..8163eee 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -105,6 +105,8 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity. +A member's provider logins are Heimdall's (`linked_identities`); Bifrost shows, links and unlinks them on `/Membership/LinkedAccounts` by calling Heimdall's member routes with a bearer from a fresh Heimdall sign-in, and keeps none (`docs/linked-accounts.md`). + Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed. ### Patron Collection Crossing diff --git a/docs/linked-accounts.md b/docs/linked-accounts.md new file mode 100644 index 0000000..041e79c --- /dev/null +++ b/docs/linked-accounts.md @@ -0,0 +1,49 @@ +# Linked accounts + +`/Membership/LinkedAccounts` (`src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml[.cs]`) is where a signed-in member sees the provider logins attached to their Heimdall account, links another one, and unlinks one. Heimdall owns the logins and every rule about them; this page starts the flows and shows what Heimdall answered. A member reaches it from the membership status page. A session with no Heimdall account (a Bifrost-native GitHub sign-in) gets a note instead of controls. + +## What it does + +| Action | What Bifrost does | Heimdall call | +|---|---|---| +| Show my linked logins | A fresh ordinary Heimdall sign-in as the session's account; inside its completion, the list | `GET /v1/me/identities` with that sign-in's bearer | +| Link Discord, Patreon or Google | The same fresh sign-in as proof, then the link start for the chosen provider with its bearer. Discord and Patreon attach in Heimdall's callback and return a link completion; Google writes a pending link, and a second fresh sign-in confirms it | `POST /v1/oauth/{provider}/start` `mode: "link"`, then for Google `POST /v1/apps/bifrost/links/confirm` | +| Unlink | A sign-in through Google with `stepUp: true` (app secret); inside its completion, the unlink | `POST /v1/me/identities/{id}/unlink` with that sign-in's bearer | + +The linkable providers are the ones Heimdall's `bifrost` profile admits (`identityProviders` in Heimdall `src/app-profiles.ts`): Discord, Patreon, Google. Twitch is offered for sign-in elsewhere in Bifrost but the profile refuses it, so it is not offered here. + +## How the bearer is kept: it is not + +Bifrost keeps no Heimdall token. Each action is an attempt: a Data Protection cookie (`__Host-bifrost_heimdall_`, HttpOnly, Secure, ten minutes) holding a purpose (`ListProof`, `UnlinkProof`, `LinkStart`, `LinkAwaitGoogle`, `LinkConfirm`, `LinkAwaitDirect`), the account the session must be, and a target (the provider to link, or the identity to unlink). The browser returns from Heimdall to `/auth/heimdall/complete`; Bifrost redeems the code, verifies the token, requires that the proven account is the attempt's account and that the browser's session is that same account, calls Heimdall with the bearer inside that one request, and drops it. The bearer is never stored, logged, put in a cookie or a redirect, or rendered. Heimdall's member routes go through an HTTP client that carries no app secret (`HeimdallMemberClient`), so a Bifrost fault cannot call them without a member present. + +The list is kept for five minutes in this process only (`LinkedAccountsSnapshots`), keyed by the Heimdall account id, so the page can render after the completion's redirect: identity id, provider, display name (or username when there is none) and link date. Never the bearer, the email, the provider user id or the scopes. It is not given to the browser. A restart or the five minutes empties it and the page offers "Show my linked logins" again. + +Every action is a POST with antiforgery; a GET starts nothing. The page and every redirect carry only a code from a closed set (`HeimdallLinkOutcomes`, `?result=`); the wording is Bifrost's, never Heimdall's text or the query's. + +## The proof limit + +Unlinking needs fresh proof: Heimdall refuses `403 step_up_required` unless the access token carries `step_up_at` from a step-up sign-in within 600 seconds (`docs/service-contract.md`, `POST /v1/me/identities/{identityId}/unlink`, in the Heimdall repository). Only Google can give that to a `bifrost` sign-in: Discord and Patreon have no re-authentication parameter (an open provider session completes without credentials), and Twitch and YouTube, which do, are not in the `bifrost` profile. So: + +- An account with no Google login linked cannot unlink anything from here. The page says so and shows no Unlink button; a posted unlink starts nothing. +- Heimdall answers `503 provider_not_configured` for Google until a Google OAuth client is configured on the Heimdall host (follow-up `heimdall-body:follow_up:google-console-client-registration`; not set live as of 2026-11-13). The step-up start is then refused and the page says fresh proof could not be started. Nothing loops: the refusal sets no attempt cookie. +- Linking Google needs the same client. Until then the Google link button ends in "That could not be completed. Nothing was changed." +- A Google step-up proof is as strong as Google's `prompt=login`; Heimdall says "asked", not "verified" (`stepUpRequested`). + +Heimdall's other answers are shown as plain outcomes with nothing changed: `409 last_identity` (the last login is never removed), `404 identity_not_found` (the list is dropped as stale), `401 sign_in_required`, `403 step_up_required`, and any failure to reach Heimdall. + +A link refused because the login already belongs to another account (`409 identity_linked_elsewhere` from `links/confirm`; for Discord and Patreon the callback's `link_initiator_mismatch`, carried on by the complete page as a fixed result) is shown as "That login belongs to another account." The other account is never named. + +## Not here + +- Merging two accounts. A refused link ends there; nothing is moved or merged. +- Step-up through Discord, Patreon or Twitch. Heimdall does not offer it to the `bifrost` profile. +- Linking Twitch or GitHub: not admitted by the `bifrost` profile, and Heimdall's GitHub runtime is not implemented. +- A list of other apps' logins, grants or consent receipts (Heimdall's grants page). + +## Where the code is + +- `Pages/Membership/LinkedAccounts.cshtml[.cs]`: the page and its three POST handlers. +- `Features/Heimdall/HeimdallAuthService.cs`: the attempt machinery (`StartListAsync`, `StartLinkAsync`, `StartUnlinkAsync`, `ContinueLinkAsync`), shared with sign-in. +- `Features/Heimdall/HeimdallMemberClient.cs`: the two member calls and their outcome mapping. +- `Features/Heimdall/LinkedAccountsSnapshots.cs`: the five-minute list. +- Tests: `tests/Bifrost.Web.Tests/LinkedAccountsTests.cs`, `HeimdallGoogleLinkTests.cs`, with the fake Heimdall in `Support/HeimdallTestKit.cs`. diff --git a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs index a58d58f..80177c2 100644 --- a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs +++ b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs @@ -95,9 +95,11 @@ public sealed class LinkedAccountsTests { using var host = new HeimdallHost(); - var (_, html) = await host.GetLinkedAccountsAsync(session: null); + var (response, html) = await host.GetLinkedAccountsAsync(session: null); var start = await host.StartListAsync(session: null); + // No form, so no antiforgery token to make the response uncacheable: the page itself must say no-store. + Assert.Contains("no-store", response.Headers.CacheControl?.ToString()); Assert.Contains("not a Heimdall account", html); Assert.DoesNotContain("Link Discord", html); Assert.Empty(host.Heimdall.StartBodies); @@ -558,6 +560,19 @@ public sealed class LinkedAccountsTests Assert.Equal($"https://heimdall.test/oauth/{provider}/authorize?state=link-fixture", Location(step)); } + [Fact] + public async Task A_proof_sign_in_goes_through_a_provider_the_account_still_holds() + { + using var host = new HeimdallHost(realSessions: true); + // Signed in through Discord, which the account has since unlinked: signing in through it again would mint another account. + var member = await ListedMember(host, "patreon", "google"); + + var start = await host.StartLinkAsync(member.Session, "discord"); + + Assert.Equal("/v1/oauth/patreon/start", host.Heimdall.StartPaths[^1]); + Assert.NotNull(start.Cookie); + } + [Theory] [InlineData("twitch")] [InlineData("github")] From 8e65f56b0c259e34375c0911f07cf05656ec9efd Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 20:08:07 +0000 Subject: [PATCH 053/102] Linked accounts: never guess a proof provider; require stepUpRequested; no-store on completion With no current list the proof goes only through a provider the member names, never the session's sign-in provider: a sign-in through an unlinked provider makes Heimdall mint an orphan account holding that login. Link buttons need a current list. The unlink completion must say a step-up was requested. POST /auth/heimdall/complete answers no-store. The fake Heimdall now models orphan minting, derives step_up_at from the start's stepUp flag, and enforces the app secret, sign_in-only, unsupported-provider and 600 s rules. Co-Authored-By: Claude Sonnet 5.5 --- docs/linked-accounts.md | 20 ++- .../Features/Heimdall/HeimdallAuthService.cs | 63 +++++--- .../Pages/Membership/LinkedAccounts.cshtml | 50 ++++--- .../Pages/Membership/LinkedAccounts.cshtml.cs | 25 +++- src/Bifrost.Web/Program.cs | 2 + .../Bifrost.Web.Tests/LinkedAccountsTests.cs | 139 ++++++++++++++++-- .../Support/HeimdallTestKit.cs | 95 +++++++++++- 7 files changed, 325 insertions(+), 69 deletions(-) diff --git a/docs/linked-accounts.md b/docs/linked-accounts.md index 041e79c..1d5636f 100644 --- a/docs/linked-accounts.md +++ b/docs/linked-accounts.md @@ -6,8 +6,8 @@ | Action | What Bifrost does | Heimdall call | |---|---|---| -| Show my linked logins | A fresh ordinary Heimdall sign-in as the session's account; inside its completion, the list | `GET /v1/me/identities` with that sign-in's bearer | -| Link Discord, Patreon or Google | The same fresh sign-in as proof, then the link start for the chosen provider with its bearer. Discord and Patreon attach in Heimdall's callback and return a link completion; Google writes a pending link, and a second fresh sign-in confirms it | `POST /v1/oauth/{provider}/start` `mode: "link"`, then for Google `POST /v1/apps/bifrost/links/confirm` | +| Show my logins | A fresh ordinary Heimdall sign-in as the session's account, through the proof provider (below); inside its completion, the list | `GET /v1/me/identities` with that sign-in's bearer | +| Link Discord, Patreon or Google | The same fresh sign-in as proof, then the link start for the chosen provider with its bearer. Needs a current list. Discord and Patreon attach in Heimdall's callback and return a link completion; Google writes a pending link, and a second fresh sign-in confirms it | `POST /v1/oauth/{provider}/start` `mode: "link"`, then for Google `POST /v1/apps/bifrost/links/confirm` | | Unlink | A sign-in through Google with `stepUp: true` (app secret); inside its completion, the unlink | `POST /v1/me/identities/{id}/unlink` with that sign-in's bearer | The linkable providers are the ones Heimdall's `bifrost` profile admits (`identityProviders` in Heimdall `src/app-profiles.ts`): Discord, Patreon, Google. Twitch is offered for sign-in elsewhere in Bifrost but the profile refuses it, so it is not offered here. @@ -18,7 +18,15 @@ Bifrost keeps no Heimdall token. Each action is an attempt: a Data Protection co The list is kept for five minutes in this process only (`LinkedAccountsSnapshots`), keyed by the Heimdall account id, so the page can render after the completion's redirect: identity id, provider, display name (or username when there is none) and link date. Never the bearer, the email, the provider user id or the scopes. It is not given to the browser. A restart or the five minutes empties it and the page offers "Show my linked logins" again. -Every action is a POST with antiforgery; a GET starts nothing. The page and every redirect carry only a code from a closed set (`HeimdallLinkOutcomes`, `?result=`); the wording is Bifrost's, never Heimdall's text or the query's. +Every action is a POST with antiforgery; a GET starts nothing. The completion route's answers are `Cache-Control: no-store`. The page and every redirect carry only a code from a closed set (`HeimdallLinkOutcomes`, `?result=`); the wording is Bifrost's, never Heimdall's text or the query's. + +## Which provider proves the account + +An ordinary proof sign-in through a provider the account no longer holds makes Heimdall mint a new, unrelated account that holds that login, and the freed login then answers `identity_linked_elsewhere` when the member tries to link it back. So Bifrost never guesses a provider, and it does not use the "provider you signed in with" claim in the session (that records how you signed in once, not what the account holds now): + +- With a current list (the five-minute snapshot), the proof goes through a provider on it: Discord, else Patreon, else Google. +- With no list (first visit, five minutes passed, a restart), the page offers only "Show my logins through Discord / Patreon / Google" and says plainly that Bifrost cannot tell what the account holds, so the member picks a login they know it holds. A named provider the account turns out not to hold still mints at Heimdall: that is the member's choice and the page says so beforehand. A refresh with no list and no named provider starts nothing (`provider_needed`). +- Link buttons appear only with a current list; a posted link with none starts nothing (`list_expired`). Unlink needs the list too. ## The proof limit @@ -29,7 +37,7 @@ Unlinking needs fresh proof: Heimdall refuses `403 step_up_required` unless the - Linking Google needs the same client. Until then the Google link button ends in "That could not be completed. Nothing was changed." - A Google step-up proof is as strong as Google's `prompt=login`; Heimdall says "asked", not "verified" (`stepUpRequested`). -Heimdall's other answers are shown as plain outcomes with nothing changed: `409 last_identity` (the last login is never removed), `404 identity_not_found` (the list is dropped as stale), `401 sign_in_required`, `403 step_up_required`, and any failure to reach Heimdall. +The unlink completion must also say a step-up was requested (`stepUpRequested`, set by Heimdall when the start asked for one); one that does not is refused without calling Heimdall. Heimdall's other answers are shown as plain outcomes with nothing changed: `409 last_identity` (the last login is never removed), `404 identity_not_found` (the list is dropped as stale), `401 sign_in_required`, `403 step_up_required`, and any failure to reach Heimdall. A link refused because the login already belongs to another account (`409 identity_linked_elsewhere` from `links/confirm`; for Discord and Patreon the callback's `link_initiator_mismatch`, carried on by the complete page as a fixed result) is shown as "That login belongs to another account." The other account is never named. @@ -47,3 +55,7 @@ A link refused because the login already belongs to another account (`409 identi - `Features/Heimdall/HeimdallMemberClient.cs`: the two member calls and their outcome mapping. - `Features/Heimdall/LinkedAccountsSnapshots.cs`: the five-minute list. - Tests: `tests/Bifrost.Web.Tests/LinkedAccountsTests.cs`, `HeimdallGoogleLinkTests.cs`, with the fake Heimdall in `Support/HeimdallTestKit.cs`. + +## Known leftover + +When Discord or Patreon's callback refuses a link because the login belongs to another account, the complete page's script redirects to the page with the fixed result and does not post anything. The held attempt cookie (`__Host-bifrost_heimdall_`, HttpOnly, so script cannot clear it) stays until its ten minutes end and occupies one of the two slots meanwhile; a new start reuses a free or the oldest slot, so nothing blocks. Clearing it would need a server route reached by GET or a post from the failure path, which the hardened script deliberately does not do. diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 96db4ab..e3c0f39 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -163,27 +163,28 @@ public sealed class HeimdallAuthService( } /// - /// The provider an ordinary proof sign-in uses: the one the person signed in with when the account still holds it, - /// else the first held one of Discord, Patreon, Google. With no list to go by, the session's provider, else Discord. - /// Signing in through a provider the account no longer holds would only mint an unrelated Heimdall account. + /// The provider an ordinary proof sign-in uses, or null when none is safe. A sign-in through a provider the account no + /// longer holds makes Heimdall mint an unrelated account that holds that login, so it is never guessed: with the account's + /// current list, the first held one of the person's choice, Discord, Patreon, Google; with no list, only a provider the + /// member named (), who is the one who knows what they hold. The session's provider claim is not + /// consulted: it records how they signed in once, not what the account holds now. /// - public static string ProofProvider(string? sessionProvider, IReadOnlyCollection? held) + public static string? ProofProvider(IReadOnlyCollection? held, string? via) { - var session = sessionProvider is not null && ProofProviders.Contains(sessionProvider) ? sessionProvider.ToLowerInvariant() : null; - if (held is { Count: > 0 }) + var chosen = via is not null && ProofProviders.Contains(via) ? via.ToLowerInvariant() : null; + if (held is null) { - foreach (var candidate in new[] { session, "discord", "patreon", "google" }) - { - if (candidate is not null && held.Contains(candidate, StringComparer.OrdinalIgnoreCase)) - { - return candidate; - } - } + return chosen; } - return session ?? "discord"; + return new[] { chosen, "discord", "patreon", "google" } + .FirstOrDefault(x => x is not null && held.Contains(x, StringComparer.OrdinalIgnoreCase)); } + private string ProofProviderFor(string accountId, string? via) => + ProofProvider(snapshots.Get(accountId)?.Select(x => x.Provider).ToArray(), via) + ?? throw new HeimdallAuthException("No provider the account is known to hold can prove it."); + /// /// The page's proof sign-ins. Each is a Heimdall sign-in for the account already signed in here, kept as an attempt /// that says what it is the proof for (list, link, unlink) and which person it must prove. The session is never replaced. @@ -209,17 +210,23 @@ public sealed class HeimdallAuthService( stepUp: stepUp, target: target); - /// Reads the account's logins: an ordinary sign-in, then GET /v1/me/identities inside its completion. + /// + /// Reads the account's logins: an ordinary sign-in, then GET /v1/me/identities inside its completion. Through a provider on + /// the current list, or, when there is none, the one the member names in . + /// public Task StartListAsync( - string sessionAccountId, string? sessionProvider, string callbackBaseUrl, + string sessionAccountId, string? via, string callbackBaseUrl, IEnumerable> requestCookies, CancellationToken cancellationToken) => StartProofAsync( - HeimdallAttemptPurpose.ListProof, ProofProvider(sessionProvider, snapshots.Get(sessionAccountId)?.Select(x => x.Provider).ToArray()), + HeimdallAttemptPurpose.ListProof, ProofProviderFor(sessionAccountId, via), sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken); - /// Links another provider: an ordinary sign-in as proof, then the link start for with its bearer. + /// + /// Links another provider: an ordinary sign-in as proof through a provider on the account's current list (there is no + /// guessing without one), then the link start for with its bearer. + /// public Task StartLinkAsync( - string sessionAccountId, string? sessionProvider, string targetProvider, string callbackBaseUrl, + string sessionAccountId, string targetProvider, string callbackBaseUrl, IEnumerable> requestCookies, CancellationToken cancellationToken) { if (!LinkableProviders.Contains(targetProvider)) @@ -228,7 +235,7 @@ public sealed class HeimdallAuthService( } return StartProofAsync( - HeimdallAttemptPurpose.LinkStart, ProofProvider(sessionProvider, snapshots.Get(sessionAccountId)?.Select(x => x.Provider).ToArray()), + HeimdallAttemptPurpose.LinkStart, ProofProviderFor(sessionAccountId, null), sessionAccountId, callbackBaseUrl, requestCookies, false, targetProvider.ToLowerInvariant(), cancellationToken); } @@ -458,7 +465,13 @@ public sealed class HeimdallAuthService( case HeimdallAttemptPurpose.UnlinkProof: { - var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + var (identity, bearer, redeemed) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken); + if (redeemed.StepUpRequested != true) + { + // Heimdall reports "asked" on a step-up completion; one that does not was not a step-up sign-in. + return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.StepUpRequired); + } + var unlinked = await memberClient.UnlinkAsync(bearer, attempt.Target ?? string.Empty, cancellationToken); switch (unlinked.Call) { @@ -838,7 +851,8 @@ public enum HeimdallLinkOutcome IdentityNotFound, SignInRequired, ProofUnavailable, - ListExpired + ListExpired, + ProviderNeeded } /// @@ -865,6 +879,7 @@ public static class HeimdallLinkOutcomes HeimdallLinkOutcome.SignInRequired => "sign_in_required", HeimdallLinkOutcome.ProofUnavailable => "proof_unavailable", HeimdallLinkOutcome.ListExpired => "list_expired", + HeimdallLinkOutcome.ProviderNeeded => "provider_needed", _ => "error" }; @@ -881,6 +896,7 @@ public static class HeimdallLinkOutcomes "identity_not_found" => "That login is no longer on your account. Nothing was changed; the list below is read again when you ask.", "sign_in_required" => "Heimdall no longer accepts that sign-in. Nothing was changed; try again.", "proof_unavailable" => "Fresh proof could not be started, so nothing was changed. Unlinking needs a fresh Google sign-in, and it is not available right now.", + "provider_needed" => "Bifrost does not know which logins this account holds right now, so it will not guess. Pick one you know it holds to read the list. Nothing was changed.", "list_expired" => "The list was out of date. Nothing was changed; read your linked logins again.", "error" => "That could not be completed. Nothing was changed.", _ => null @@ -928,7 +944,8 @@ public sealed record HeimdallRedeemResponse( [property: JsonPropertyName("accessToken")] string? AccessToken, [property: JsonPropertyName("account")] HeimdallAccount? Account, [property: JsonPropertyName("mode")] string? Mode = null, - [property: JsonPropertyName("provider")] string? Provider = null); + [property: JsonPropertyName("provider")] string? Provider = null, + [property: JsonPropertyName("stepUpRequested")] bool? StepUpRequested = null); public sealed record HeimdallAccount( [property: JsonPropertyName("id")] string Id); diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml index 75ee5c2..6a596c3 100644 --- a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml @@ -29,10 +29,21 @@

Your logins

@if (Model.Identities is null) { -
- - You will sign in to Heimdall again, which reads the list. It is kept for five minutes, then asked for again. -
+

+ Bifrost does not hold the list: it asks Heimdall, which means signing in to Heimdall again, and keeps the answer for + five minutes. It also cannot tell which logins this account holds right now, so it will not choose one for you. + Pick a login you know it holds. Signing in through one it no longer holds would create a separate Heimdall account + holding that login. +

+
+ @foreach (var provider in Model.LinkableProviders) + { +
+ + +
+ } +
} else { @@ -82,20 +93,23 @@ } -

Link another login

-

- You will sign in to Heimdall again, then to the provider you pick. If that login already belongs to another account, - nothing is linked and nothing is merged. -

-
- @foreach (var provider in Model.LinkableProviders) - { -
- - -
- } -
+ @if (Model.Identities is not null) + { +

Link another login

+

+ You will sign in to Heimdall again, then to the provider you pick. If that login already belongs to another account, + nothing is linked and nothing is merged. +

+
+ @foreach (var provider in Model.LinkableProviders) + { +
+ + +
+ } +
+ } }
diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs index b247e30..27b6d6d 100644 --- a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs @@ -55,13 +55,21 @@ public sealed class LinkedAccountsModel( } /// Reads the account's logins: an ordinary Heimdall sign-in, then the list inside its completion. - public Task OnPostRefreshAsync(CancellationToken cancellationToken) => - BeginAsync( - (id, ct) => heimdallAuthService.StartListAsync(id, SessionProvider, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), + public Task OnPostRefreshAsync(string? via, CancellationToken cancellationToken) + { + // With no current list Bifrost cannot tell which provider the account still holds, so the member names one. + if (AccountId is not { } id || (snapshots.Get(id) is null && (via is null || !HeimdallAuthService.LinkableProviders.Contains(via)))) + { + return Task.FromResult(Done(HeimdallLinkOutcome.ProviderNeeded)); + } + + return BeginAsync( + (account, ct) => heimdallAuthService.StartListAsync(account, via, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), HeimdallLinkOutcome.Error, cancellationToken); + } - /// Links another provider. The provider is one Heimdall's bifrost profile admits; anything else starts nothing. + /// Links another provider. The provider is one Heimdall's bifrost profile admits; anything else starts nothing, and so does a missing list. public Task OnPostLinkAsync(string? provider, CancellationToken cancellationToken) { if (provider is null || !HeimdallAuthService.LinkableProviders.Contains(provider)) @@ -69,8 +77,13 @@ public sealed class LinkedAccountsModel( return Task.FromResult(Done(HeimdallLinkOutcome.Error)); } + if (AccountId is not { } id || snapshots.Get(id) is null) + { + return Task.FromResult(Done(HeimdallLinkOutcome.ListExpired)); + } + return BeginAsync( - (id, ct) => heimdallAuthService.StartLinkAsync(id, SessionProvider, provider, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), + (account, ct) => heimdallAuthService.StartLinkAsync(account, provider, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), HeimdallLinkOutcome.Error, cancellationToken); } @@ -98,8 +111,6 @@ public sealed class LinkedAccountsModel( cancellationToken); } - private string? SessionProvider => User.FindFirstValue(BifrostClaimTypes.AuthProvider); - private IActionResult Done(HeimdallLinkOutcome outcome) => RedirectToPage(null, new Dictionary { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) }); diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 6124f40..8fc0ee5 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -829,6 +829,8 @@ app.MapPost("/auth/heimdall/complete", async ( ILogger logger, CancellationToken cancellationToken) => { + // Every answer here sets or clears an attempt cookie or signs someone in: none may be stored. + httpContext.Response.Headers.CacheControl = "no-store"; var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); var attempt = form?["attempt"].ToString(); var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt); diff --git a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs index 80177c2..098f8a6 100644 --- a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs +++ b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs @@ -28,9 +28,9 @@ public sealed class LinkedAccountsTests /// The fresh sign-in's completion as the browser posts it: Heimdall minted a code for . private static async Task Finish( HeimdallHost host, string session, StartedAttempt attempt, string proofAccount, string? token = null, - string mode = "sign_in", string provider = "discord", string? sessionOverride = null) + string mode = "sign_in", string provider = "discord", string? sessionOverride = null, bool? stepUpRequested = null) { - var code = host.CodeFor(attempt, proofAccount, token, mode: mode, provider: provider); + var code = host.CodeFor(attempt, proofAccount, token, mode: mode, provider: provider, stepUpRequested: stepUpRequested); return await host.PostCompleteAsync( new Dictionary { ["attempt"] = attempt.Nonce, ["code"] = code }, Jar(sessionOverride ?? session, attempt)); } @@ -50,9 +50,10 @@ public sealed class LinkedAccountsTests host.Heimdall.AddIdentity(provider, "Name of " + provider); } - var start = await host.StartListAsync(session); + var start = await host.StartListAsync(session, providers[0]); var done = await Finish(host, session, start, account); Assert.Equal(Page + "?result=listed", Location(done)); + Assert.Empty(host.Heimdall.OrphanMints); return new Member(account, session); } @@ -82,12 +83,83 @@ public sealed class LinkedAccountsTests Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains("no-store", response.Headers.CacheControl?.ToString()); - Assert.Contains("Show my linked logins", html); + Assert.Contains("Show my logins through", html); + Assert.Contains("Show my logins through Patreon", html); + Assert.Contains("will not choose one for you", html); + Assert.DoesNotContain("Link Discord", html); + Assert.DoesNotContain("Unlink", html); + } + + [Fact] + public async Task With_a_list_the_page_offers_each_admitted_provider_to_link_and_never_twitch() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + Assert.Contains("Link Discord", html); Assert.Contains("Link Patreon", html); Assert.Contains("Link Google", html); Assert.DoesNotContain("Link Twitch", html); - Assert.DoesNotContain("Unlink", html); + } + + [Fact] + public async Task After_the_list_expired_a_proof_is_never_started_through_a_provider_the_member_unlinked() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); // signed in through Discord + var discord = host.Heimdall.AddIdentity("discord", "D"); + host.Heimdall.AddIdentity("google", "G"); + var list = await host.StartListAsync(session, "discord"); + await Finish(host, session, list, account); + var unlink = await host.StartUnlinkAsync(session, discord); + var done = await Finish(host, session, unlink, account, provider: "google"); + Assert.Equal(Page + "?result=unlinked", Location(done)); + host.Clock.Advance(LinkedAccountsSnapshots.Lifetime + TimeSpan.FromSeconds(1)); + var starts = host.Heimdall.StartBodies.Count; + + var refresh = await host.StartListAsync(session, via: null); + var link = await host.StartLinkAsync(session, "patreon", seedList: false); + var (_, html) = await host.GetLinkedAccountsAsync(session); + + // Signing in through Discord again would mint an unrelated account holding that login: nothing is started. + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=provider_needed", Location(refresh.Response)); + Assert.Equal(Page + "?result=list_expired", Location(link.Response)); + Assert.Empty(host.Heimdall.OrphanMints); + Assert.Contains("will not choose one for you", html); + Assert.DoesNotContain("Link Patreon", html); + } + + [Theory] + [InlineData(null)] + [InlineData("twitch")] + [InlineData("nonsense")] + public async Task Without_a_list_the_member_must_name_an_admitted_provider_or_nothing_is_started(string? via) + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + var starts = host.Heimdall.StartBodies.Count; + + var post = await host.StartListAsync(session, via); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=provider_needed", Location(post.Response)); + } + + [Fact] + public async Task With_a_list_the_member_cannot_steer_the_proof_to_a_provider_the_account_does_not_hold() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "patreon", "google"); + + var post = await host.StartListAsync(member.Session, "discord"); + + Assert.Equal("/v1/oauth/patreon/start", host.Heimdall.StartPaths[^1]); + Assert.Empty(host.Heimdall.OrphanMints); + Assert.NotNull(post.Cookie); } [Fact] @@ -226,13 +298,13 @@ public sealed class LinkedAccountsTests Assert.Contains("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); var (_, other) = await host.GetLinkedAccountsAsync(stranger); Assert.DoesNotContain("Name of discord", other); - Assert.Contains("Show my linked logins", other); + Assert.Contains("Show my logins through", other); host.Clock.Advance(LinkedAccountsSnapshots.Lifetime + TimeSpan.FromSeconds(1)); var (_, later) = await host.GetLinkedAccountsAsync(member.Session); Assert.DoesNotContain("Name of discord", later); - Assert.Contains("Show my linked logins", later); + Assert.Contains("Show my logins through", later); } [Theory] @@ -251,7 +323,7 @@ public sealed class LinkedAccountsTests Assert.Equal($"{Page}?result={outcome}", Location(done)); Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines)); - Assert.Contains("Show my linked logins", (await host.GetLinkedAccountsAsync(session)).Html); + Assert.Contains("Show my logins through", (await host.GetLinkedAccountsAsync(session)).Html); } [Fact] @@ -267,7 +339,7 @@ public sealed class LinkedAccountsTests var done = await Finish(host, session, start, account); Assert.Equal(Page + "?result=error", Location(done)); - Assert.Contains("Show my linked logins", (await host.GetLinkedAccountsAsync(session)).Html); + Assert.Contains("Show my logins through", (await host.GetLinkedAccountsAsync(session)).Html); } [Fact] @@ -356,6 +428,54 @@ public sealed class LinkedAccountsTests Assert.Contains("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); } + [Fact] + public async Task A_completion_that_does_not_say_a_step_up_was_asked_unlinks_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + + // The token even carries step_up_at, but the completion is not the answer to a step-up start. + var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google", + stepUpRequested: false); + + Assert.Equal(Page + "?result=step_up_required", Location(done)); + Assert.Empty(host.Heimdall.Unlinks); + Assert.Equal(2, host.Heimdall.Identities.Count); + } + + [Fact] + public async Task A_proof_older_than_600_seconds_is_refused_by_Heimdall_and_changes_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); + // The sign-in happened 601 seconds before the unlink reaches Heimdall (the attempt itself lives ten minutes). + var stale = host.Token(member.Account, mutate: claims => claims["step_up_at"] = host.Clock.Now.ToUnixTimeSeconds() - 601); + + var done = await Finish(host, member.Session, start, member.Account, stale, provider: "google"); + + Assert.Equal(Page + "?result=step_up_required", Location(done)); + Assert.Single(host.Heimdall.Unlinks); + Assert.Equal(2, host.Heimdall.Identities.Count); + } + + [Fact] + public async Task Every_completion_answer_is_no_store() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + host.Heimdall.AddIdentity("discord"); + var start = await host.StartListAsync(session); + + var done = await Finish(host, session, start, account); + var refused = await host.PostCompleteAsync(new Dictionary { ["attempt"] = "nope" }, null); + + Assert.Contains("no-store", done.Headers.CacheControl?.ToString()); + Assert.Contains("no-store", refused.Headers.CacheControl?.ToString()); + } + [Theory] [InlineData(HttpStatusCode.NotFound, "identity_not_found")] [InlineData(HttpStatusCode.Unauthorized, "sign_in_required")] @@ -701,6 +821,7 @@ public sealed class LinkedAccountsTests [InlineData("sign_in_required")] [InlineData("proof_unavailable")] [InlineData("list_expired")] + [InlineData("provider_needed")] [InlineData("error")] public void Every_outcome_code_has_words_and_nothing_else_does(string code) { diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index d491d0c..ced87d5 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -244,6 +244,18 @@ public sealed class FakeHeimdall : HttpMessageHandler public List ListAuthorizations { get; } = []; + /// The clock the fake judges a step-up proof's age by (the host sets it to its test clock). + public Func Now { get; set; } = () => DateTimeOffset.UtcNow; + + /// The returnTo of every start that asked for a step-up and passed Heimdall's checks: its completion carries the proof. + public HashSet StepUpReturnTos { get; } = new(StringComparer.Ordinal); + + /// + /// Providers an ordinary sign-in came through that the fake account (once it holds any login) does not hold. Heimdall mints an + /// unrelated account for such a sign-in, which would then hold that login. + /// + public List OrphanMints { get; } = []; + /// When set, GET /v1/me/identities answers this status with an error body. public HttpStatusCode? ListStatus { get; set; } @@ -304,9 +316,12 @@ public sealed class FakeHeimdall : HttpMessageHandler }; } - // Heimdall's rule: the bearer must carry a step_up_at claim. (The 600 second window is Heimdall's own and not modelled.) + // Heimdall's rule: the bearer must carry a step_up_at claim from the last 600 seconds, and not from the future. var payload = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(authorization["Bearer ".Length..].Split('.')[1])); - if (!payload.Contains("\"step_up_at\"", StringComparison.Ordinal)) + using var claims = JsonDocument.Parse(payload); + var fresh = claims.RootElement.TryGetProperty("step_up_at", out var at) && at.TryGetInt64(out var seconds) + && seconds <= Now().ToUnixTimeSeconds() && Now().ToUnixTimeSeconds() - seconds <= 600; + if (!fresh) { return Json(HttpStatusCode.Forbidden, """{"error":"step_up_required"}"""); } @@ -398,6 +413,28 @@ public sealed class FakeHeimdall : HttpMessageHandler StartPaths.Add(path); StartAuthorizations.Add(authorization); var root = JsonDocument.Parse(body).RootElement; + static HttpResponseMessage Refuse(HttpStatusCode status, string error) => + new(status) { Content = new StringContent($$"""{"error":"{{error}}"}""", Encoding.UTF8, "application/json") }; + var startProvider = path.Split('/')[3]; + if (root.TryGetProperty("stepUp", out var stepUp) && stepUp.ValueKind == JsonValueKind.True) + { + // Heimdall's step-up rules (service-contract.md, "Step-up"): the app's request, sign_in only, a provider that can re-authenticate. + if (secret != RequiredAppSecret) + { + return Refuse(HttpStatusCode.Unauthorized, "app_auth_required"); + } + + if (root.TryGetProperty("mode", out var stepMode) && stepMode.GetString() != "sign_in") + { + return Refuse(HttpStatusCode.BadRequest, "step_up_only_in_sign_in"); + } + + if (startProvider is not ("google" or "twitch" or "youtube")) + { + return Refuse(HttpStatusCode.BadRequest, "step_up_not_supported"); + } + } + if (root.TryGetProperty("mode", out var mode) && mode.GetString() == "link") { if (authorization is null) @@ -416,6 +453,16 @@ public sealed class FakeHeimdall : HttpMessageHandler }; } + if (Identities.Count > 0 && Identities.All(x => (string?)x["provider"] != startProvider)) + { + OrphanMints.Add(startProvider); + } + + if (StartStatus == HttpStatusCode.OK && root.TryGetProperty("stepUp", out var asked) && asked.ValueKind == JsonValueKind.True) + { + StepUpReturnTos.Add(root.GetProperty("returnTo").GetString()!); + } + var wantsPolicy = JsonDocument.Parse(body).RootElement.TryGetProperty("entitlementPolicy", out _); if (wantsPolicy && RequiredAppSecret is not null && secret != RequiredAppSecret) { @@ -580,6 +627,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory _publicBaseUrl = publicBaseUrl; _discordRoles = discordRoles; Heimdall.Published.Add(Key); + Heimdall.Now = () => Clock.Now; Heimdall.RequiredAppSecret = AppSecret; } @@ -679,10 +727,21 @@ public sealed class HeimdallHost : TestWebApplicationFactory /// Registers a completion code as Heimdall would mint it after authenticating this attempt. public string CodeFor(StartedAttempt attempt, string accountId, string? accessToken = null, string[]? looseCapabilities = null, - string[]? looseFacts = null, string looseName = "Loose Name", string? returnTo = null, string mode = "sign_in", string provider = "discord") + string[]? looseFacts = null, string looseName = "Loose Name", string? returnTo = null, string mode = "sign_in", string provider = "discord", + bool? stepUpRequested = null) { + // A completion of a start that asked for a step-up carries the proof the way Heimdall's does: step_up_at in the token and stepUpRequested. + var asked = Heimdall.StepUpReturnTos.Contains(returnTo ?? attempt.ReturnTo); + var token = accessToken ?? (asked + ? Token(accountId, mutate: claims => claims["step_up_at"] = Clock.Now.ToUnixTimeSeconds()) + : Token(accountId)); var completion = HeimdallShapes.Completion( - returnTo ?? attempt.ReturnTo, accountId, accessToken ?? Token(accountId), looseCapabilities, looseFacts, looseName, mode, provider); + returnTo ?? attempt.ReturnTo, accountId, token, looseCapabilities, looseFacts, looseName, mode, provider); + if (stepUpRequested ?? asked) + { + completion["stepUpRequested"] = true; + } + return Heimdall.RegisterCode(completion); } @@ -718,11 +777,15 @@ public sealed class HeimdallHost : TestWebApplicationFactory } /// A browser session: a normal Heimdall sign-in, answered with the session cookie it earned. + private readonly Dictionary _sessionAccounts = new(StringComparer.Ordinal); + public async Task SignInSessionAsync(string accountId, string provider = "discord") { var attempt = await StartAsync(provider: provider); var done = await CompleteAsync(attempt, CodeFor(attempt, accountId)); - return CookieValue(done.Response, BifrostCookies.Session) ?? throw new InvalidOperationException("sign-in set no session"); + var session = CookieValue(done.Response, BifrostCookies.Session) ?? throw new InvalidOperationException("sign-in set no session"); + _sessionAccounts[session] = accountId; + return session; } /// The raw POST of the complete page, whatever fields and cookies the caller names. @@ -805,11 +868,27 @@ public sealed class HeimdallHost : TestWebApplicationFactory return AttemptFrom(await client.SendAsync(request), before); } + /// + /// A link start needs the account's current list (Bifrost will not guess a provider to prove with). Link tests that are not + /// about the list get one seeded for a session of this host: the Discord login it signed in with. false leaves it. + /// public Task StartLinkAsync( - string? session, string provider = "google", bool antiforgery = true, HttpMethod? method = null, string? extraCookies = null) => - PostLinkedAccountsAsync(session, "Link", new Dictionary { ["provider"] = provider }, antiforgery, method, extraCookies); + string? session, string provider = "google", bool antiforgery = true, HttpMethod? method = null, string? extraCookies = null, + bool seedList = true) + { + if (seedList && session is not null && _sessionAccounts.TryGetValue(session, out var account) + && Services.GetRequiredService().Get(account) is null) + { + Services.GetRequiredService().Put( + account, [new LinkedIdentitySummary(Guid.NewGuid().ToString(), "discord", "Seeded", null)]); + } - public Task StartListAsync(string? session) => PostLinkedAccountsAsync(session, "Refresh"); + return PostLinkedAccountsAsync(session, "Link", new Dictionary { ["provider"] = provider }, antiforgery, method, extraCookies); + } + + /// The list button. is the provider the member names, needed only when no current list is held. + public Task StartListAsync(string? session, string? via = "discord") => + PostLinkedAccountsAsync(session, "Refresh", via is null ? null : new Dictionary { ["via"] = via }); public Task StartUnlinkAsync(string? session, string identityId) => PostLinkedAccountsAsync(session, "Unlink", new Dictionary { ["identityId"] = identityId }); From 18f1ec6aca6243fef3b2ee590bfb0a60d55a8b2c Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 20:23:54 +0000 Subject: [PATCH 054/102] Linked accounts: drop the list after an unlink with an uncertain end; Google never mints; docs Co-Authored-By: Claude Sonnet 5.5 --- docs/linked-accounts.md | 10 +-- .../Features/Heimdall/HeimdallAuthService.cs | 17 ++--- .../Pages/Membership/LinkedAccounts.cshtml | 9 +-- .../Bifrost.Web.Tests/LinkedAccountsTests.cs | 71 ++++++++++++++++++- .../Support/HeimdallTestKit.cs | 17 ++++- 5 files changed, 102 insertions(+), 22 deletions(-) diff --git a/docs/linked-accounts.md b/docs/linked-accounts.md index 1d5636f..4ecf3e9 100644 --- a/docs/linked-accounts.md +++ b/docs/linked-accounts.md @@ -22,11 +22,11 @@ Every action is a POST with antiforgery; a GET starts nothing. The completion ro ## Which provider proves the account -An ordinary proof sign-in through a provider the account no longer holds makes Heimdall mint a new, unrelated account that holds that login, and the freed login then answers `identity_linked_elsewhere` when the member tries to link it back. So Bifrost never guesses a provider, and it does not use the "provider you signed in with" claim in the session (that records how you signed in once, not what the account holds now): +An ordinary proof sign-in through Discord or Patreon when the account no longer holds that login makes Heimdall mint a new, unrelated account that holds it (those providers are `mints_account`), and the freed login then answers `identity_linked_elsewhere` when the member tries to link it back. Google is `requires_link`: Heimdall refuses a Google sign-in for a login no account holds (`identity_not_linked`) and never mints. So Bifrost never guesses a provider, and it does not use the "provider you signed in with" claim in the session (that records how you signed in once, not what the account holds now). This guarantee covers this page's proofs only: the older anonymous `GET /auth/heimdall/{provider}` still starts an ordinary sign-in through any named provider, and can overwrite an in-flight attempt slot; that route is unchanged here. - With a current list (the five-minute snapshot), the proof goes through a provider on it: Discord, else Patreon, else Google. -- With no list (first visit, five minutes passed, a restart), the page offers only "Show my logins through Discord / Patreon / Google" and says plainly that Bifrost cannot tell what the account holds, so the member picks a login they know it holds. A named provider the account turns out not to hold still mints at Heimdall: that is the member's choice and the page says so beforehand. A refresh with no list and no named provider starts nothing (`provider_needed`). -- Link buttons appear only with a current list; a posted link with none starts nothing (`list_expired`). Unlink needs the list too. +- With no list (first visit, five minutes passed, a restart), the page offers only "Show my logins through Google (cannot create an account) / Discord / Patreon" and says plainly that Bifrost cannot tell what the account holds, so the member picks a login they know it holds. A named Discord or Patreon the account turns out not to hold still mints at Heimdall: that is the member's choice and the page says so beforehand. A refresh with no list and no named provider starts nothing (`provider_needed`). +- Link buttons appear only with a current list; a posted link with none starts nothing (`list_expired`). Unlink needs the list too. After an unlink whose result is not a definitive refusal (a 500, a lost or malformed answer, not found) the list is dropped, since Heimdall may have applied it; only `403 step_up_required`, `409 last_identity` and `401` leave it. ## The proof limit @@ -58,4 +58,6 @@ A link refused because the login already belongs to another account (`409 identi ## Known leftover -When Discord or Patreon's callback refuses a link because the login belongs to another account, the complete page's script redirects to the page with the fixed result and does not post anything. The held attempt cookie (`__Host-bifrost_heimdall_`, HttpOnly, so script cannot clear it) stays until its ten minutes end and occupies one of the two slots meanwhile; a new start reuses a free or the oldest slot, so nothing blocks. Clearing it would need a server route reached by GET or a post from the failure path, which the hardened script deliberately does not do. +Several exits leave the held attempt cookie (`__Host-bifrost_heimdall_`, HttpOnly, so script cannot clear it) in the browser until its ten minutes end: the complete page's redirect when Discord or Patreon refuses a link because the login belongs to another account, every other `heimdall_status=error` redirect, and a return with no code or no attempt. The script redirects without posting anything; clearing the cookie would need a server route reached by GET or a post from the failure path, which the hardened script deliberately does not do. + +A start takes a free slot, else overwrites the slot that expires first, live or dead (`ChooseSlot`, two slots). So three member starts within ten minutes can cost a still-live older attempt, which then finds no cookie and is refused; the member starts again. diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index e3c0f39..12e028e 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -164,7 +164,7 @@ public sealed class HeimdallAuthService( /// /// The provider an ordinary proof sign-in uses, or null when none is safe. A sign-in through a provider the account no - /// longer holds makes Heimdall mint an unrelated account that holds that login, so it is never guessed: with the account's + /// longer holds makes Heimdall mint an unrelated account that holds that login (Discord and Patreon; Google refuses instead), so it is never guessed: with the account's /// current list, the first held one of the person's choice, Discord, Patreon, Google; with no list, only a provider the /// member named (), who is the one who knows what they hold. The session's provider claim is not /// consulted: it records how they signed in once, not what the account holds now. @@ -473,14 +473,15 @@ public sealed class HeimdallAuthService( } var unlinked = await memberClient.UnlinkAsync(bearer, attempt.Target ?? string.Empty, cancellationToken); - switch (unlinked.Call) + // Only Heimdall's definitive refusals leave the account as the list showed it. Any other end (not found, a lost + // answer, a 500, a malformed 200) may have applied the unlink, so the list is dropped rather than trusted. + if (unlinked.Call == HeimdallMemberCall.Ok) { - case HeimdallMemberCall.Ok: - snapshots.Put(identity.AccountId, unlinked.Identities!); - break; - case HeimdallMemberCall.IdentityNotFound: - snapshots.Remove(identity.AccountId); - break; + snapshots.Put(identity.AccountId, unlinked.Identities!); + } + else if (unlinked.Call is not (HeimdallMemberCall.SignInRequired or HeimdallMemberCall.StepUpRequired or HeimdallMemberCall.LastIdentity)) + { + snapshots.Remove(identity.AccountId); } return new HeimdallLinkStep(null, cookieName, null, unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call)); diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml index 6a596c3..c27ccc6 100644 --- a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml @@ -32,15 +32,16 @@

Bifrost does not hold the list: it asks Heimdall, which means signing in to Heimdall again, and keeps the answer for five minutes. It also cannot tell which logins this account holds right now, so it will not choose one for you. - Pick a login you know it holds. Signing in through one it no longer holds would create a separate Heimdall account - holding that login. + Pick a login you know it holds. Signing in through Discord or Patreon when the account no longer holds that login + would create a separate Heimdall account holding it. Google cannot do that: Heimdall refuses a Google sign-in for a + login no account holds.

- @foreach (var provider in Model.LinkableProviders) + @foreach (var provider in Model.LinkableProviders.OrderBy(x => x == "google" ? 0 : 1)) {
- +
}
diff --git a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs index 098f8a6..98a8993 100644 --- a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs +++ b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs @@ -133,6 +133,71 @@ public sealed class LinkedAccountsTests Assert.DoesNotContain("Link Patreon", html); } + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.OK)] + public async Task An_unlink_whose_answer_is_lost_drops_the_list_and_a_link_then_starts_nothing(HttpStatusCode lost) + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var discord = (string)host.Heimdall.Identities[0]["id"]!; + var start = await host.StartUnlinkAsync(member.Session, discord); + host.Heimdall.UnlinkApplyThenFail = lost; + + var done = await Finish(host, member.Session, start, member.Account, provider: "google"); + var starts = host.Heimdall.StartBodies.Count; + var link = await host.StartLinkAsync(member.Session, "patreon", seedList: false); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + + // Heimdall did unlink Discord. Linking now through the stale list would sign in through Discord and mint an orphan. + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Single(host.Heimdall.Identities); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=list_expired", Location(link.Response)); + Assert.Empty(host.Heimdall.OrphanMints); + Assert.Contains("will not choose one for you", html); + } + + [Fact] + public async Task Without_a_list_the_picker_names_Google_first_as_unable_to_create_an_account() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + + var (_, html) = await host.GetLinkedAccountsAsync(session); + + Assert.Contains("Google cannot do that", html); + Assert.Contains("through Google (cannot create an account)", html); + Assert.True(html.IndexOf("Show my logins through Google", StringComparison.Ordinal) < html.IndexOf("Show my logins through Discord", StringComparison.Ordinal)); + } + + [Fact] + public void With_no_list_and_no_choice_there_is_no_proof_provider() + { + Assert.Null(HeimdallAuthService.ProofProvider(null, null)); + Assert.Null(HeimdallAuthService.ProofProvider(null, "twitch")); + Assert.Equal("patreon", HeimdallAuthService.ProofProvider(null, "Patreon")); + Assert.Null(HeimdallAuthService.ProofProvider([], "discord")); + Assert.Null(HeimdallAuthService.ProofProvider(["twitch"], null)); + Assert.Equal("patreon", HeimdallAuthService.ProofProvider(["google", "patreon"], null)); + } + + [Fact] + public async Task The_service_refuses_to_start_a_proof_with_no_list_and_no_choice() + { + using var host = new HeimdallHost(realSessions: true); + using var scope = host.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var starts = host.Heimdall.StartBodies.Count; + + await Assert.ThrowsAsync(() => service.StartListAsync( + HeimdallShapes.NewAccountId(), null, "https://localhost", [], CancellationToken.None)); + await Assert.ThrowsAsync(() => service.StartLinkAsync( + HeimdallShapes.NewAccountId(), "patreon", "https://localhost", [], CancellationToken.None)); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + [Theory] [InlineData(null)] [InlineData("twitch")] @@ -493,8 +558,8 @@ public sealed class LinkedAccountsTests Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines)); var (_, html) = await host.GetLinkedAccountsAsync(member.Session); Assert.DoesNotContain("HEIMDALL FREE TEXT", html); - // Not found says the list is stale: it is dropped. Anything else leaves what was shown. - Assert.Equal(status != HttpStatusCode.NotFound, html.Contains("Name of discord")); + // Only a definitive refusal (401 here) leaves the list shown; not found or an unreadable end may have changed the account, so it is dropped. + Assert.Equal(status == HttpStatusCode.Unauthorized, html.Contains("Name of discord")); } [Fact] @@ -508,7 +573,7 @@ public sealed class LinkedAccountsTests var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google"); Assert.Equal(Page + "?result=error", Location(done)); - Assert.Contains("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); + Assert.DoesNotContain("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); } [Fact] diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index ced87d5..7299221 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -251,8 +251,8 @@ public sealed class FakeHeimdall : HttpMessageHandler public HashSet StepUpReturnTos { get; } = new(StringComparer.Ordinal); /// - /// Providers an ordinary sign-in came through that the fake account (once it holds any login) does not hold. Heimdall mints an - /// unrelated account for such a sign-in, which would then hold that login. + /// Discord or Patreon sign-ins that came through a provider the fake account (once it holds any login) does not hold. Heimdall mints an + /// unrelated account for such a sign-in, which would then hold that login. Google never mints. /// public List OrphanMints { get; } = []; @@ -265,6 +265,9 @@ public sealed class FakeHeimdall : HttpMessageHandler /// When set, every unlink answers this status with Heimdall's error code for it (the fake's own rules are bypassed). public HttpStatusCode? UnlinkStatus { get; set; } + /// When set, an unlink is applied and then answered with this status (200 answers a body that is not JSON): a lost answer. + public HttpStatusCode? UnlinkApplyThenFail { get; set; } + /// Throws instead of answering a member route, as an unreachable Heimdall does. public Exception? MemberFailure { get; set; } @@ -304,6 +307,13 @@ public sealed class FakeHeimdall : HttpMessageHandler return Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""); } + if (UnlinkApplyThenFail is { } lost) + { + // The unlink happened; the answer did not arrive intact. + Identities.RemoveAll(x => (string?)x["id"] == id); + return Json(lost, lost == HttpStatusCode.OK ? "not json" : """{"error":"server_error"}"""); + } + if (UnlinkStatus is { } forced) { return forced switch @@ -453,7 +463,8 @@ public sealed class FakeHeimdall : HttpMessageHandler }; } - if (Identities.Count > 0 && Identities.All(x => (string?)x["provider"] != startProvider)) + // Discord and Patreon mint an account for an identity no account holds; Google is requires_link and refuses (identity_not_linked). + if (startProvider != "google" && Identities.Count > 0 && Identities.All(x => (string?)x["provider"] != startProvider)) { OrphanMints.Add(startProvider); } From 425af745000623813ff0d5b6fcc3b75329d3cd9d Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 00:37:56 +0000 Subject: [PATCH 055/102] Passkeys: WebAuthn enrolment under a fresh sign-in, 48 h cooling-off, assertion proof at unlink (#85) Delta Soul clean at 73f97ed (no blocking findings; one Low: challenge check and spend not atomic). Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- docs/jurisdiction-map.md | 2 +- docs/linked-accounts.md | 9 +- docs/passkeys.md | 133 ++ src/Bifrost.Web/Bifrost.Web.csproj | 1 + src/Bifrost.Web/Data/BifrostDbContext.cs | 11 + .../20261113120000_AddPasskeyCredentials.cs | 48 + .../BifrostDbContextModelSnapshot.cs | 64 + src/Bifrost.Web/Domain/BifrostModels.cs | 35 + .../Features/Heimdall/HeimdallAuthService.cs | 212 ++- .../Features/Passkeys/PasskeyCeremonies.cs | 139 ++ .../Features/Passkeys/PasskeyRelyingParty.cs | 54 + .../Features/Passkeys/PasskeyService.cs | 470 +++++++ .../Features/Shared/BifrostCookies.cs | 1 + .../Pages/Membership/LinkedAccounts.cshtml | 125 +- .../Pages/Membership/LinkedAccounts.cshtml.cs | 172 ++- src/Bifrost.Web/Program.cs | 19 + src/Bifrost.Web/packages.lock.json | 156 ++- src/Bifrost.Web/wwwroot/js/passkeys.js | 125 ++ .../Bifrost.Web.Tests/CookieHardeningTests.cs | 1 + .../Fixtures/passkey-script-harness.cjs | 96 ++ .../HeimdallGoogleLinkTests.cs | 2 +- .../Bifrost.Web.Tests/LinkedAccountsTests.cs | 5 +- .../MigrationDiscoveryTests.cs | 1 + tests/Bifrost.Web.Tests/PasskeyScriptTests.cs | 99 ++ tests/Bifrost.Web.Tests/PasskeyTests.cs | 1153 +++++++++++++++++ .../Support/HeimdallTestKit.cs | 118 ++ .../Support/SoftwareAuthenticator.cs | 156 +++ tests/Bifrost.Web.Tests/packages.lock.json | 154 ++- 29 files changed, 3450 insertions(+), 113 deletions(-) create mode 100644 docs/passkeys.md create mode 100644 src/Bifrost.Web/Data/Migrations/20261113120000_AddPasskeyCredentials.cs create mode 100644 src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs create mode 100644 src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs create mode 100644 src/Bifrost.Web/Features/Passkeys/PasskeyService.cs create mode 100644 src/Bifrost.Web/wwwroot/js/passkeys.js create mode 100644 tests/Bifrost.Web.Tests/Fixtures/passkey-script-harness.cjs create mode 100644 tests/Bifrost.Web.Tests/PasskeyScriptTests.cs create mode 100644 tests/Bifrost.Web.Tests/PasskeyTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/SoftwareAuthenticator.cs diff --git a/README.md b/README.md index 3cdcc75..ba62504 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core - Razor Pages app under `src/Bifrost.Web` - PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations` - Bifrost-native identity registration plus GitHub OAuth and Heimdall-linked provider identities, with invite/approval-based membership gating -- a Linked accounts page where a member sees, links and unlinks their Heimdall provider logins, with unlink gated on a fresh Google step-up proof ([docs/linked-accounts.md](docs/linked-accounts.md)) +- a Linked accounts page where a member sees, links and unlinks their Heimdall provider logins, with unlink gated on a strong proof: a WebAuthn passkey (enrolled behind a fresh sign-in, 48 hour cooling-off), or a fresh Google step-up for an account with none ([docs/linked-accounts.md](docs/linked-accounts.md), [docs/passkeys.md](docs/passkeys.md)) - explicit member roles and admin-managed patron or contributor tier snapshots - patron support event recording with derived patron point summaries and tier refresh for voting weight - shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links diff --git a/docs/jurisdiction-map.md b/docs/jurisdiction-map.md index 8163eee..ede5294 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -105,7 +105,7 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity. -A member's provider logins are Heimdall's (`linked_identities`); Bifrost shows, links and unlinks them on `/Membership/LinkedAccounts` by calling Heimdall's member routes with a bearer from a fresh Heimdall sign-in, and keeps none (`docs/linked-accounts.md`). +A member's provider logins are Heimdall's (`linked_identities`); Bifrost shows, links and unlinks them on `/Membership/LinkedAccounts` by calling Heimdall's member routes with a bearer from a fresh Heimdall sign-in, and keeps none (`docs/linked-accounts.md`). Strong proof for an unlink is a Bifrost-verified passkey (WebAuthn, RP ID the Bifrost host) that Bifrost asserts to Heimdall through its app-asserted step-up route; Bifrost owns the credential store and the ceremonies, Heimdall owns the unlink gate (`docs/passkeys.md`). Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed. diff --git a/docs/linked-accounts.md b/docs/linked-accounts.md index 4ecf3e9..0da0b95 100644 --- a/docs/linked-accounts.md +++ b/docs/linked-accounts.md @@ -9,6 +9,8 @@ | Show my logins | A fresh ordinary Heimdall sign-in as the session's account, through the proof provider (below); inside its completion, the list | `GET /v1/me/identities` with that sign-in's bearer | | Link Discord, Patreon or Google | The same fresh sign-in as proof, then the link start for the chosen provider with its bearer. Needs a current list. Discord and Patreon attach in Heimdall's callback and return a link completion; Google writes a pending link, and a second fresh sign-in confirms it | `POST /v1/oauth/{provider}/start` `mode: "link"`, then for Google `POST /v1/apps/bifrost/links/confirm` | | Unlink | A sign-in through Google with `stepUp: true` (app secret); inside its completion, the unlink | `POST /v1/me/identities/{id}/unlink` with that sign-in's bearer | +| Unlink with a passkey (an account with a strong passkey) | A WebAuthn assertion verified at Bifrost, then a fresh ordinary sign-in as the account; inside its completion, Heimdall's app-asserted step-up, then the unlink with the token it returns | `POST /v1/apps/bifrost/step-up` `{method: "bifrost-passkey", nonce}`, then `POST /v1/me/identities/{id}/unlink` | +| Add or revoke a passkey | Enrolment needs a fresh Heimdall sign-in first and cools off 48 hours; revoking needs no proof | none | The linkable providers are the ones Heimdall's `bifrost` profile admits (`identityProviders` in Heimdall `src/app-profiles.ts`): Discord, Patreon, Google. Twitch is offered for sign-in elsewhere in Bifrost but the profile refuses it, so it is not offered here. @@ -30,9 +32,11 @@ An ordinary proof sign-in through Discord or Patreon when the account no longer ## The proof limit +Passkeys (`docs/passkeys.md`) are the strong proof: an account with a passkey past its cooling-off unlinks with it, and the Google step-up below is then closed (`passkey_required`). The rest of this section is the Google proof, which stays for an account with no strong passkey. + Unlinking needs fresh proof: Heimdall refuses `403 step_up_required` unless the access token carries `step_up_at` from a step-up sign-in within 600 seconds (`docs/service-contract.md`, `POST /v1/me/identities/{identityId}/unlink`, in the Heimdall repository). Only Google can give that to a `bifrost` sign-in: Discord and Patreon have no re-authentication parameter (an open provider session completes without credentials), and Twitch and YouTube, which do, are not in the `bifrost` profile. So: -- An account with no Google login linked cannot unlink anything from here. The page says so and shows no Unlink button; a posted unlink starts nothing. +- An account with no Google login linked and no strong passkey cannot unlink anything from here. The page says what it can do (add a passkey; the time a cooling-off one becomes strong) and shows no Unlink button; a posted unlink starts nothing. - Heimdall answers `503 provider_not_configured` for Google until a Google OAuth client is configured on the Heimdall host (follow-up `heimdall-body:follow_up:google-console-client-registration`; not set live as of 2026-11-13). The step-up start is then refused and the page says fresh proof could not be started. Nothing loops: the refusal sets no attempt cookie. - Linking Google needs the same client. Until then the Google link button ends in "That could not be completed. Nothing was changed." - A Google step-up proof is as strong as Google's `prompt=login`; Heimdall says "asked", not "verified" (`stepUpRequested`). @@ -53,8 +57,9 @@ A link refused because the login already belongs to another account (`409 identi - `Pages/Membership/LinkedAccounts.cshtml[.cs]`: the page and its three POST handlers. - `Features/Heimdall/HeimdallAuthService.cs`: the attempt machinery (`StartListAsync`, `StartLinkAsync`, `StartUnlinkAsync`, `ContinueLinkAsync`), shared with sign-in. - `Features/Heimdall/HeimdallMemberClient.cs`: the two member calls and their outcome mapping. +- `Features/Passkeys/`, `wwwroot/js/passkeys.js`: the passkey ceremonies (`docs/passkeys.md`). - `Features/Heimdall/LinkedAccountsSnapshots.cs`: the five-minute list. -- Tests: `tests/Bifrost.Web.Tests/LinkedAccountsTests.cs`, `HeimdallGoogleLinkTests.cs`, with the fake Heimdall in `Support/HeimdallTestKit.cs`. +- Tests: `tests/Bifrost.Web.Tests/LinkedAccountsTests.cs`, `HeimdallGoogleLinkTests.cs`, `PasskeyTests.cs`, with the fake Heimdall in `Support/HeimdallTestKit.cs`. ## Known leftover diff --git a/docs/passkeys.md b/docs/passkeys.md new file mode 100644 index 0000000..6a04d07 --- /dev/null +++ b/docs/passkeys.md @@ -0,0 +1,133 @@ +# Passkeys: strong proof for unlinking a login + +A Heimdall-backed member can enrol WebAuthn passkeys on `/Membership/LinkedAccounts` and use one as the strong proof that unlinking a provider login needs. Bifrost verifies the passkey; Heimdall accepts Bifrost's word through its app-asserted step-up route (`POST /v1/apps/bifrost/step-up`, Heimdall `docs/service-contract.md`, `docs/app-profiles/bifrost.md`). Scoping and the option comparison are in the handoff `strong-proof-scoping-20261113.md` (option 1a with the S1 sink). + +## Scope + +- Accounts that have a `HeimdallAccountId`: the accounts that can unlink at all. A Bifrost-native-only account (a native `BifrostIdentity` or GitHub sign-in) has no logins to unlink and gets no passkeys from this cut; the page shows it nothing. +- Unlink only. A passkey is not a sign-in method here. + +## The library: Fido2NetLib (`Fido2` 4.3.3) + +Decision: use `Fido2` 4.3.3 (NuGet id `Fido2`, repository `passwordless-lib/fido2-net-lib`) for every byte of WebAuthn parsing and verification. Bifrost hand-rolls no CBOR, COSE or signature code. + +What was checked on 2026-11-13, against the package and the repository, not from memory: + +| Question | Finding | +|---|---| +| Licence | MIT, in the nuspec (`MIT`) and in the repository's `LICENSE.txt` (GitHub reports `MIT`). Its closure: `Fido2.Models` MIT; `NSec.Cryptography` 25.4.0 MIT; `libsodium` 1.0.20.1 ISC; `System.Formats.Cbor` 9.0.24, `Microsoft.IdentityModel.JsonWebTokens` 8.2.0, `Microsoft.Extensions.Http` 9.0.0, `Microsoft.Bcl.Memory` 9.0.14 all Microsoft MIT. All open source. | +| Maintained | Not archived; repository pushed 2026-11-11; 4.3.3 released 2026-11-11 and 4.3.2 on 2026-10-29; about 1,460 stars. | +| net8.0 | The 4.3.3 nuspec has a `net8.0` dependency group (and `net10.0`); it targets the framework this project targets. | +| Packed attestation | `Src/Fido2/AttestationFormat/` holds `Packed`, `None`, `FidoU2f`, `Tpm`, `AndroidKey`, `AndroidSafetyNet`, `Apple`, `AppleAppAttest`. Bifrost asks for `none` attestation and does not use a metadata service, so it does not depend on any of them being trusted; a client that sends another format still has it parsed and its statement verified structurally by the library. | +| What verification covers | Read in `AuthenticatorAssertionResponse.cs`: challenge, origin (against `Fido2Configuration.Origins`), `webauthn.get` type, RP ID hash, user-present and user-verified flags (when required), signature over `authData || SHA-256(clientDataJSON)` with the stored COSE key, user handle ownership, and the sign count (see "Sign count" below). Enrolment verifies the attestation, the credential id and the key. | +| Transitive weight | Heavier than the repository's other packages: `Microsoft.Extensions.Http` 9.0.0 pulls `Microsoft.Extensions.*` 9.0.0, so existing 8.x entries move to 9.0.0 (seven in `src/Bifrost.Web/packages.lock.json`; twelve `Microsoft.Extensions.*` entries plus `System.Diagnostics.DiagnosticSource` in the test project's lockfile, which sees them too; app-local on top of the net8 shared framework, a supported combination), and the lockfiles gain `Fido2`, `Fido2.Models`, `Microsoft.Bcl.Memory`, `Microsoft.Extensions.Http`, `Microsoft.Extensions.Diagnostics[.Abstractions]`, `Microsoft.Extensions.Configuration[.Binder]`, `System.Diagnostics.DiagnosticSource`, `Microsoft.Extensions.Options.ConfigurationExtensions`, `NSec.Cryptography`, `System.Formats.Cbor` and the native `libsodium` package (the Linux x64 glibc and musl binaries ship in the package's `runtimes/`). `Microsoft.IdentityModel.JsonWebTokens` is requested at 8.2.0 and already resolved to 8.19.2 (through OpenIddict) before this change; it still does. `packages.lock.json` in both projects is regenerated and committed. | +| Alternatives | Not needed: the candidate passed. | + +Not verified: that the 9.0.0 `Microsoft.Extensions.*` assemblies behave on the live host's ASP.NET Core 8 runtime beyond the test host (the suite runs the real app under it); that `libsodium` loads on Yggdrasil (the EdDSA test loads it here, on Debian x64). + +The library is used through four calls: `RequestNewCredential`, `MakeNewCredentialAsync`, `GetAssertionOptions`, `MakeAssertionAsync`. `PasskeyRelyingParty` builds it from `Host:PublicBaseUrl`. + +## Sign count + +The stored sign count is updated on every successful assertion, and the library refuses a counter that does not increase once either side is non-zero. Authenticators that always report zero (most synced passkeys) are accepted every time and get no clone detection from this: both counters stay zero. An authenticator that reported a non-zero count and then reports zero is refused. Clone detection is therefore real only for hardware keys and other counting authenticators. + +## Relying party + +RP ID is the host of `Host:PublicBaseUrl`, the one origin is its scheme, host and port. They come from configuration and from nothing the browser sends. Passkeys are unavailable, with a warning logged once at start, when `Host:PublicBaseUrl` is not https or its host is not `Host:ExpectedHost`: the page offers none and every ceremony endpoint refuses (`passkeys_unavailable`). `PasskeyRelyingParty` is resolved at startup, so a misconfiguration is logged once at boot (not at the first ceremony). `Host:ExpectedHost` must be a bare host name: one with a port (`host:443`) or an IP address (`[::1]`, `127.0.0.1`) makes passkeys unavailable and is reported, because an IP address is not a valid RP ID and a port is not part of it. No new configuration key exists. + +If passkeys are unavailable while an account holds a strong passkey, the page shows no unlink form and says so: unlinking needs the passkey, the Google route stays closed (`passkey_required`), and the member can revoke their passkeys on the page (revoking does not need the relying party) to fall back to the Google proof, or wait for the operator to fix the server. This is a lockout until the operator or the member acts; it fails closed. + +## State + +- `PasskeyCredentials` (migration `20261113120000_AddPasskeyCredentials`, additive, applied at boot like the others): per `UserAccount` id, `CredentialId` (unique), `PublicKey`, `SignCount`, `Transports`, `AaGuid`, `Label`, `CreatedUtc`, `StrongFromUtc` (`CreatedUtc` + 48 h), `RevokedUtc`, `LastUsedUtc`. No secret exists to store: the private key never leaves the authenticator. Rows cascade with the account. +- `PasskeyCeremonies` (in memory, this process only, like the Linked accounts list): the short-lived single-use records of the flows, each bound to a Heimdall account id and a kind (and to an identity id for unlink). A restart empties it and every flow is begun again; with more than one Bifrost instance a flow must finish on the instance that began it, otherwise it is refused and begun again. Both fail closed (a lost ticket, challenge or fact refuses; it never grants). It is chosen over a sealed cookie because a cookie cannot be spent: a copied cookie would replay. + +| Kind | Lifetime | Spent | +|---|---|---| +| enrolment ticket | 5 min | by the registration post | +| enrolment challenge | 2 min | by the registration post | +| unlink challenge | 2 min | by the assertion post (success or not) | +| unlink assertion fact | 5 min | by the completion that asks Heimdall for the step-up | + +A challenge kind has one live record per account: a newer one replaces the older. The table holds at most 2048 records and refuses past that. + +A caller's own preconditions are checked before anything is spent. A registration needs the live ticket cookie (a session thief has none), a decodable body and room under the cap; an assertion needs a decodable body naming one of the session account's own strong, un-revoked credentials. Only then are the verification permit, the challenge and (for enrolment) the ticket taken. An assertion also needs a live challenge for that account and identity before a permit is spent. So junk posted by someone who holds only the session, naming no credential of the account or arriving with no live challenge, spends nothing. Junk naming one of the account's own credentials (the id is in `allowCredentials`) spends one verification permit per challenge that was asked for, because the first such post takes the challenge; it cannot drain the 20 permits per 10 minutes in one go. It can still burn that challenge once, and a session holder who keeps asking for challenges (an options permit each) and posting junk can exhaust both budgets: a lockout the same session already has through revoking passkeys, not a bypass. A junk post naming no credential of the account costs two cheap database lookups and no permit; that is accepted rather than spending an options-style permit on it. Both budgets are per process: with more than one instance each has its own. Asking for options spends a separate options budget (20 per 10 minutes), which a session holder can exhaust: that blocks new options for a while and nothing else. A session holder can also replace the member's pending challenge by asking for new options, which makes the member's assertion answer `passkey_expired` and the member tries again; that session can already revoke passkeys, so it is not a new capability. The member whose challenge was burned retries with a new one. + +## Enrolment + +1. The member is on the page with a current list (Bifrost will not guess a proof provider) and presses "Sign in again to add a passkey". Bifrost starts a fresh ordinary Heimdall sign-in through a provider the account holds (an attempt with purpose `EnrolProof`, the same machinery as the list and link proofs, `ExpectedAccountId` the session's account). +2. In that sign-in's completion, when the proven account is the session's own, Bifrost issues an enrolment ticket and sets its id in `__Host-bifrost_passkey_ticket` (HttpOnly, Secure, 5 minutes). The ticket exists only because a fresh sign-in completed in this browser: a stolen session cookie alone has no ticket and cannot get registration options. +3. The page's script asks for creation options (antiforgery header), calls `navigator.credentials.create` (user verification required, resident key preferred, `none` attestation, existing credentials excluded) and posts the base64url result. Bifrost spends the ticket and the challenge, verifies, and stores the credential with `StrongFromUtc` = now + 48 hours. + +A label is limited to 40 text elements (cut by rune and grapheme, never inside a surrogate pair) and loses control and format characters (bidi overrides, joiners, zero-width marks). + +Cap: five un-revoked passkeys per account (`passkey_limit`; a sixth starts no sign-in). Every options request, enrolment, assertion and enrolment start spends a permit of one `FixedWindowBudget` per account (20 per 10 minutes, `rate_limited`). + +## Cooling-off + +A passkey is strong from `StrongFromUtc` (at that instant and after), until revoked. Until then it is listed on the page with its time and cannot unlink anything. So someone who controls both a member's session and a provider login long enough to add a key of their own still waits 48 hours with the key visible, and the member can revoke it from the page. Revoking needs no strong proof (it only removes capability) and is shown. + +## Unlink with a passkey + +When the account has a strong passkey, the page's Unlink button runs a WebAuthn assertion instead of the Google step-up, and the Google route (`Unlink` handler) answers `passkey_required` and starts nothing: a member who holds a strong passkey is not offered the weaker proof. With no strong passkey the Google step-up stays as it was, and when neither exists the page says what to do (add a passkey, with the time a cooling-off one becomes strong). + +1. The script asks for request options for the chosen identity (`allowCredentials` is the account's strong passkeys; user verification required). One live challenge per account, bound to the identity. +2. The browser's assertion is posted. Bifrost spends the challenge, requires the credential to be the session account's own, not revoked and past `StrongFromUtc`, and has the library verify origin, RP ID, challenge, user verification, signature and sign count. On success it records the new sign count and last use, and issues the single-use fact "passkey assertion verified" for this account and this identity (5 minutes; no credential, no bearer). +3. Bifrost starts a fresh ordinary Heimdall sign-in for the account (attempt purpose `UnlinkPasskeyProof`; proof provider as on the page: one the current list shows, never a guess). The attempt carries the fact's id. +4. In the completion, which is the only place the bearer exists: the fact is taken (missing, expired, spent, for another account or identity: no Heimdall step-up call and `passkey_expired`); the credential the fact was made with must still be the account's own, un-revoked and strong (a passkey revoked since the assertion: no step-up call, `passkey_failed`); the app secret, the bearer and `{method: "bifrost-passkey", nonce}` go to `POST /v1/apps/bifrost/step-up` (nonce: 32 fresh random bytes, base64url, 43 characters); the returned `accessToken` goes to the existing unlink call; everything is dropped. The bearer and the minted token are never stored, logged, put in a cookie or a redirect, or rendered. + +Heimdall's refusals are shown as closed codes and nothing is retried in the flow: `401 sign_in_required` is `sign_in_required`; `403`/`400`/`401 app_auth_required` is `step_up_unavailable`; `409 step_up_nonce_replayed` is `step_up_replayed`; `429` is `step_up_rate_limited`; anything else, or Heimdall down, is `error`. A member whose attempt ends this way starts the unlink again (a new assertion). + +## Abuse cases and what stops them + +Each is a test in `tests/Bifrost.Web.Tests/PasskeyTests.cs` unless noted. + +| Case | Defence | +|---|---| +| Stolen session cookie tries to enrol | No ticket without a fresh Heimdall sign-in completed in the victim's own browser. A ticket is per account, spent once, expires in 5 minutes, and is held by id in an HttpOnly cookie | +| Stolen session tries to unlink | Needs an assertion by a strong passkey of the account; the Google route is closed while one exists; no step-up is asked of Heimdall without the fact | +| Attacker who controls the provider session enrols their own key | Cooling-off: 48 hours before it counts, shown on the page, revocable by the member | +| Replay of an assertion, double submit | The challenge is single-use; two concurrent posts start one sign-in | +| Assertion for another account or identity | The credential must belong to the session's account; the challenge, the fact and the attempt are bound to the account and the chosen identity | +| Phishing site | The library checks origin and RP ID hash against the configured values; a registration or assertion with another origin or RP ID is refused | +| Cloned authenticator | A sign count that does not increase is refused; the stored count is updated on every use | +| Cooling-off boundary | Strong at exactly `StrongFromUtc`, not one second before | +| Revoked credential | Never strong; a revoke between challenge and assertion also refuses, and so does one between the assertion and the completion | +| Junk from a session thief | Preconditions first, nothing spent before they hold (see State) | +| No user verification | Required at enrolment and at assertion | +| Cross-site POST | Antiforgery on every page POST including the JSON option requests | +| Bearer anywhere | Only in the completion request's stack; tests look in logs (with HttpClient trace logging on), cookies, redirects, the page and the decrypted attempt | +| Reused or replayed step-up | A fresh nonce each call; Heimdall's 409 is shown, not retried | +| Brute force of ceremonies | One `FixedWindowBudget` per account | + +## Account-scoped lookup and the allow list + +An assertion is accepted only for a credential of the session's own account (strong, un-revoked), and the user handle, when the authenticator sends one, must be that account's id. The request options also carry the account's strong credentials as `allowCredentials`, which the library enforces, so a credential of another account is refused twice. The test for the account-scoped lookup (`The_credential_lookup_is_scoped_to_the_session_account_even_when_the_allow_list_is_empty`) takes the allow list out of the other account's challenge through the in-process ceremony store, which production code cannot do, so the lookup is exercised alone without weakening the server. + +## Content Security Policy + +Bifrost sends no `Content-Security-Policy` header (nothing in the application sets one; this predates passkeys). The passkey script is first-party and external so that a policy of `script-src 'self'` would admit it, but nothing enforces that today. + +## The script + +`wwwroot/js/passkeys.js` is first-party, loaded by the page with an external `src` (nothing inline, no third-party asset, no `eval`, no `innerHTML`). It asks for options with the antiforgery header, calls `navigator.credentials`, and submits a same-origin form of base64url strings (`id`, `rawId`, `attestationObject` or `authenticatorData`, `clientDataJson`, `signature`, `userHandle`, `transports`). On any failure it navigates to this page with a result code from a closed set; nothing from a response is shown. `PasskeyScriptTests` runs it under Node against a simulated browser and pins what it posts. + +## What it does not do + +- No passkey sign-in; passkeys prove an already signed-in session only. +- No native-only accounts (no Heimdall account, nothing to unlink). +- No recovery without mail: a member who loses every passkey revokes them and falls back to the Google step-up if a Google login is linked, or waits for the mail gate (`docs/registration-gates.md`) and the email-code factor that follows it. The email code is not built. +- Named limit and next hardening: enrolling a second passkey should require an assertion by an existing strong passkey, and enrolment should use a Google step-up when a Google login is held. Today enrolment needs only the fresh ordinary sign-in and the cooling-off, so an attacker who controls a provider session can add a key that becomes strong after 48 hours unless the member sees and revokes it. Not built; a follow-up is filed. +- A revoke between the completion's check and its unlink does not stop that unlink: the window is the few milliseconds of the two Heimdall calls and is inherent to checking before acting. +- Revoking needs no strong proof, so a stolen session can revoke a member's passkeys and fall back to the Google proof: no weaker than the account was before it had a passkey. +- A passkey synced through a cloud account the attacker also controls is a residual risk, as in the scoping note. +- Heimdall cannot tell that a passkey was checked: `bifrost-passkey` is Bifrost's word, gated by the app secret and a live bearer (Heimdall `docs/app-profiles/bifrost.md`). +- Not verified here: a real authenticator or browser. Tests use a software authenticator built from the WebAuthn specification and the script runs under Node against a simulated browser. + +## Where the code is + +- `Features/Passkeys/PasskeyService.cs`: enrol, assert, revoke, list; `PasskeyCeremonies.cs`: the single-use records and the rate limit; `PasskeyRelyingParty.cs`: RP ID, origin, library construction. +- `Features/Heimdall/HeimdallAuthService.cs`: the `EnrolProof` and `UnlinkPasskeyProof` attempts, `StepUpAsync`. +- `Pages/Membership/LinkedAccounts.cshtml[.cs]`, `wwwroot/js/passkeys.js`. +- `Domain/BifrostModels.cs` (`PasskeyCredential`), `Data/Migrations/20261113120000_AddPasskeyCredentials.cs`. +- Tests: `PasskeyTests.cs`, `PasskeyScriptTests.cs`, `Support/SoftwareAuthenticator.cs`, and the step-up route of the fake Heimdall in `Support/HeimdallTestKit.cs`. diff --git a/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj index 177c194..8e26e7b 100644 --- a/src/Bifrost.Web/Bifrost.Web.csproj +++ b/src/Bifrost.Web/Bifrost.Web.csproj @@ -13,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index d227c42..f710aa8 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -45,6 +45,7 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet ConsentReceipts => Set(); public DbSet MindKeys => Set(); + public DbSet PasskeyCredentials => Set(); public DbSet MindNonces => Set(); @@ -333,6 +334,16 @@ public sealed class BifrostDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(x => x.CredentialId).IsUnique(); + entity.HasIndex(x => x.UserAccountId); + entity.HasOne(x => x.UserAccount) + .WithMany() + .HasForeignKey(x => x.UserAccountId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => { entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique(); diff --git a/src/Bifrost.Web/Data/Migrations/20261113120000_AddPasskeyCredentials.cs b/src/Bifrost.Web/Data/Migrations/20261113120000_AddPasskeyCredentials.cs new file mode 100644 index 0000000..fd1c2e0 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261113120000_AddPasskeyCredentials.cs @@ -0,0 +1,48 @@ +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + // Hand-written like AddConsentReceipts: no dotnet-ef on the authoring host; the snapshot was edited to match. Additive only. + [DbContext(typeof(BifrostDbContext))] + [Migration("20261113120000_AddPasskeyCredentials")] + public partial class AddPasskeyCredentials : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasskeyCredentials", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + CredentialId = table.Column(type: "bytea", nullable: false), + PublicKey = table.Column(type: "bytea", nullable: false), + SignCount = table.Column(type: "bigint", nullable: false), + Transports = table.Column(type: "text", nullable: false), + AaGuid = table.Column(type: "uuid", nullable: true), + Label = table.Column(type: "text", nullable: false), + CreatedUtc = table.Column(type: "timestamp with time zone", nullable: false), + StrongFromUtc = table.Column(type: "timestamp with time zone", nullable: false), + RevokedUtc = table.Column(type: "timestamp with time zone", nullable: true), + LastUsedUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PasskeyCredentials", x => x.Id); + table.ForeignKey("FK_PasskeyCredentials_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateIndex(name: "IX_PasskeyCredentials_CredentialId", table: "PasskeyCredentials", column: "CredentialId", unique: true); + migrationBuilder.CreateIndex(name: "IX_PasskeyCredentials_UserAccountId", table: "PasskeyCredentials", column: "UserAccountId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "PasskeyCredentials"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 042e2f0..ab4ef16 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -1470,6 +1470,59 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("ConsentReceipts"); }); + modelBuilder.Entity("Bifrost.Web.Domain.PasskeyCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUsedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("RevokedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.Property("StrongFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Transports") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId") + .IsUnique(); + + b.HasIndex("UserAccountId"); + + b.ToTable("PasskeyCredentials"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b => { b.Property("Id") @@ -2183,6 +2236,17 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("UserAccount"); }); + modelBuilder.Entity("Bifrost.Web.Domain.PasskeyCredential", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => { b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 00e3f9c..7449a6b 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -1177,6 +1177,41 @@ public sealed class ConsentReceipt public UserAccount? UserAccount { get; set; } } +/// +/// A WebAuthn passkey a Heimdall-backed member enrolled (docs/passkeys.md). Holds only what verifying an assertion needs: +/// the credential id, the public key and the sign count. No secret exists to hold. It counts as strong proof only from +/// (the cooling-off after enrolment) and only while not revoked. +/// +public sealed class PasskeyCredential +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid UserAccountId { get; set; } + + public byte[] CredentialId { get; set; } = []; + + public byte[] PublicKey { get; set; } = []; + + public long SignCount { get; set; } + + // Comma-separated WebAuthn transport hints the authenticator reported at enrolment; a hint for the browser, never trusted. + public string Transports { get; set; } = string.Empty; + + public Guid? AaGuid { get; set; } + + public string Label { get; set; } = string.Empty; + + public DateTimeOffset CreatedUtc { get; set; } + + public DateTimeOffset StrongFromUtc { get; set; } + + public DateTimeOffset? RevokedUtc { get; set; } + + public DateTimeOffset? LastUsedUtc { get; set; } + + public UserAccount? UserAccount { get; set; } +} + /// A Mind's registered Ed25519 public key. Bifrost never holds a private key. public sealed class MindKey { diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 12e028e..4741fe1 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -8,6 +8,7 @@ using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Passkeys; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.WebUtilities; @@ -33,7 +34,9 @@ public sealed class HeimdallAuthService( IDataProtectionProvider dataProtectionProvider, TimeProvider timeProvider, HeimdallMemberClient memberClient, - LinkedAccountsSnapshots snapshots) + LinkedAccountsSnapshots snapshots, + PasskeyCeremonies ceremonies, + PasskeyService passkeys) { public const string ErrorRedirect = "/?auth=heimdall-error"; public const string AppSecretHeader = "x-heimdall-app-secret"; @@ -89,7 +92,8 @@ public sealed class HeimdallAuthService( string? pendingLinkId = null, string? replaceSlot = null, bool stepUp = false, - string? target = null) + string? target = null, + string? proof = null) { // The public sign-in routes offer AllowedProviders; the proofs of the Linked accounts page may also use Google // (a step-up proof only Google can give a bifrost sign-in, an ordinary one for an account that holds Google). @@ -144,7 +148,8 @@ public sealed class HeimdallAuthService( purpose, expectedAccountId, pendingLinkId, - target); + target, + proof); return new HeimdallAuthStartResult( new Uri(payload.AuthorizationUrl, UriKind.Absolute), replaceSlot ?? ChooseSlot(requestCookies), Protect(attempt)); } @@ -197,7 +202,8 @@ public sealed class HeimdallAuthService( IEnumerable> requestCookies, bool stepUp, string? target, - CancellationToken cancellationToken) => + CancellationToken cancellationToken, + string? proof = null) => StartAsync( provider, callbackBaseUrl, @@ -208,7 +214,8 @@ public sealed class HeimdallAuthService( purpose, sessionAccountId, stepUp: stepUp, - target: target); + target: target, + proof: proof); /// /// Reads the account's logins: an ordinary sign-in, then GET /v1/me/identities inside its completion. Through a provider on @@ -250,6 +257,29 @@ public sealed class HeimdallAuthService( StartProofAsync( HeimdallAttemptPurpose.UnlinkProof, StepUpProvider, sessionAccountId, callbackBaseUrl, requestCookies, true, identityId, cancellationToken); + /// + /// The fresh sign-in that earns an enrolment ticket (docs/passkeys.md): an ordinary sign-in through a provider the account + /// holds, proving the account to this browser's session. Its completion issues the ticket; no passkey exists before it. + /// + public Task StartEnrolProofAsync( + string sessionAccountId, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) => + StartProofAsync( + HeimdallAttemptPurpose.EnrolProof, ProofProviderFor(sessionAccountId, null), + sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken); + + /// + /// Unlinks one login on a verified passkey assertion: an ordinary fresh sign-in as the account (never a guessed provider), and + /// inside its completion, with that bearer, Heimdall's app-asserted step-up and then the unlink. is the + /// id of the single-use assertion fact; the attempt carries it and nothing else about the passkey. + /// + public Task StartUnlinkPasskeyAsync( + string sessionAccountId, string identityId, string factId, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) => + StartProofAsync( + HeimdallAttemptPurpose.UnlinkPasskeyProof, ProofProviderFor(sessionAccountId, null), + sessionAccountId, callbackBaseUrl, requestCookies, false, identityId, cancellationToken, factId); + /// /// The providers Heimdall's bifrost profile admits (src/app-profiles.ts, identityProviders): a link or a proof can use them. /// Twitch is offered for sign-in here but the profile refuses it, so it is not linkable. @@ -472,19 +502,44 @@ public sealed class HeimdallAuthService( return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.StepUpRequired); } - var unlinked = await memberClient.UnlinkAsync(bearer, attempt.Target ?? string.Empty, cancellationToken); - // Only Heimdall's definitive refusals leave the account as the list showed it. Any other end (not found, a lost - // answer, a 500, a malformed 200) may have applied the unlink, so the list is dropped rather than trusted. - if (unlinked.Call == HeimdallMemberCall.Ok) + return new HeimdallLinkStep(null, cookieName, null, await UnlinkAsync(identity.AccountId, bearer, attempt.Target, cancellationToken)); + } + + case HeimdallAttemptPurpose.EnrolProof: + { + // A fresh ordinary sign-in as the session's own account: the one thing a stolen session cookie cannot produce. + // It earns a short-lived single-use ticket, handed to this browser in a cookie; no passkey exists yet. + var (identity, _) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + var ticket = ceremonies.Issue(CeremonyKind.EnrolTicket, identity.AccountId, null, null, PasskeyCeremonies.TicketLifetime); + return new HeimdallLinkStep( + null, cookieName, null, ticket is null ? HeimdallLinkOutcome.Error : HeimdallLinkOutcome.EnrolReady, ticket); + } + + case HeimdallAttemptPurpose.UnlinkPasskeyProof: + { + // The assertion fact is spent before anything else: missing, expired, spent, or for another account or identity + // means no step-up is asked of Heimdall and nothing is unlinked. + var fact = ceremonies.Take(CeremonyKind.UnlinkProof, attempt.ExpectedAccountId, attempt.Target, attempt.Proof); + var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + if (fact is null) { - snapshots.Put(identity.AccountId, unlinked.Identities!); - } - else if (unlinked.Call is not (HeimdallMemberCall.SignInRequired or HeimdallMemberCall.StepUpRequired or HeimdallMemberCall.LastIdentity)) - { - snapshots.Remove(identity.AccountId); + return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.PasskeyExpired); } - return new HeimdallLinkStep(null, cookieName, null, unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call)); + // A passkey revoked since the assertion no longer proves anything: Heimdall is not asked. + if (!await passkeys.StillStrongAsync(identity.AccountId, fact.Data, cancellationToken)) + { + return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.PasskeyFailed); + } + + var asserted = await StepUpAsync(bearer, cancellationToken); + if (asserted.Refusal is { } refusal) + { + return new HeimdallLinkStep(null, cookieName, null, refusal); + } + + // The returned token is this request's alone: it goes to the one unlink and is dropped. + return new HeimdallLinkStep(null, cookieName, null, await UnlinkAsync(identity.AccountId, asserted.AccessToken!, attempt.Target, cancellationToken)); } default: @@ -492,6 +547,69 @@ public sealed class HeimdallAuthService( } } + /// + /// Heimdall's unlink with a bearer that carries fresh proof, and what the list becomes. Only Heimdall's definitive refusals leave + /// the account as the list showed it. Any other end (not found, a lost answer, a 500, a malformed 200) may have applied the + /// unlink, so the list is dropped rather than trusted. + /// + private async Task UnlinkAsync(string accountId, string bearer, string? identityId, CancellationToken cancellationToken) + { + var unlinked = await memberClient.UnlinkAsync(bearer, identityId ?? string.Empty, cancellationToken); + if (unlinked.Call == HeimdallMemberCall.Ok) + { + snapshots.Put(accountId, unlinked.Identities!); + } + else if (unlinked.Call is not (HeimdallMemberCall.SignInRequired or HeimdallMemberCall.StepUpRequired or HeimdallMemberCall.LastIdentity)) + { + snapshots.Remove(accountId); + } + + return unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call); + } + + /// The method label Heimdall's bifrost profile admits for an app-asserted passkey proof. + public const string PasskeyStepUpMethod = "bifrost-passkey"; + + /// + /// POST /v1/apps/{app}/step-up (Heimdall docs/service-contract.md): the app secret, the member's live bearer and a fresh random + /// nonce; Heimdall answers a token for the same session that carries the proof. Called only from the completion that holds the + /// bearer, after a verified passkey assertion fact was spent. Each refusal is a closed outcome; nothing is retried here. + /// + private async Task<(HeimdallLinkOutcome? Refusal, string? AccessToken)> StepUpAsync(string bearer, CancellationToken cancellationToken) + { + var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); + try + { + using var message = new HttpRequestMessage( + HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/step-up") + { + Content = JsonContent.Create(new HeimdallStepUpRequest(PasskeyStepUpMethod, nonce)) + }; + message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer); + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (response.StatusCode == HttpStatusCode.Created) + { + var minted = await response.Content.ReadFromJsonAsync(cancellationToken); + return string.IsNullOrWhiteSpace(minted?.AccessToken) ? (HeimdallLinkOutcome.Error, null) : (null, minted.AccessToken); + } + + var error = await HeimdallRefusal.CodeAsync(response, cancellationToken); + return ((int)response.StatusCode, error) switch + { + (401, "sign_in_required") => (HeimdallLinkOutcome.SignInRequired, null), + (429, _) => (HeimdallLinkOutcome.StepUpRateLimited, null), + (409, "step_up_nonce_replayed") => (HeimdallLinkOutcome.StepUpReplayed, null), + (401 or 403 or 400, _) => (HeimdallLinkOutcome.StepUpUnavailable, null), + _ => (HeimdallLinkOutcome.Error, null) + }; + } + catch (Exception exception) when (exception is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException + && !cancellationToken.IsCancellationRequested) + { + return (HeimdallLinkOutcome.Error, null); + } + } + private static HeimdallLinkOutcome OutcomeOf(HeimdallMemberCall call) => call switch { HeimdallMemberCall.SignInRequired => HeimdallLinkOutcome.SignInRequired, @@ -623,7 +741,7 @@ public sealed class HeimdallAuthService( private string Protect(HeimdallAttempt attempt) => _protector.Protect(JsonSerializer.Serialize(new AttemptWire( attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt, - attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId, attempt.Target))); + attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId, attempt.Target, attempt.Proof))); /// The live attempt a cookie value protects when it belongs to this nonce, else null. private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) => @@ -647,7 +765,7 @@ public sealed class HeimdallAuthService( return new HeimdallAttempt( wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt, - wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId, wire.Target); + wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId, wire.Target, wire.Proof); } catch (Exception error) when (error is CryptographicException or JsonException or FormatException) { @@ -664,7 +782,8 @@ public sealed class HeimdallAuthService( HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, string? ExpectedAccountId = null, string? PendingLinkId = null, - string? Target = null); + string? Target = null, + string? Proof = null); private async Task UpsertUserAccountAsync( HeimdallVerifiedIdentity identity, @@ -820,7 +939,8 @@ public sealed record HeimdallAttempt( HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, string? ExpectedAccountId = null, string? PendingLinkId = null, - string? Target = null); + string? Target = null, + string? Proof = null); /// /// What a held attempt is for. SignIn is the default, so a cookie written before purposes existed reads as a sign-in. @@ -835,7 +955,9 @@ public enum HeimdallAttemptPurpose LinkConfirm = 3, ListProof = 4, UnlinkProof = 5, - LinkAwaitDirect = 6 + LinkAwaitDirect = 6, + EnrolProof = 7, + UnlinkPasskeyProof = 8 } /// How a Linked accounts action ended. is its closed wire form. @@ -853,7 +975,20 @@ public enum HeimdallLinkOutcome SignInRequired, ProofUnavailable, ListExpired, - ProviderNeeded + ProviderNeeded, + EnrolReady, + EnrolNeedsSignIn, + PasskeyAdded, + PasskeyRevoked, + PasskeyLimit, + PasskeyExpired, + PasskeyFailed, + PasskeyRequired, + PasskeysUnavailable, + RateLimited, + StepUpUnavailable, + StepUpReplayed, + StepUpRateLimited } /// @@ -881,6 +1016,19 @@ public static class HeimdallLinkOutcomes HeimdallLinkOutcome.ProofUnavailable => "proof_unavailable", HeimdallLinkOutcome.ListExpired => "list_expired", HeimdallLinkOutcome.ProviderNeeded => "provider_needed", + HeimdallLinkOutcome.EnrolReady => "enrol_ready", + HeimdallLinkOutcome.EnrolNeedsSignIn => "enrol_needs_sign_in", + HeimdallLinkOutcome.PasskeyAdded => "passkey_added", + HeimdallLinkOutcome.PasskeyRevoked => "passkey_revoked", + HeimdallLinkOutcome.PasskeyLimit => "passkey_limit", + HeimdallLinkOutcome.PasskeyExpired => "passkey_expired", + HeimdallLinkOutcome.PasskeyFailed => "passkey_failed", + HeimdallLinkOutcome.PasskeyRequired => "passkey_required", + HeimdallLinkOutcome.PasskeysUnavailable => "passkeys_unavailable", + HeimdallLinkOutcome.RateLimited => "rate_limited", + HeimdallLinkOutcome.StepUpUnavailable => "step_up_unavailable", + HeimdallLinkOutcome.StepUpReplayed => "step_up_replayed", + HeimdallLinkOutcome.StepUpRateLimited => "step_up_rate_limited", _ => "error" }; @@ -898,17 +1046,37 @@ public static class HeimdallLinkOutcomes "sign_in_required" => "Heimdall no longer accepts that sign-in. Nothing was changed; try again.", "proof_unavailable" => "Fresh proof could not be started, so nothing was changed. Unlinking needs a fresh Google sign-in, and it is not available right now.", "provider_needed" => "Bifrost does not know which logins this account holds right now, so it will not guess. Pick one you know it holds to read the list. Nothing was changed.", + "enrol_ready" => "You signed in again. Add the passkey below within five minutes.", + "enrol_needs_sign_in" => "Adding a passkey needs a fresh sign-in first, and none is current. Start again with \"Sign in again to add a passkey\". Nothing was added.", + "passkey_added" => "The passkey was added. It counts as strong proof after a 48 hour cooling-off; until then it cannot unlink anything.", + "passkey_revoked" => "The passkey was revoked.", + "passkey_limit" => "This account already has the most passkeys it can hold. Revoke one to add another.", + "passkey_expired" => "The passkey check expired or was already used. Nothing was changed; start the unlink again.", + "passkey_failed" => "The passkey could not be verified. Nothing was changed.", + "passkey_required" => "This account has a passkey, so unlinking needs it. Nothing was changed.", + "passkeys_unavailable" => "Passkeys are not available on this server right now. Nothing was changed.", + "rate_limited" => "Too many passkey attempts. Nothing was changed; wait a few minutes.", + "step_up_unavailable" => "Your passkey was verified, but Heimdall would not take Bifrost's proof. Nothing was changed.", + "step_up_replayed" => "Heimdall had already seen that proof. Nothing was changed; start the unlink again.", + "step_up_rate_limited" => "Heimdall is limiting proofs for now. Nothing was changed; wait a few minutes and start the unlink again.", "list_expired" => "The list was out of date. Nothing was changed; read your linked logins again.", "error" => "That could not be completed. Nothing was changed.", _ => null }; } -public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome); +public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome, string? EnrolTicket = null); public sealed record HeimdallConfirmLinkRequest( [property: JsonPropertyName("pendingLinkId")] string PendingLinkId); +public sealed record HeimdallStepUpRequest( + [property: JsonPropertyName("method")] string Method, + [property: JsonPropertyName("nonce")] string Nonce); + +public sealed record HeimdallStepUpResponse( + [property: JsonPropertyName("accessToken")] string? AccessToken); + public sealed record HeimdallConfirmLinkResponse( [property: JsonPropertyName("status")] string? Status); diff --git a/src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs b/src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs new file mode 100644 index 0000000..ddbb36f --- /dev/null +++ b/src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs @@ -0,0 +1,139 @@ +using Bifrost.Web.Features.Shared; + +namespace Bifrost.Web.Features.Passkeys; + +public enum CeremonyKind +{ + /// Permission to enrol one passkey, earned by a fresh Heimdall sign-in. Held by id in a cookie of the browser that signed in. + EnrolTicket, + /// The WebAuthn creation options in flight for an account. + EnrolChallenge, + /// The WebAuthn request options in flight for an account's unlink of one identity. + UnlinkChallenge, + /// The fact "this account's passkey assertion was verified for unlinking this identity". Carries no bearer and no credential. + UnlinkProof +} + +/// +/// The short-lived single-use server-side state of the passkey flows, in this process only (like the Linked accounts list): +/// a restart empties it and every flow is simply begun again. Every entry is bound to an account and a kind, and the unlink +/// kinds to the identity they are for; Take removes the entry whether or not the caller then succeeds, so a +/// challenge, a ticket or an assertion fact can be spent once. A challenge kind has one live entry per account: a newer one +/// replaces the older. A full table refuses new entries rather than grow. +/// +public sealed class PasskeyCeremonies(TimeProvider clock) +{ + public static readonly TimeSpan TicketLifetime = TimeSpan.FromMinutes(5); + public static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(2); + public static readonly TimeSpan ProofLifetime = TimeSpan.FromMinutes(5); + public const int MaxLive = 2048; + + public sealed record Entry(string Id, CeremonyKind Kind, string Account, string? Subject, string? Data, DateTimeOffset ExpiresAt); + + private readonly Dictionary _held = new(StringComparer.Ordinal); + private readonly object _gate = new(); + + /// Adds an entry and returns its id, or null when the table is full. + public string? Issue(CeremonyKind kind, string account, string? subject, string? data, TimeSpan lifetime) + { + lock (_gate) + { + Purge(); + if (kind != CeremonyKind.UnlinkProof) + { + foreach (var old in _held.Values.Where(x => x.Kind == kind && x.Account == account).ToList()) + { + _held.Remove(old.Id); + } + } + + if (_held.Count >= MaxLive) + { + return null; + } + + var id = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) + .Replace('+', '-').Replace('/', '_').TrimEnd('='); + _held[id] = new Entry(id, kind, account, subject, data, clock.GetUtcNow() + lifetime); + return id; + } + } + + /// The live entry, without spending it. null finds the account's one current entry of that kind. + public Entry? Peek(CeremonyKind kind, string account, string? subject, string? id) + { + lock (_gate) + { + Purge(); + return Find(kind, account, subject, id); + } + } + + /// The live entry, removed: it can be taken once. + public Entry? Take(CeremonyKind kind, string account, string? subject, string? id) + { + lock (_gate) + { + Purge(); + var entry = Find(kind, account, subject, id); + if (entry is not null) + { + _held.Remove(entry.Id); + } + + return entry; + } + } + + public void Discard(string? id) + { + if (id is not null) + { + lock (_gate) + { + _held.Remove(id); + } + } + } + + private Entry? Find(CeremonyKind kind, string account, string? subject, string? id) + { + var entry = id is null + ? _held.Values.FirstOrDefault(x => x.Kind == kind && x.Account == account) + : _held.GetValueOrDefault(id); + return entry is not null && entry.Kind == kind + && string.Equals(entry.Account, account, StringComparison.Ordinal) + && string.Equals(entry.Subject, subject, StringComparison.Ordinal) + ? entry + : null; + } + + private void Purge() + { + var now = clock.GetUtcNow(); + foreach (var dead in _held.Values.Where(x => x.ExpiresAt <= now).ToList()) + { + _held.Remove(dead.Id); + } + } +} + +/// +/// The budgets on the passkey ceremonies, per Heimdall account, on the shared . Two separate +/// ones: asking for options or starting an enrolment sign-in (anyone holding the session can do that), and verifying a +/// registration or an assertion, which is spent only after the caller holds what the verification needs (a live ticket, or one of +/// the account's own strong credentials and a live challenge). Junk from a session thief that names no credential of the account, or +/// arrives with no live challenge, spends nothing; junk naming one of the account's credentials spends one permit per challenge that +/// was asked for, because the first such post takes the challenge. A session holder can still exhaust both budgets by asking for +/// challenges over and over: a lockout the same session already has through revoking passkeys, not a bypass. Budgets are per process. +/// +public sealed class PasskeyRateLimit(TimeProvider clock) +{ + public const int Permits = 20; + public static readonly TimeSpan Window = TimeSpan.FromMinutes(10); + + private readonly FixedWindowBudget _options = new(Permits, Window, 10_000, clock); + private readonly FixedWindowBudget _verifications = new(Permits, Window, 10_000, clock); + + public bool TryAcquire(string account, bool verification = false) => (verification ? _verifications : _options).Spend(account) is null; +} diff --git a/src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs b/src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs new file mode 100644 index 0000000..38aae13 --- /dev/null +++ b/src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs @@ -0,0 +1,54 @@ +using Bifrost.Web.Configuration; +using Fido2NetLib; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Passkeys; + +/// +/// The relying party every passkey ceremony runs as. RP ID and origin come from and from +/// nothing the browser sends: the public base URL must be https and its host must be the expected host, otherwise passkeys +/// are unavailable (the page offers none and every ceremony endpoint refuses). A passkey is bound to the RP ID by the +/// authenticator, so a wrong or http origin would enrol keys that cannot be used or can be phished. +/// +public sealed class PasskeyRelyingParty +{ + public const string Name = "Bifrost"; + + public PasskeyRelyingParty(IOptions hostOptions, ILogger logger) + { + var host = hostOptions.Value; + if (host.ExpectedHost.Contains(':') || System.Net.IPAddress.TryParse(host.ExpectedHost.Trim('[', ']'), out _)) + { + logger.LogWarning("Passkeys are unavailable: Host:ExpectedHost must be a bare DNS host name, with no port and not an IP address (an IP address is not a valid RP ID)."); + return; + } + + if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var url) + || url.Scheme != Uri.UriSchemeHttps + || string.IsNullOrWhiteSpace(host.ExpectedHost) + || !string.Equals(url.Host, host.ExpectedHost, StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning("Passkeys are unavailable: Host:PublicBaseUrl must be https and its host must equal Host:ExpectedHost."); + return; + } + + Origin = url.GetLeftPart(UriPartial.Authority); + RpId = url.Host.ToLowerInvariant(); + Fido2 = new Fido2(new Fido2Configuration + { + RPID = RpId, + RPName = Name, + Origins = new HashSet([Origin], StringComparer.Ordinal), + ChallengeSize = 32, + Timeout = 120_000 + }); + } + + public bool IsAvailable => Fido2 is not null; + + public string? Origin { get; } + + public string? RpId { get; } + + public IFido2? Fido2 { get; } +} diff --git a/src/Bifrost.Web/Features/Passkeys/PasskeyService.cs b/src/Bifrost.Web/Features/Passkeys/PasskeyService.cs new file mode 100644 index 0000000..367d13c --- /dev/null +++ b/src/Bifrost.Web/Features/Passkeys/PasskeyService.cs @@ -0,0 +1,470 @@ +using System.Globalization; +using System.Text; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Fido2NetLib; +using Fido2NetLib.Objects; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.Passkeys; + +/// One passkey as the Linked accounts page shows it: a label, its cooling-off state, never a key. +public sealed record PasskeyView(Guid Id, string Label, DateTimeOffset CreatedUtc, DateTimeOffset StrongFromUtc, DateTimeOffset? LastUsedUtc, bool IsStrong); + +/// The base64url fields a browser posts after navigator.credentials.get (an assertion) or .create (an enrolment). +public sealed record PasskeyFields( + string? Id, string? RawId, string? AuthenticatorData, string? ClientDataJson, string? Signature, string? UserHandle, + string? AttestationObject, string? Transports); + +/// +/// Passkeys for accounts that have a Heimdall account id (docs/passkeys.md). Enrolment needs a ticket earned by a fresh +/// Heimdall sign-in and starts a credential in a 48 hour cooling-off; an assertion for an unlink needs a credential that is +/// the session's own, not revoked and past its cooling-off, and records a single-use fact for that account and identity. +/// The WebAuthn parsing and verification (CBOR, COSE, signatures, origin, RP ID, challenge, user verification, sign count) +/// is Fido2NetLib's; this class decides who may begin, what is bound to what, and what is kept. Outcomes are the closed set +/// of the page words itself. +/// +public sealed class PasskeyService( + BifrostDbContext db, + PasskeyRelyingParty relyingParty, + PasskeyCeremonies ceremonies, + PasskeyRateLimit rateLimit, + TimeProvider clock, + ILogger logger) +{ + public const int MaxActive = 5; + public const int MaxLabel = 40; + public static readonly TimeSpan CoolingOff = TimeSpan.FromHours(48); + + private static readonly HashSet TransportNames = new(StringComparer.Ordinal) + { + "usb", "nfc", "ble", "internal", "hybrid", "smart-card" + }; + + public bool IsAvailable => relyingParty.IsAvailable; + + private Task AccountAsync(string heimdallAccountId, CancellationToken ct) => + string.IsNullOrEmpty(heimdallAccountId) + ? Task.FromResult(null) + : db.UserAccounts.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId, ct); + + private IQueryable Active(Guid userId) => + db.PasskeyCredentials.Where(x => x.UserAccountId == userId && x.RevokedUtc == null); + + public async Task> ListAsync(string heimdallAccountId, CancellationToken ct) + { + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return []; + } + + var now = clock.GetUtcNow(); + var rows = await Active(user.Id).ToListAsync(ct); + return rows.OrderBy(x => x.CreatedUtc) + .Select(x => new PasskeyView(x.Id, x.Label, x.CreatedUtc, x.StrongFromUtc, x.LastUsedUtc, x.StrongFromUtc <= now)) + .ToList(); + } + + /// Whether a fresh enrolment ticket is held by this browser for this account. + public bool HasTicket(string heimdallAccountId, string? ticketId) => + ticketId is not null && ceremonies.Peek(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId) is not null; + + /// Why a fresh sign-in for enrolment may not even start, or null when it may. Spends a ceremony permit. + public async Task PreflightEnrolStartAsync(string heimdallAccountId, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return HeimdallLinkOutcome.PasskeysUnavailable; + } + + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return HeimdallLinkOutcome.Error; + } + + if (await Active(user.Id).CountAsync(ct) >= MaxActive) + { + return HeimdallLinkOutcome.PasskeyLimit; + } + + return rateLimit.TryAcquire(heimdallAccountId) ? null : HeimdallLinkOutcome.RateLimited; + } + + /// The creation options for one enrolment, bound to the account; needs the ticket the fresh sign-in earned. + public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginEnrolAsync(string heimdallAccountId, string? ticketId, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return (HeimdallLinkOutcome.PasskeysUnavailable, null); + } + + if (!HasTicket(heimdallAccountId, ticketId) || await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return (HeimdallLinkOutcome.EnrolNeedsSignIn, null); + } + + if (!rateLimit.TryAcquire(heimdallAccountId)) + { + return (HeimdallLinkOutcome.RateLimited, null); + } + + var existing = await Active(user.Id).Select(x => x.CredentialId).ToListAsync(ct); + if (existing.Count >= MaxActive) + { + return (HeimdallLinkOutcome.PasskeyLimit, null); + } + + var options = relyingParty.Fido2!.RequestNewCredential(new RequestNewCredentialParams + { + User = new Fido2User { Id = user.Id.ToByteArray(), Name = Label(user.DisplayName, "Bifrost member"), DisplayName = Label(user.DisplayName, "Bifrost member") }, + ExcludeCredentials = existing.Select(x => new PublicKeyCredentialDescriptor(x)).ToList(), + AuthenticatorSelection = new AuthenticatorSelection + { + ResidentKey = ResidentKeyRequirement.Preferred, + UserVerification = UserVerificationRequirement.Required + }, + AttestationPreference = AttestationConveyancePreference.None + }); + var json = options.ToJson(); + return ceremonies.Issue(CeremonyKind.EnrolChallenge, heimdallAccountId, null, json, PasskeyCeremonies.ChallengeLifetime) is null + ? (HeimdallLinkOutcome.Error, null) + : (null, json); + } + + /// + /// Verifies a registration and stores the credential, cooling off for . The ticket and the + /// challenge are both spent here, whatever the outcome: a failed attempt needs a fresh sign-in and a new challenge. + /// + public async Task EnrolAsync( + string heimdallAccountId, string? ticketId, PasskeyFields fields, string? label, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return HeimdallLinkOutcome.PasskeysUnavailable; + } + + // The caller's own preconditions first, spending nothing: someone with only the session has no ticket, so junk from them + // can neither burn the member's ticket and challenge nor spend the verification permits. + if (!HasTicket(heimdallAccountId, ticketId) || await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return HeimdallLinkOutcome.EnrolNeedsSignIn; + } + + if (await Active(user.Id).CountAsync(ct) >= MaxActive) + { + return HeimdallLinkOutcome.PasskeyLimit; + } + + if (!TryDecode(fields.RawId, out var rawId) || !TryDecode(fields.AttestationObject, out var attestation) + || !TryDecode(fields.ClientDataJson, out var clientData) || fields.Id is null) + { + return HeimdallLinkOutcome.PasskeyFailed; + } + + if (!rateLimit.TryAcquire(heimdallAccountId, verification: true)) + { + return HeimdallLinkOutcome.RateLimited; + } + + // Now both are spent, whatever the outcome. + var ticket = ceremonies.Take(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId); + var challenge = ceremonies.Take(CeremonyKind.EnrolChallenge, heimdallAccountId, null, null); + if (ticket is null) + { + return HeimdallLinkOutcome.EnrolNeedsSignIn; + } + + if (challenge?.Data is null) + { + return HeimdallLinkOutcome.PasskeyExpired; + } + + try + { + var registered = await relyingParty.Fido2!.MakeNewCredentialAsync(new MakeNewCredentialParams + { + AttestationResponse = new AuthenticatorAttestationRawResponse + { + Id = fields.Id, + RawId = rawId, + Type = PublicKeyCredentialType.PublicKey, + ClientExtensionResults = new AuthenticationExtensionsClientOutputs(), + Response = new AuthenticatorAttestationRawResponse.AttestationResponse + { + AttestationObject = attestation, + ClientDataJson = clientData, + Transports = Transports(fields.Transports) + } + }, + OriginalOptions = CredentialCreateOptions.FromJson(challenge.Data), + IsCredentialIdUniqueToUserCallback = async (parameters, token) => + !await db.PasskeyCredentials.AnyAsync(x => x.CredentialId == parameters.CredentialId, token) + }, ct); + + var now = clock.GetUtcNow(); + db.PasskeyCredentials.Add(new PasskeyCredential + { + UserAccountId = user.Id, + CredentialId = registered.Id, + PublicKey = registered.PublicKey, + SignCount = registered.SignCount, + Transports = string.Join(',', Transports(fields.Transports).Select(x => TransportName(x))), + AaGuid = registered.AaGuid == Guid.Empty ? null : registered.AaGuid, + Label = Label(label, "Passkey"), + CreatedUtc = now, + StrongFromUtc = now + CoolingOff + }); + await db.SaveChangesAsync(ct); + return HeimdallLinkOutcome.PasskeyAdded; + } + catch (Exception error) when (error is Fido2VerificationException or DbUpdateException or FormatException or System.Text.Json.JsonException) + { + logger.LogWarning("Passkey enrolment refused: {Reason}", error is Fido2VerificationException f ? f.Code.ToString() : error.GetType().Name); + return HeimdallLinkOutcome.PasskeyFailed; + } + } + + /// Revokes one of the session's own passkeys. Needs no strong proof; the page shows the revocation. + public async Task RevokeAsync(string heimdallAccountId, Guid credentialId, CancellationToken ct) + { + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return HeimdallLinkOutcome.Error; + } + + var credential = await Active(user.Id).SingleOrDefaultAsync(x => x.Id == credentialId, ct); + if (credential is null) + { + return HeimdallLinkOutcome.PasskeyFailed; + } + + credential.RevokedUtc = clock.GetUtcNow(); + await db.SaveChangesAsync(ct); + return HeimdallLinkOutcome.PasskeyRevoked; + } + + private IQueryable Strong(Guid userId) + { + var now = clock.GetUtcNow(); + return Active(userId).Where(x => x.StrongFromUtc <= now); + } + + public async Task HasStrongAsync(string heimdallAccountId, CancellationToken ct) => + await AccountAsync(heimdallAccountId, ct) is { } user && await Strong(user.Id).AnyAsync(ct); + + /// When the first cooling-off credential becomes strong, or null when none is cooling. + public async Task NextStrongAtAsync(string heimdallAccountId, CancellationToken ct) + { + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return null; + } + + var now = clock.GetUtcNow(); + var cooling = await Active(user.Id).Where(x => x.StrongFromUtc > now).Select(x => x.StrongFromUtc).ToListAsync(ct); + return cooling.Count == 0 ? null : cooling.Min(); + } + + /// The request options for unlinking one identity: only the account's strong passkeys may answer, with user verification. + public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginAssertionAsync(string heimdallAccountId, string identityId, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return (HeimdallLinkOutcome.PasskeysUnavailable, null); + } + + if (!rateLimit.TryAcquire(heimdallAccountId)) + { + return (HeimdallLinkOutcome.RateLimited, null); + } + + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return (HeimdallLinkOutcome.Error, null); + } + + var strong = await Strong(user.Id).ToListAsync(ct); + if (strong.Count == 0) + { + return (HeimdallLinkOutcome.PasskeyRequired, null); + } + + var options = relyingParty.Fido2!.GetAssertionOptions(new GetAssertionOptionsParams + { + AllowedCredentials = strong.Select(x => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, x.CredentialId, Transports(x.Transports))).ToList(), + UserVerification = UserVerificationRequirement.Required + }); + var json = options.ToJson(); + return ceremonies.Issue(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, json, PasskeyCeremonies.ChallengeLifetime) is null + ? (HeimdallLinkOutcome.Error, null) + : (null, json); + } + + /// + /// Verifies an assertion made for unlinking . The challenge is spent first. On success the + /// credential's sign count and last use are recorded and a single-use fact for this account and identity is issued; its id + /// (not a credential, not a bearer) is what the fresh Heimdall sign-in's attempt carries. + /// + public async Task<(HeimdallLinkOutcome? Refusal, string? FactId)> VerifyAssertionAsync( + string heimdallAccountId, string identityId, PasskeyFields fields, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return (HeimdallLinkOutcome.PasskeysUnavailable, null); + } + + // The caller's own preconditions first, spending nothing: a decodable assertion naming one of this account's strong, + // un-revoked credentials. Junk from someone holding only the session fails here and burns neither the member's challenge + // nor the verification permits. + if (await AccountAsync(heimdallAccountId, ct) is not { } user + || !TryDecode(fields.RawId, out var rawId) || !TryDecode(fields.AuthenticatorData, out var authenticatorData) + || !TryDecode(fields.ClientDataJson, out var clientData) || !TryDecode(fields.Signature, out var signature) + || fields.Id is null) + { + return (HeimdallLinkOutcome.PasskeyFailed, null); + } + + byte[]? userHandle = null; + if (!string.IsNullOrEmpty(fields.UserHandle) && !TryDecode(fields.UserHandle, out userHandle)) + { + return (HeimdallLinkOutcome.PasskeyFailed, null); + } + + // The credential must be this session's own, not revoked and past its cooling-off. Anything else is not a strong proof. + var credential = await Strong(user.Id).SingleOrDefaultAsync(x => x.CredentialId == rawId, ct); + if (credential is null) + { + return (HeimdallLinkOutcome.PasskeyFailed, null); + } + + // Without a live challenge for this account and identity nothing can verify, so no permit is spent either: junk naming the + // account's own credential spends at most one permit per challenge that was asked for (the first junk then takes it). + if (ceremonies.Peek(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, null) is null) + { + return (HeimdallLinkOutcome.PasskeyExpired, null); + } + + if (!rateLimit.TryAcquire(heimdallAccountId, verification: true)) + { + return (HeimdallLinkOutcome.RateLimited, null); + } + + // Spent from here on, whatever the outcome. + var challenge = ceremonies.Take(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, null); + if (challenge?.Data is null) + { + return (HeimdallLinkOutcome.PasskeyExpired, null); + } + + try + { + var result = await relyingParty.Fido2!.MakeAssertionAsync(new MakeAssertionParams + { + AssertionResponse = new AuthenticatorAssertionRawResponse + { + Id = fields.Id, + RawId = rawId, + Type = PublicKeyCredentialType.PublicKey, + ClientExtensionResults = new AuthenticationExtensionsClientOutputs(), + Response = new AuthenticatorAssertionRawResponse.AssertionResponse + { + AuthenticatorData = authenticatorData, + ClientDataJson = clientData, + Signature = signature, + UserHandle = userHandle + } + }, + OriginalOptions = AssertionOptions.FromJson(challenge.Data), + StoredPublicKey = credential.PublicKey, + StoredSignatureCounter = (uint)credential.SignCount, + IsUserHandleOwnerOfCredentialIdCallback = (parameters, _) => + Task.FromResult(parameters.UserHandle.AsSpan().SequenceEqual(user.Id.ToByteArray()) && parameters.CredentialId.AsSpan().SequenceEqual(credential.CredentialId)) + }, ct); + + credential.SignCount = result.SignCount; + credential.LastUsedUtc = clock.GetUtcNow(); + await db.SaveChangesAsync(ct); + } + catch (Exception error) when (error is Fido2VerificationException or FormatException or System.Text.Json.JsonException) + { + logger.LogWarning("Passkey assertion refused: {Reason}", error is Fido2VerificationException f ? f.Code.ToString() : error.GetType().Name); + return (HeimdallLinkOutcome.PasskeyFailed, null); + } + + var fact = ceremonies.Issue(CeremonyKind.UnlinkProof, heimdallAccountId, identityId, credential.Id.ToString(), PasskeyCeremonies.ProofLifetime); + return fact is null ? (HeimdallLinkOutcome.Error, null) : (null, fact); + } + + public void DiscardFact(string? factId) => ceremonies.Discard(factId); + + /// + /// Whether the credential an assertion fact was made with is still this account's own, un-revoked and strong. Asked in the + /// completion, before Heimdall is: a passkey revoked between the assertion and the completion no longer proves anything. + /// + public async Task StillStrongAsync(string heimdallAccountId, string? credentialId, CancellationToken ct) => + Guid.TryParse(credentialId, out var id) && await AccountAsync(heimdallAccountId, ct) is { } user + && await Strong(user.Id).AnyAsync(x => x.Id == id, ct); + + /// A label from a form field: trimmed, control characters dropped, bounded; the fallback when nothing is left. + private static string Label(string? value, string fallback) + { + // By rune and by text element, never by UTF-16 unit: a cut inside a surrogate pair is not valid text and Postgres refuses it. + // Control and format characters (bidi overrides, joiners, zero-width marks) are dropped; a lone surrogate becomes U+FFFD. + var runes = (value ?? string.Empty).EnumerateRunes() + .Where(r => Rune.GetUnicodeCategory(r) is not (UnicodeCategory.Control or UnicodeCategory.Format)) + .Take(MaxLabel * 4); + var cleaned = string.Concat(runes.Select(r => r.ToString())).Trim(); + var elements = new StringInfo(cleaned); + if (elements.LengthInTextElements > MaxLabel) + { + cleaned = elements.SubstringByTextElements(0, MaxLabel); + } + + return cleaned.Length == 0 ? fallback : cleaned; + } + + private static bool TryDecode(string? value, out byte[] bytes) + { + bytes = []; + if (string.IsNullOrEmpty(value) || value.Length > 16_384) + { + return false; + } + + try + { + bytes = WebEncoders.Base64UrlDecode(value); + return bytes.Length > 0; + } + catch (FormatException) + { + return false; + } + } + + private static AuthenticatorTransport[] Transports(string? csv) + { + var parsed = new List(); + foreach (var name in (csv ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct()) + { + try + { + // Only names of the closed set reach the converter; one it does not know is dropped. + if (TransportNames.Contains(name)) + { + parsed.Add(System.Text.Json.JsonSerializer.Deserialize($"\"{name}\"")); + } + } + catch (System.Text.Json.JsonException) + { + } + } + + return parsed.ToArray(); + } + + private static string TransportName(AuthenticatorTransport transport) => + System.Text.Json.JsonSerializer.Serialize(transport).Trim('"'); +} diff --git a/src/Bifrost.Web/Features/Shared/BifrostCookies.cs b/src/Bifrost.Web/Features/Shared/BifrostCookies.cs index 1c099e8..0a99cf0 100644 --- a/src/Bifrost.Web/Features/Shared/BifrostCookies.cs +++ b/src/Bifrost.Web/Features/Shared/BifrostCookies.cs @@ -12,6 +12,7 @@ public static class BifrostCookies public const string Antiforgery = HostPrefix + "bifrost_antiforgery"; public const string Voter = HostPrefix + "bifrost_voter"; public const string HeimdallAttemptPrefix = HostPrefix + "bifrost_heimdall_"; + public const string PasskeyTicket = HostPrefix + "bifrost_passkey_ticket"; public const string Path = "/"; } diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml index c27ccc6..12790f3 100644 --- a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml @@ -61,7 +61,23 @@ { linked @linkedAt.UtcDateTime.ToString("dd MMM yyyy") UTC } - @if (Model.CanProve) + @if (Model.HasStrongPasskey) + { + @if (Model.PasskeysAvailable) + { +
+ + + + + + + + +
+ } + } + else if (Model.HasGoogle) {
@@ -76,17 +92,31 @@

This is your only login. Heimdall will not remove the last one.

} - @if (!Model.CanProve) + @if (Model.HasStrongPasskey && !Model.PasskeysAvailable) { -

- Unlinking needs a fresh Google sign-in as proof. Discord and Patreon cannot give that proof, and no Google login - is linked to this account, so nothing can be unlinked from here yet. Linking Google first makes it possible - once Heimdall has Google set up. +

+ This account has a passkey, so unlinking needs it, but passkeys are not available on this server right now (a + configuration fault the operator must fix), so nothing can be unlinked from here at the moment. Nothing is lost: + try again later. If you cannot wait, revoke your passkeys below; unlinking then falls back to a fresh Google sign-in, + if a Google login is linked to this account.

} + else if (Model.HasStrongPasskey) + { +

Unlinking asks for one of your passkeys, then signs you in to Heimdall once more and removes only the login you picked.

+ } + else if (Model.HasGoogle) + { +

Unlinking asks you to sign in with Google again as proof, then removes only the login you picked. A passkey, once it has cooled off, is stronger proof and takes over from Google.

+ } else { -

Unlinking asks you to sign in with Google again as proof, then removes only the login you picked.

+

+ Unlinking needs strong proof, and none is available. Add a passkey below@(Model.NextStrongAt is { } waiting + ? $" (your newest one counts from {waiting.UtcDateTime:dd MMM yyyy HH:mm} UTC)" + : " (it counts after a 48 hour cooling-off)"), + or link a Google login once Heimdall has Google set up. Discord and Patreon cannot give that proof. +

} @@ -94,6 +124,80 @@
} +

Passkeys

+ @if (!Model.PasskeysAvailable) + { +

Passkeys are not available on this server right now, so none can be added or used. You can still revoke the ones listed.

+ } + @if (Model.PasskeysAvailable) + { +

+ A passkey is the strong proof for unlinking a login: it stays on your device, and a site that is not Bifrost cannot use it. + A new passkey is added only right after you sign in to Heimdall again, and it counts as strong proof 48 hours later, so + someone who borrows your open browser cannot add one and use it at once. You can revoke a passkey here at any time. +

+ } + @if (Model.Passkeys.Count > 0) + { +
    + @foreach (var key in Model.Passkeys) + { +
  • + @key.Label + @if (key.IsStrong) + { + active + } + else + { + cooling off until @key.StrongFromUtc.UtcDateTime.ToString("dd MMM yyyy HH:mm") UTC; cannot unlink yet + } + added @key.CreatedUtc.UtcDateTime.ToString("dd MMM yyyy") UTC + @if (key.LastUsedUtc is { } used) + { + last used @used.UtcDateTime.ToString("dd MMM yyyy") UTC + } +
    + + +
    +
  • + } +
+ } + + @if (Model.PasskeysAvailable) + { + if (Model.PasskeyLimitReached) + { +

This is the most passkeys an account can hold. Revoke one to add another.

+ } + else if (Model.CanEnrol) + { +
+ + + + + + + +
+ + } + else if (Model.Identities is not null) + { +
+ +
+ } + else + { +

Read your linked logins first; adding a passkey then asks you to sign in to Heimdall once more.

+ } + } +

+ @if (Model.Identities is not null) {

Link another login

@@ -117,3 +221,10 @@ Back to membership status
+ +@section Scripts { + @if (Model.HasHeimdallAccount && Model.PasskeysAvailable) + { + + } +} diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs index 27b6d6d..e70d4e1 100644 --- a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs @@ -2,6 +2,8 @@ using System.Security.Claims; using Bifrost.Web.Configuration; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Passkeys; +using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -20,6 +22,7 @@ namespace Bifrost.Web.Pages.Membership; public sealed class LinkedAccountsModel( HeimdallAuthService heimdallAuthService, LinkedAccountsSnapshots snapshots, + PasskeyService passkeys, IOptions hostOptions, ILogger logger) : PageModel { @@ -41,17 +44,41 @@ public sealed class LinkedAccountsModel( /// The logins the last read found, or null when there is none or it has expired. public IReadOnlyList? Identities { get; private set; } - /// Whether a fresh Google sign-in can be had for this account: unlinking needs one, and only Google can give it. - public bool CanProve => Identities?.Any(x => x.Provider == HeimdallAuthService.StepUpProvider) == true; + /// Whether a fresh Google sign-in can be had for this account: without a strong passkey, unlinking needs one, and only Google can give it. + public bool HasGoogle => Identities?.Any(x => x.Provider == HeimdallAuthService.StepUpProvider) == true; + + /// The account's passkeys that are not revoked, with their cooling-off state. + public IReadOnlyList Passkeys { get; private set; } = []; + + /// Whether a passkey past its cooling-off exists: unlinking then needs it and no longer accepts Google as the proof. + public bool HasStrongPasskey => Passkeys.Any(x => x.IsStrong); + + /// When the first passkey still cooling off becomes strong. + public DateTimeOffset? NextStrongAt => Passkeys.Where(x => !x.IsStrong).Select(x => (DateTimeOffset?)x.StrongFromUtc).Min(); + + /// Whether this browser holds a live ticket from a fresh sign-in, so a passkey can be added now. + public bool CanEnrol { get; private set; } + + /// Whether this server can run passkey ceremonies at all (https and the host it expects). + public bool PasskeysAvailable => passkeys.IsAvailable; + + public bool PasskeyLimitReached => Passkeys.Count >= PasskeyService.MaxActive; + + public bool CanUnlink => HasStrongPasskey || HasGoogle; public IReadOnlyList LinkableProviders => HeimdallAuthService.LinkableProviders; public static string ProviderName(string provider) => ProviderNames.GetValueOrDefault(provider, provider); - public void OnGet() + public async Task OnGetAsync(CancellationToken cancellationToken) { ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString()); Identities = AccountId is { } id ? snapshots.Get(id) : null; + if (AccountId is { } account) + { + Passkeys = await passkeys.ListAsync(account, cancellationToken); + CanEnrol = passkeys.HasTicket(account, Request.Cookies[BifrostCookies.PasskeyTicket]); + } } /// Reads the account's logins: an ordinary Heimdall sign-in, then the list inside its completion. @@ -92,25 +119,156 @@ public sealed class LinkedAccountsModel( /// Unlinks one login with fresh proof. The identity must be on the list this account was last shown and the account must /// hold a Google login to prove with; otherwise Heimdall is not asked, and the page says so. ///
- public Task OnPostUnlinkAsync(string? identityId, CancellationToken cancellationToken) + public async Task OnPostUnlinkAsync(string? identityId, CancellationToken cancellationToken) { var shown = AccountId is { } id ? snapshots.Get(id) : null; if (shown is null || identityId is null || !shown.Any(x => x.Id == identityId)) { - return Task.FromResult(Done(HeimdallLinkOutcome.ListExpired)); + return Done(HeimdallLinkOutcome.ListExpired); + } + + // A strong passkey replaces Google as the proof: a member who has one is never asked for the weaker one. + if (await passkeys.HasStrongAsync(AccountId!, cancellationToken)) + { + return Done(HeimdallLinkOutcome.PasskeyRequired); } if (!shown.Any(x => x.Provider == HeimdallAuthService.StepUpProvider)) { - return Task.FromResult(Done(HeimdallLinkOutcome.ProofUnavailable)); + return Done(HeimdallLinkOutcome.ProofUnavailable); } - return BeginAsync( + return await BeginAsync( (account, ct) => heimdallAuthService.StartUnlinkAsync(account, identityId, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), HeimdallLinkOutcome.ProofUnavailable, cancellationToken); } + /// Begins the fresh sign-in whose completion issues the enrolment ticket. Needs a current list: the proof provider is never guessed. + public async Task OnPostPasskeyEnrolStartAsync(CancellationToken cancellationToken) + { + if (AccountId is not { } id) + { + return Done(HeimdallLinkOutcome.Error); + } + + if (snapshots.Get(id) is null) + { + return Done(HeimdallLinkOutcome.ListExpired); + } + + if (await passkeys.PreflightEnrolStartAsync(id, cancellationToken) is { } refused) + { + return Done(refused); + } + + return await BeginAsync( + (account, ct) => heimdallAuthService.StartEnrolProofAsync(account, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), + HeimdallLinkOutcome.Error, + cancellationToken); + } + + /// The WebAuthn creation options for the ticket this browser holds, as JSON for the page's script. + public async Task OnPostPasskeyEnrolOptionsAsync(CancellationToken cancellationToken) + { + if (AccountId is not { } id) + { + return Options(HeimdallLinkOutcome.Error, null); + } + + var (refusal, json) = await passkeys.BeginEnrolAsync(id, Request.Cookies[BifrostCookies.PasskeyTicket], cancellationToken); + return Options(refusal, json); + } + + /// Verifies the registration the script posted. The ticket cookie is spent whatever the outcome. + public async Task OnPostPasskeyEnrolAsync( + string? id, string? rawId, string? attestationObject, string? clientDataJson, string? transports, string? label, CancellationToken cancellationToken) + { + if (AccountId is not { } account) + { + return Done(HeimdallLinkOutcome.Error); + } + + var outcome = await passkeys.EnrolAsync( + account, Request.Cookies[BifrostCookies.PasskeyTicket], + new PasskeyFields(id, rawId, null, clientDataJson, null, null, attestationObject, transports), label, cancellationToken); + Response.Cookies.Delete(BifrostCookies.PasskeyTicket, HeimdallAttemptCookie.DeleteOptions()); + return Done(outcome); + } + + public async Task OnPostPasskeyRevokeAsync(Guid credentialId, CancellationToken cancellationToken) => + AccountId is { } id ? Done(await passkeys.RevokeAsync(id, credentialId, cancellationToken)) : Done(HeimdallLinkOutcome.Error); + + /// The WebAuthn request options for unlinking one identity, as JSON for the page's script. + public async Task OnPostPasskeyChallengeAsync(string? identityId, CancellationToken cancellationToken) + { + if (AccountId is not { } id) + { + return Options(HeimdallLinkOutcome.Error, null); + } + + if (snapshots.Get(id) is not { } shown || identityId is null || !shown.Any(x => x.Id == identityId)) + { + return Options(HeimdallLinkOutcome.ListExpired, null); + } + + var (refusal, json) = await passkeys.BeginAssertionAsync(id, identityId, cancellationToken); + return Options(refusal, json); + } + + /// + /// Unlinks one login on a passkey assertion. The assertion is verified here; only then does Bifrost start the fresh Heimdall sign-in + /// whose completion asks Heimdall for the step-up and unlinks. A refused or unverifiable assertion starts no sign-in. + /// + public async Task OnPostUnlinkPasskeyAsync( + string? identityId, string? id, string? rawId, string? authenticatorData, string? clientDataJson, string? signature, string? userHandle, + CancellationToken cancellationToken) + { + var shown = AccountId is { } account ? snapshots.Get(account) : null; + if (shown is null || identityId is null || !shown.Any(x => x.Id == identityId)) + { + return Done(HeimdallLinkOutcome.ListExpired); + } + + var (refusal, factId) = await passkeys.VerifyAssertionAsync( + AccountId!, identityId, new PasskeyFields(id, rawId, authenticatorData, clientDataJson, signature, userHandle, null, null), cancellationToken); + if (refusal is { } failed) + { + return Done(failed); + } + + try + { + var started = await heimdallAuthService.StartUnlinkPasskeyAsync( + AccountId!, identityId, factId!, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken); + Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options()); + return Redirect(started.AuthorizationUrl.ToString()); + } + catch (HeimdallAuthException error) + { + logger.LogWarning("Linked accounts start refused: {Reason}", error.Message); + passkeys.DiscardFact(factId); + return Done(HeimdallLinkOutcome.ProofUnavailable); + } + } + + /// JSON for the page's script: the options, or only a closed outcome code with the status that fits. + private IActionResult Options(HeimdallLinkOutcome? refusal, string? json) + { + if (refusal is null && json is not null) + { + return Content(json, "application/json"); + } + + var status = refusal == HeimdallLinkOutcome.RateLimited ? StatusCodes.Status429TooManyRequests : StatusCodes.Status400BadRequest; + return new ContentResult + { + StatusCode = status, + ContentType = "application/json", + Content = "{\"error\":\"" + HeimdallLinkOutcomes.Code(refusal ?? HeimdallLinkOutcome.Error) + "\"}" + }; + } + private IActionResult Done(HeimdallLinkOutcome outcome) => RedirectToPage(null, new Dictionary { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) }); diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 8fc0ee5..48c0f74 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -159,6 +159,10 @@ builder.Services.AddHttpClient((serviceProvider, client) = HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); @@ -281,6 +285,8 @@ var app = builder.Build(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); app.Services.GetRequiredService().Validate(); +// Resolved now so a misconfigured RP (not https, host differs, a port in ExpectedHost) is logged once at boot, not on the first ceremony. +_ = app.Services.GetRequiredService(); await using (var scope = app.Services.CreateAsyncScope()) { await scope.ServiceProvider @@ -856,6 +862,19 @@ app.MapPost("/auth/heimdall/complete", async ( return Results.Redirect(step.Next.ToString()); } + if (step.EnrolTicket is not null) + { + // The ticket id goes only to the browser that completed the fresh sign-in; the server holds what it is bound to. + httpContext.Response.Cookies.Append(BifrostCookies.PasskeyTicket, step.EnrolTicket, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = BifrostCookies.Path, + MaxAge = Bifrost.Web.Features.Passkeys.PasskeyCeremonies.TicketLifetime + }); + } + httpContext.Response.Cookies.Delete(step.CookieName, HeimdallAttemptCookie.DeleteOptions()); return Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(step.Outcome!.Value)); } diff --git a/src/Bifrost.Web/packages.lock.json b/src/Bifrost.Web/packages.lock.json index d969a56..796baa1 100644 --- a/src/Bifrost.Web/packages.lock.json +++ b/src/Bifrost.Web/packages.lock.json @@ -8,6 +8,19 @@ "resolved": "2.4.0", "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" }, + "Fido2": { + "type": "Direct", + "requested": "[4.3.3, )", + "resolved": "4.3.3", + "contentHash": "++G8ohXlhp9AH49FXIvYqziXUAEpuCtT+z5HBevX6+Pl6sYz9zfDHGTgvne6t3UEhM0hudesgMudyNG7RlpaEA==", + "dependencies": { + "Fido2.Models": "4.3.3", + "Microsoft.Extensions.Http": "9.0.0", + "Microsoft.IdentityModel.JsonWebTokens": "8.2.0", + "NSec.Cryptography": "25.4.0", + "System.Formats.Cbor": "9.0.24" + } + }, "MessagePack": { "type": "Direct", "requested": "[3.1.12, )", @@ -111,11 +124,24 @@ "resolved": "8.0.5", "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" }, + "Fido2.Models": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "G/Q8MYgptOwMrbWgDoGsy90u3J5GTdNAHub8aZRg/mQrUETzB5CqFzstGdTXE9UhL0qD6sRynOoPUnqCwKRnMw==", + "dependencies": { + "Microsoft.Bcl.Memory": "9.0.14" + } + }, "Humanizer.Core": { "type": "Transitive", "resolved": "2.14.1", "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" }, + "libsodium": { + "type": "Transitive", + "resolved": "1.0.20.1", + "contentHash": "pkiceEqJDQFHjbga3k/oPfTVX9g+GKJZ1VrkuiLhaymt9YNw1Dr7cX9hNuZuNaWcr9WD0moW55k4pMwzQ7JaZQ==" + }, "MessagePack.Annotations": { "type": "Transitive", "resolved": "3.1.12", @@ -139,6 +165,11 @@ "System.Formats.Asn1": "10.0.2" } }, + "Microsoft.Bcl.Memory": { + "type": "Transitive", + "resolved": "9.0.14", + "contentHash": "laQCcjTM2FnbyznkAu9hwBuQh3z2/OtJKwjGkJOUevylFpFXZIUVq/+MU2jM0HQm1n5wsjjwtLIDOiWJ6Nai3A==" + }, "Microsoft.CodeAnalysis.Analyzers": { "type": "Transitive", "resolved": "3.3.3", @@ -226,26 +257,43 @@ "Microsoft.Extensions.Primitives": "8.0.0" } }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "YIMO9T3JL8MeEXgVozKt2v79hquo/EFtnY0vgxmLnUvk1Rei/halI7kOWZL2RBeV9FMGzgM9LZA8CVaNwFMaNA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "resolved": "9.0.0", + "contentHash": "lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==", "dependencies": { - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==", + "resolved": "9.0.0", + "contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + "resolved": "9.0.0", + "contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -256,37 +304,83 @@ "System.Text.Json": "8.0.0" } }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "0CF9ZrNw5RAlRfbZuVIvzzhP8QeWqHiUmMBU/2H7Nmit8/vwP3/SbHeEctth7D4Gz2fBnEbokPc1NU8/j/1ZLw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.0" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "1K8P7XzuzX8W8pmXcZjcrqS6x5eSSdvhQohmcpgiQNY/HlDAlnrhR9dvlURfFz428A+RTCJpUyB+aKTA6AgVcQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "System.Diagnostics.DiagnosticSource": "9.0.0" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "DqI4q54U4hH7bIAq9M5a/hl5Odr/KBAoaZ0dcT4OgutD8dook34CbkvAfAIzkMVjYXiL+E5ul9etwwqiX4PHGw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Diagnostics": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0" + } + }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==", + "resolved": "9.0.0", + "contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "8.0.1", - "Microsoft.Extensions.Logging.Abstractions": "8.0.2", - "Microsoft.Extensions.Options": "8.0.2" + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==", + "resolved": "9.0.0", + "contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "System.Diagnostics.DiagnosticSource": "9.0.0" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "resolved": "9.0.0", + "contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "Ob3FXsXkcSMQmGZi7qP07EQ39kZpSBlTcAZLbJLdI4FIf0Jug8biv2HTavWmnTirchctPlq9bl/26CXtQRguzA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + "resolved": "9.0.0", + "contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg==" }, "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", @@ -340,6 +434,14 @@ "System.CodeDom": "4.4.0" } }, + "NSec.Cryptography": { + "type": "Transitive", + "resolved": "25.4.0", + "contentHash": "Z3wPpG0xefMLmhn9T+Ka/EzAmMQPT0THL/5E4lf9cXI/N9rbGH0G23ZFpRcPnD0ast2k7ieG6QSVNIjmfPCXBQ==", + "dependencies": { + "libsodium": "[1.0.20.1, 1.0.21)" + } + }, "OpenIddict.Abstractions": { "type": "Transitive", "resolved": "7.7.1", @@ -448,11 +550,21 @@ "System.Composition.Runtime": "6.0.0" } }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ddppcFpnbohLWdYKr/ZeLZHmmI+DXFgZ3Snq+/E7SwcdW4UnvxmaugkwGywvGVWkHPGCSZjCP+MLzu23AL5SDw==" + }, "System.Formats.Asn1": { "type": "Transitive", "resolved": "10.0.2", "contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q==" }, + "System.Formats.Cbor": { + "type": "Transitive", + "resolved": "9.0.24", + "contentHash": "SEfEvHu6qXcXoDsDKagddLDf8A9iHIhHUD0hGemdmwlP206KLLCI5ksskOpeXMgNpQCs2diB0fOBKcnjySHFUQ==" + }, "System.IO.Pipelines": { "type": "Transitive", "resolved": "6.0.3", diff --git a/src/Bifrost.Web/wwwroot/js/passkeys.js b/src/Bifrost.Web/wwwroot/js/passkeys.js new file mode 100644 index 0000000..3d898f0 --- /dev/null +++ b/src/Bifrost.Web/wwwroot/js/passkeys.js @@ -0,0 +1,125 @@ +// The Linked accounts page's passkey ceremonies (docs/passkeys.md). First-party, no dependency. It asks the server for +// WebAuthn options, calls navigator.credentials, and posts back only base64url strings in a same-origin form. Nothing it +// reads from the options is shown or evaluated; the server checks the origin, the challenge and the signature. +(function () { + "use strict"; + + var status = document.getElementById("passkey-status"); + + function say(text) { + if (status) { + status.textContent = text; + } + } + + function toBytes(value) { + var padded = value.replace(/-/g, "+").replace(/_/g, "/"); + while (padded.length % 4) { + padded += "="; + } + var raw = atob(padded); + var bytes = new Uint8Array(raw.length); + for (var i = 0; i < raw.length; i++) { + bytes[i] = raw.charCodeAt(i); + } + return bytes.buffer; + } + + function toText(buffer) { + var bytes = new Uint8Array(buffer); + var raw = ""; + for (var i = 0; i < bytes.length; i++) { + raw += String.fromCharCode(bytes[i]); + } + return btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function token(form) { + var field = form.querySelector("input[name=__RequestVerificationToken]"); + return field ? field.value : ""; + } + + // Posts to a handler of this page with the form's antiforgery token and returns the parsed JSON, or throws. + function options(handler, form, fields) { + return fetch(location.pathname + "?handler=" + handler, { + method: "POST", + credentials: "same-origin", + headers: { "RequestVerificationToken": token(form), "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields) + }).then(function (response) { + return response.json().then(function (body) { + if (!response.ok) { + throw new Error(body && body.error ? body.error : "error"); + } + return body; + }); + }); + } + + function descriptors(list) { + return (list || []).map(function (item) { + var copy = { type: item.type, id: toBytes(item.id) }; + if (item.transports) { + copy.transports = item.transports; + } + return copy; + }); + } + + function set(form, name, value) { + form.elements[name].value = value; + } + + function failed(error) { + var code = error && error.message ? error.message : "error"; + location.assign(location.pathname + "?result=" + (/^[a-z_]+$/.test(code) && code !== "error" ? encodeURIComponent(code) : "passkey_failed")); + } + + document.querySelectorAll("form[data-passkey-enrol]").forEach(function (form) { + form.addEventListener("submit", function (event) { + if (form.dataset.ready === "1") { + return; + } + event.preventDefault(); + say("Waiting for your device..."); + options("PasskeyEnrolOptions", form, {}).then(function (created) { + created.challenge = toBytes(created.challenge); + created.user.id = toBytes(created.user.id); + created.excludeCredentials = descriptors(created.excludeCredentials); + return navigator.credentials.create({ publicKey: created }); + }).then(function (credential) { + set(form, "id", credential.id); + set(form, "rawId", toText(credential.rawId)); + set(form, "attestationObject", toText(credential.response.attestationObject)); + set(form, "clientDataJson", toText(credential.response.clientDataJSON)); + set(form, "transports", credential.response.getTransports ? credential.response.getTransports().join(",") : ""); + form.dataset.ready = "1"; + form.submit(); + }).catch(failed); + }); + }); + + document.querySelectorAll("form[data-passkey-unlink]").forEach(function (form) { + form.addEventListener("submit", function (event) { + if (form.dataset.ready === "1") { + return; + } + event.preventDefault(); + say("Waiting for your device..."); + options("PasskeyChallenge", form, { identityId: form.elements.identityId.value }).then(function (requested) { + requested.challenge = toBytes(requested.challenge); + requested.allowCredentials = descriptors(requested.allowCredentials); + return navigator.credentials.get({ publicKey: requested }); + }).then(function (credential) { + set(form, "id", credential.id); + set(form, "rawId", toText(credential.rawId)); + set(form, "authenticatorData", toText(credential.response.authenticatorData)); + set(form, "clientDataJson", toText(credential.response.clientDataJSON)); + set(form, "signature", toText(credential.response.signature)); + set(form, "userHandle", credential.response.userHandle ? toText(credential.response.userHandle) : ""); + form.dataset.ready = "1"; + form.submit(); + }).catch(failed); + }); + }); +})(); diff --git a/tests/Bifrost.Web.Tests/CookieHardeningTests.cs b/tests/Bifrost.Web.Tests/CookieHardeningTests.cs index 287e8ac..10e2804 100644 --- a/tests/Bifrost.Web.Tests/CookieHardeningTests.cs +++ b/tests/Bifrost.Web.Tests/CookieHardeningTests.cs @@ -36,6 +36,7 @@ public sealed class CookieHardeningTests(CookieHardeningTests.OAuthConfiguredFac Assert.Equal("__Host-bifrost_antiforgery", BifrostCookies.Antiforgery); Assert.Equal("__Host-bifrost_voter", VoterKeyResolver.CookieName); Assert.Equal("__Host-bifrost_heimdall_", HeimdallAttemptCookie.Prefix); + Assert.Equal("__Host-bifrost_passkey_ticket", BifrostCookies.PasskeyTicket); Assert.All(HeimdallAttemptCookie.SlotNames, x => Assert.StartsWith("__Host-", x, StringComparison.Ordinal)); Assert.Equal("/", BifrostCookies.Path); Assert.Equal("/", VoterKeyResolver.CookiePath); diff --git a/tests/Bifrost.Web.Tests/Fixtures/passkey-script-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/passkey-script-harness.cjs new file mode 100644 index 0000000..638242d --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/passkey-script-harness.cjs @@ -0,0 +1,96 @@ +// Runs wwwroot/js/passkeys.js against a simulated browser, once per scenario, and prints what it did as JSON: +// every fetch (URL, headers, body), the arguments to navigator.credentials, the form values at the submit, and any navigation. +// Usage: node passkey-script-harness.cjs +const fs = require("fs"); +const vm = require("vm"); + +const source = fs.readFileSync(process.argv[2], "utf8"); +const buf = (...bytes) => Uint8Array.from(bytes).buffer; + +function form(names, extra = {}) { + const elements = {}; + for (const name of names) { + elements[name] = { value: "" }; + } + Object.assign(elements, extra); + return { + elements, + dataset: {}, + handler: null, + addEventListener(type, fn) { + if (type === "submit") this.handler = fn; + }, + querySelector() { + return { value: "TOKEN" }; + }, + submit() { + this.submitted = Object.fromEntries(Object.entries(this.elements).map(([k, v]) => [k, v.value])); + }, + }; +} + +async function run(name, { kind, fetchReply, credentialsReply }) { + const enrol = kind === "enrol"; + const theForm = enrol + ? form(["id", "rawId", "attestationObject", "clientDataJson", "transports", "label"]) + : form(["id", "rawId", "authenticatorData", "clientDataJson", "signature", "userHandle"], { identityId: { value: "IDENTITY" } }); + const log = { name, fetches: [], credentialCall: null, navigations: [], status: "" }; + const statusEl = { textContent: "" }; + const document = { + getElementById: (id) => (id === "passkey-status" ? statusEl : null), + querySelectorAll: (selector) => (selector === (enrol ? "form[data-passkey-enrol]" : "form[data-passkey-unlink]") ? [theForm] : []), + }; + const location = { pathname: "/Membership/LinkedAccounts", assign: (url) => log.navigations.push(url) }; + const fetch = async (url, init) => { + log.fetches.push({ url, method: init.method, headers: init.headers, body: String(init.body), credentials: init.credentials }); + return { ok: fetchReply.ok, json: async () => fetchReply.body }; + }; + const call = async (publicKey) => { + log.credentialCall = { + challengeIsBuffer: publicKey.challenge instanceof ArrayBuffer, + challengeBytes: Array.from(new Uint8Array(publicKey.challenge)), + userIdBytes: publicKey.user ? Array.from(new Uint8Array(publicKey.user.id)) : null, + listIdsAreBuffers: (publicKey.excludeCredentials || publicKey.allowCredentials).every((x) => x.id instanceof ArrayBuffer), + }; + if (credentialsReply === "reject") throw new Error("NotAllowedError"); + return credentialsReply; + }; + const navigator = { + credentials: enrol ? { create: ({ publicKey }) => call(publicKey) } : { get: ({ publicKey }) => call(publicKey) }, + }; + vm.runInNewContext(source, { document, location, fetch, navigator, URLSearchParams, Uint8Array, ArrayBuffer, atob, btoa, Promise, Error }); + await theForm.handler({ preventDefault() {} }); + await new Promise((r) => setTimeout(r, 20)); + log.submitted = theForm.submitted || null; + log.status = statusEl.textContent; + return log; +} + +(async () => { + const creationOptions = { + ok: true, + body: { challenge: "AQID", user: { id: "BAUG" }, excludeCredentials: [{ type: "public-key", id: "BwgJ" }] }, + }; + const requestOptions = { + ok: true, + body: { challenge: "AQID", allowCredentials: [{ type: "public-key", id: "BwgJ", transports: ["internal"] }] }, + }; + const created = { + id: "CRED-ID", + rawId: buf(251, 255, 254), + response: { attestationObject: buf(1, 2, 3), clientDataJSON: buf(4, 5), getTransports: () => ["internal", "hybrid"] }, + }; + const asserted = { + id: "CRED-ID", + rawId: buf(251, 255, 254), + response: { authenticatorData: buf(9, 9), clientDataJSON: buf(4, 5), signature: buf(7, 7, 7), userHandle: buf(1, 1) }, + }; + const out = [ + await run("enrol", { kind: "enrol", fetchReply: creationOptions, credentialsReply: created }), + await run("unlink", { kind: "unlink", fetchReply: requestOptions, credentialsReply: asserted }), + await run("refused", { kind: "enrol", fetchReply: { ok: false, body: { error: "enrol_needs_sign_in" } }, credentialsReply: created }), + await run("hostile-error", { kind: "unlink", fetchReply: { ok: false, body: { error: "" } }, credentialsReply: asserted }), + await run("cancelled", { kind: "unlink", fetchReply: requestOptions, credentialsReply: "reject" }), + ]; + console.log(JSON.stringify(out)); +})(); diff --git a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs index d45e883..e1c90ca 100644 --- a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs @@ -532,7 +532,7 @@ public sealed class HeimdallGoogleLinkTests [Theory] [InlineData("9")] [InlineData("-1")] - [InlineData("7")] + [InlineData("99")] public async Task An_attempt_cookie_with_an_undefined_purpose_is_no_attempt_and_asks_Heimdall_for_nothing(string purpose) { using var host = new HeimdallHost(realSessions: true); diff --git a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs index 98a8993..bd2348a 100644 --- a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs +++ b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs @@ -587,8 +587,9 @@ public sealed class LinkedAccountsTests var post = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); Assert.DoesNotContain(">Unlink<", html); - Assert.Contains("fresh Google sign-in", html); - Assert.Contains("no Google login", html); + Assert.Contains("Unlinking needs strong proof", html); + Assert.Contains("Add a passkey below", html); + Assert.Contains("link a Google login", html); Assert.Equal(starts, host.Heimdall.StartBodies.Count); Assert.Equal(Page + "?result=proof_unavailable", Location(post.Response)); Assert.Null(post.Cookie); diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 6d902cb..0451330 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -28,6 +28,7 @@ public sealed class MigrationDiscoveryTests "20261111100000_ConsentReceiptsOneLivePerVersion", "20261111110000_AddMembershipSuspensionReason", "20261111120000_ClearLegacyConsentReceipts", + "20261113120000_AddPasskeyCredentials", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. diff --git a/tests/Bifrost.Web.Tests/PasskeyScriptTests.cs b/tests/Bifrost.Web.Tests/PasskeyScriptTests.cs new file mode 100644 index 0000000..2a269e2 --- /dev/null +++ b/tests/Bifrost.Web.Tests/PasskeyScriptTests.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Bifrost.Web.Tests; + +/// +/// Runs the page's first-party passkey script (src/Bifrost.Web/wwwroot/js/passkeys.js) under Node against a simulated browser +/// (Fixtures/passkey-script-harness.cjs) and pins what it posts: the antiforgery header, base64url strings only, and no navigation +/// text that did not come from a closed code. Node is required: a missing Node fails these tests. +/// +public sealed partial class PasskeyScriptTests +{ + private sealed record Fetch(string Url, string Method, Dictionary Headers, string Body, string Credentials); + + private sealed record Call(string Name, Fetch[] Fetches, JsonElement CredentialCall, string[] Navigations, Dictionary? Submitted, string Status); + + [GeneratedRegex("^[A-Za-z0-9_-]*$")] + private static partial Regex Base64Url(); + + private static string ScriptPath() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Bifrost.slnx"))) + { + dir = dir.Parent; + } + + return Path.Combine(dir!.FullName, "src", "Bifrost.Web", "wwwroot", "js", "passkeys.js"); + } + + private static async Task> RunAsync() + { + var start = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true }; + start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "Fixtures", "passkey-script-harness.cjs")); + start.ArgumentList.Add(ScriptPath()); + using var process = Process.Start(start)!; + var output = process.StandardOutput.ReadToEndAsync(); + var errors = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + await process.WaitForExitAsync(timeout.Token); + Assert.True(process.ExitCode == 0, await errors); + return JsonSerializer.Deserialize(await output, new JsonSerializerOptions(JsonSerializerDefaults.Web))!.ToDictionary(x => x.Name); + } + + [Fact] + public async Task Enrolment_posts_the_antiforgery_header_to_this_page_and_only_base64url_fields_after_the_ceremony() + { + var run = (await RunAsync())["enrol"]; + + var fetch = Assert.Single(run.Fetches); + Assert.Equal("/Membership/LinkedAccounts?handler=PasskeyEnrolOptions", fetch.Url); + Assert.Equal("POST", fetch.Method); + Assert.Equal("same-origin", fetch.Credentials); + Assert.Equal("TOKEN", fetch.Headers["RequestVerificationToken"]); + Assert.Equal("", fetch.Body); + Assert.True(run.CredentialCall.GetProperty("challengeIsBuffer").GetBoolean()); + Assert.True(run.CredentialCall.GetProperty("listIdsAreBuffers").GetBoolean()); + Assert.Equal([4, 5, 6], run.CredentialCall.GetProperty("userIdBytes").EnumerateArray().Select(x => x.GetInt32())); + Assert.Equal("CRED-ID", run.Submitted!["id"]); + Assert.Equal("-__-", run.Submitted["rawId"]); // 251,255,254 in base64url without padding + Assert.Matches(Base64Url(), run.Submitted["attestationObject"]); + Assert.Matches(Base64Url(), run.Submitted["clientDataJson"]); + Assert.Equal("internal,hybrid", run.Submitted["transports"]); + Assert.Empty(run.Navigations); + } + + [Fact] + public async Task Unlinking_asks_for_a_challenge_for_the_chosen_identity_only_and_posts_the_assertion_as_base64url() + { + var run = (await RunAsync())["unlink"]; + + var fetch = Assert.Single(run.Fetches); + Assert.Equal("/Membership/LinkedAccounts?handler=PasskeyChallenge", fetch.Url); + Assert.Equal("identityId=IDENTITY", fetch.Body); + Assert.Equal("TOKEN", fetch.Headers["RequestVerificationToken"]); + Assert.True(run.CredentialCall.GetProperty("listIdsAreBuffers").GetBoolean()); + foreach (var name in new[] { "rawId", "authenticatorData", "clientDataJson", "signature", "userHandle" }) + { + Assert.Matches(Base64Url(), run.Submitted![name]); + Assert.NotEmpty(run.Submitted[name]); + } + + Assert.Equal("IDENTITY", run.Submitted!["identityId"]); + Assert.Empty(run.Navigations); + } + + [Theory] + [InlineData("refused", "?result=enrol_needs_sign_in")] + [InlineData("hostile-error", "?result=passkey_failed")] + [InlineData("cancelled", "?result=passkey_failed")] + public async Task A_refusal_or_a_cancelled_ceremony_submits_nothing_and_navigates_only_to_a_closed_result_code(string scenario, string expected) + { + var run = (await RunAsync())[scenario]; + + Assert.Null(run.Submitted); + Assert.Equal(["/Membership/LinkedAccounts" + expected], run.Navigations); + } +} diff --git a/tests/Bifrost.Web.Tests/PasskeyTests.cs b/tests/Bifrost.Web.Tests/PasskeyTests.cs new file mode 100644 index 0000000..3fe1381 --- /dev/null +++ b/tests/Bifrost.Web.Tests/PasskeyTests.cs @@ -0,0 +1,1153 @@ +using System.Net; +using System.Text.Json; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Passkeys; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +/// +/// Passkeys on the Linked accounts page from the member's side: a fake Heimdall that models the step-up route of its service contract, +/// and a software authenticator that makes real attestations and assertions. Enrolment needs a fresh Heimdall sign-in and cools off for +/// 48 hours; unlinking with a strong passkey verifies an assertion at Bifrost, then asks Heimdall for an app-asserted step-up inside the +/// completion that holds the bearer. +/// +public sealed class PasskeyTests +{ + private const string Page = "/Membership/LinkedAccounts"; + + private sealed record Member(string Account, string Session, string Discord, string Patreon); + + private static string? Location(HttpResponseMessage response) => response.Headers.Location?.OriginalString; + + private static string Ticket(string ticket) => $"{BifrostCookies.PasskeyTicket}={ticket}"; + + private static async Task Finish(HeimdallHost host, Member member, StartedAttempt attempt, string? token = null, string? account = null) + { + var code = host.CodeFor(attempt, account ?? member.Account, token); + return await host.PostCompleteAsync( + new Dictionary { ["attempt"] = attempt.Nonce, ["code"] = code }, + $"{BifrostCookies.Session}={member.Session}; {attempt.CookieName}={attempt.Cookie}"); + } + + /// A signed-in member whose Heimdall account holds Discord and Patreon (and Google when asked), with the list read. + private static async Task NewMember(HeimdallHost host, bool google = false) + { + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var discord = host.Heimdall.AddIdentity("discord", "D"); + var patreon = host.Heimdall.AddIdentity("patreon", "P"); + if (google) + { + host.Heimdall.AddIdentity("google", "G"); + } + + var member = new Member(account, session, discord, patreon); + await ReadList(host, member); + return member; + } + + private static async Task ReadList(HeimdallHost host, Member member) + { + var start = await host.StartListAsync(member.Session, "discord"); + var done = await Finish(host, member, start); + Assert.Equal(Page + "?result=listed", Location(done)); + } + + private static async Task JsonOf(StartedAttempt call) => await call.Response.Content.ReadAsStringAsync(); + + /// The fresh sign-in that earns the ticket; returns the ticket cookie value. + private static async Task SignInForEnrolment(HeimdallHost host, Member member) + { + var before = host.Heimdall.StartBodies.Count; + var start = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolStart"); + Assert.Equal(before + 1, host.Heimdall.StartBodies.Count); + var done = await Finish(host, member, start); + Assert.Equal(Page + "?result=enrol_ready", Location(done)); + return HeimdallHost.CookieValue(done, BifrostCookies.PasskeyTicket)!; + } + + private static async Task Enrol(HeimdallHost host, Member member, SoftwareAuthenticator authenticator, string ticket, string label = "Test key") + { + var options = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + Assert.Equal(HttpStatusCode.OK, options.Response.StatusCode); + var fields = authenticator.Register(await JsonOf(options), label); + return (await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrol", fields, extraCookies: Ticket(ticket))).Response; + } + + private static async Task AddPasskey(HeimdallHost host, Member member, SoftwareAuthenticator authenticator) + { + var ticket = await SignInForEnrolment(host, member); + var done = await Enrol(host, member, authenticator, ticket); + Assert.Equal(Page + "?result=passkey_added", Location(done)); + } + + /// Waits out the cooling-off and reads the list again (the list lives five minutes). + private static async Task CoolOff(HeimdallHost host, Member member) + { + host.Clock.Advance(PasskeyService.CoolingOff); + await ReadList(host, member); + } + + private static async Task<(string Json, StartedAttempt Call)> Challenge(HeimdallHost host, Member member, string identityId) + { + var call = await host.PostLinkedAccountsAsync(member.Session, "PasskeyChallenge", new Dictionary { ["identityId"] = identityId }); + return (await JsonOf(call), call); + } + + private static async Task Unlink(HeimdallHost host, Member member, SoftwareAuthenticator authenticator, string identityId) + { + var (json, call) = await Challenge(host, member, identityId); + Assert.Equal(HttpStatusCode.OK, call.Response.StatusCode); + return await PostAssertion(host, member, authenticator.Assert(json), identityId); + } + + private static Task PostAssertion(HeimdallHost host, Member member, Dictionary fields, string identityId) + { + fields["identityId"] = identityId; + return host.PostLinkedAccountsAsync(member.Session, "UnlinkPasskey", fields); + } + + private static string ErrorOf(string json) => JsonDocument.Parse(json).RootElement.GetProperty("error").GetString()!; + + private static Task> Credentials(HeimdallHost host) => + host.Db(db => db.PasskeyCredentials.ToListAsync()); + + // ---- enrolment ---- + + [Theory] + [InlineData(SoftwareAuthenticator.Kind.Es256)] + [InlineData(SoftwareAuthenticator.Kind.EdDsa)] + public async Task A_passkey_is_enrolled_after_a_fresh_ordinary_sign_in_and_starts_cooling_off(SoftwareAuthenticator.Kind kind) + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(kind); + + var ticket = await SignInForEnrolment(host, member); + var done = await Enrol(host, member, authenticator, ticket, "My laptop"); + + Assert.Equal(Page + "?result=passkey_added", Location(done)); + var stored = Assert.Single(await Credentials(host)); + Assert.Equal(authenticator.CredentialId, stored.CredentialId); + Assert.Equal(host.Clock.Now, stored.CreatedUtc); + Assert.Equal(host.Clock.Now + TimeSpan.FromHours(48), stored.StrongFromUtc); + Assert.Null(stored.RevokedUtc); + Assert.Equal("My laptop", stored.Label); + Assert.Equal((await host.Account(member.Account))!.Id, stored.UserAccountId); + // The sign-in that earned the ticket was ordinary (no step-up) through a provider the account holds, and minted nothing. + Assert.DoesNotContain("stepUp", host.Heimdall.StartBodies[^1]); + Assert.Empty(host.Heimdall.OrphanMints); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + Assert.Contains("cooling off until", html); + Assert.Contains("cannot unlink yet", html); + Assert.Contains("My <b>laptop</b>", html); + Assert.DoesNotContain("My laptop", html); + } + + [Fact] + public async Task A_session_alone_cannot_start_an_enrolment_because_the_ticket_needs_a_fresh_sign_in() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + + var options = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions"); + var forged = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket("not-a-ticket-0123456789abcdefghijklmnopqrstuvw")); + + Assert.Equal(HttpStatusCode.BadRequest, options.Response.StatusCode); + Assert.Equal("enrol_needs_sign_in", ErrorOf(await JsonOf(options))); + Assert.Equal("enrol_needs_sign_in", ErrorOf(await JsonOf(forged))); + Assert.Empty(await Credentials(host)); + } + + [Fact] + public async Task A_registration_without_a_ticket_is_refused_even_with_a_valid_challenge() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + var ticket = await SignInForEnrolment(host, member); + var options = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + + var fields = authenticator.Register(await JsonOf(options)); + var withoutTicket = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrol", fields); + + Assert.Equal(Page + "?result=enrol_needs_sign_in", Location(withoutTicket.Response)); + Assert.Empty(await Credentials(host)); + } + + [Fact] + public async Task A_ticket_is_single_use_and_does_not_work_for_another_session() + { + using var host = new HeimdallHost(realSessions: true); + var victim = await NewMember(host); + var other = await NewMember(host); + var ticket = await SignInForEnrolment(host, victim); + + var otherSession = await host.PostLinkedAccountsAsync(other.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + var done = await Enrol(host, victim, new SoftwareAuthenticator(), ticket); + var again = await host.PostLinkedAccountsAsync(victim.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + + Assert.Equal("enrol_needs_sign_in", ErrorOf(await JsonOf(otherSession))); + Assert.Equal(Page + "?result=passkey_added", Location(done)); + Assert.Equal("enrol_needs_sign_in", ErrorOf(await JsonOf(again))); + Assert.Single(await Credentials(host)); + } + + [Fact] + public async Task The_ticket_expires_after_five_minutes() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var ticket = await SignInForEnrolment(host, member); + + host.Clock.Advance(PasskeyCeremonies.TicketLifetime + TimeSpan.FromSeconds(1)); + var options = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + + Assert.Equal("enrol_needs_sign_in", ErrorOf(await JsonOf(options))); + } + + [Fact] + public async Task The_enrolment_sign_in_proven_by_another_account_earns_no_ticket() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var start = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolStart"); + + var done = await Finish(host, member, start, account: HeimdallShapes.NewAccountId()); + + Assert.Null(HeimdallHost.CookieValue(done, BifrostCookies.PasskeyTicket)); + Assert.Equal(Page + "?result=error", Location(done)); + } + + [Fact] + public async Task The_ticket_cookie_is_host_only_http_only_and_short_lived() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var start = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolStart"); + + var done = await Finish(host, member, start); + + var header = HeimdallHost.SetCookieHeader(done, BifrostCookies.PasskeyTicket)!.ToLowerInvariant(); + Assert.StartsWith("__host-bifrost_passkey_ticket=", header); + Assert.Contains("secure", header); + Assert.Contains("httponly", header); + Assert.Contains("path=/", header); + Assert.Contains("max-age=300", header); + Assert.Contains("samesite=lax", header); + Assert.DoesNotContain("domain", header); + } + + [Fact] + public async Task A_registration_from_another_origin_or_for_another_RP_or_without_user_verification_is_refused() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + + foreach (var tamper in new Action[] + { + a => a.Origin = "https://bifrost.evil.example", + a => a.RpId = "evil.example", + a => a.UserVerified = false + }) + { + var authenticator = new SoftwareAuthenticator(); + tamper(authenticator); + var ticket = await SignInForEnrolment(host, member); + var done = await Enrol(host, member, authenticator, ticket); + Assert.Equal(Page + "?result=passkey_failed", Location(done)); + } + + Assert.Empty(await Credentials(host)); + } + + [Fact] + public async Task A_registration_answering_an_older_challenge_is_refused() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var ticket = await SignInForEnrolment(host, member); + var first = await JsonOf(await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket))); + await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + + var fields = new SoftwareAuthenticator().Register(first); + var done = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrol", fields, extraCookies: Ticket(ticket)); + + Assert.Equal(Page + "?result=passkey_failed", Location(done.Response)); + Assert.Empty(await Credentials(host)); + } + + [Fact] + public async Task A_credential_id_another_account_already_holds_cannot_be_enrolled_again() + { + using var host = new HeimdallHost(realSessions: true); + var first = await NewMember(host); + var second = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, first, authenticator); + + var ticket = await SignInForEnrolment(host, second); + var done = await Enrol(host, second, authenticator, ticket); + + Assert.Equal(Page + "?result=passkey_failed", Location(done)); + Assert.Single(await Credentials(host)); + } + + [Fact] + public async Task An_account_holds_at_most_five_passkeys_and_a_sixth_starts_no_sign_in() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + for (var i = 0; i < PasskeyService.MaxActive; i++) + { + await AddPasskey(host, member, new SoftwareAuthenticator()); + await ReadList(host, member); + } + + var starts = host.Heimdall.StartBodies.Count; + var sixth = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolStart"); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + + Assert.Equal(Page + "?result=passkey_limit", Location(sixth.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Contains("most passkeys an account can hold", html); + } + + [Fact] + public async Task Revoking_a_passkey_needs_no_strong_proof_is_the_members_own_and_frees_the_slot() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var stranger = await NewMember(host); + await AddPasskey(host, member, new SoftwareAuthenticator()); + var id = (await Credentials(host)).Single().Id; + + var byStranger = await host.PostLinkedAccountsAsync(stranger.Session, "PasskeyRevoke", new Dictionary { ["credentialId"] = id.ToString() }); + Assert.Equal(Page + "?result=passkey_failed", Location(byStranger.Response)); + Assert.Null((await Credentials(host)).Single().RevokedUtc); + + var revoke = await host.PostLinkedAccountsAsync(member.Session, "PasskeyRevoke", new Dictionary { ["credentialId"] = id.ToString() }); + + Assert.Equal(Page + "?result=passkey_revoked", Location(revoke.Response)); + Assert.Equal(host.Clock.Now, (await Credentials(host)).Single().RevokedUtc); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + Assert.DoesNotContain("cooling off until", html); + } + + [Fact] + public async Task Ceremonies_are_rate_limited_per_account() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + + StartedAttempt? last = null; + for (var i = 0; i < PasskeyRateLimit.Permits + 1; i++) + { + last = await host.PostLinkedAccountsAsync(member.Session, "PasskeyChallenge", new Dictionary { ["identityId"] = member.Patreon }); + } + + Assert.Equal(HttpStatusCode.TooManyRequests, last!.Response.StatusCode); + Assert.Equal("rate_limited", ErrorOf(await JsonOf(last))); + } + + [Theory] + [InlineData("PasskeyEnrolStart")] + [InlineData("PasskeyEnrolOptions")] + [InlineData("PasskeyEnrol")] + [InlineData("PasskeyRevoke")] + [InlineData("PasskeyChallenge")] + [InlineData("UnlinkPasskey")] + public async Task Every_passkey_post_needs_the_antiforgery_token(string handler) + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var starts = host.Heimdall.StartBodies.Count; + + var call = await host.PostLinkedAccountsAsync(member.Session, handler, new Dictionary { ["identityId"] = member.Patreon }, antiforgery: false); + + Assert.Equal(HttpStatusCode.BadRequest, call.Response.StatusCode); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Empty(host.Heimdall.StepUps); + } + + [Fact] + public async Task Passkeys_are_unavailable_unless_the_origin_is_https_and_its_host_is_the_expected_one() + { + foreach (var publicBase in new[] { "http://localhost", "https://other.example" }) + { + using var host = new HeimdallHost(realSessions: true, publicBaseUrl: publicBase); + var member = await NewMember(host); + + var options = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions"); + var start = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolStart"); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + + Assert.Equal("passkeys_unavailable", ErrorOf(await JsonOf(options))); + Assert.Equal(Page + "?result=passkeys_unavailable", Location(start.Response)); + Assert.Contains("Passkeys are not available on this server", html); + Assert.DoesNotContain("passkeys.js", html); + } + } + + [Fact] + public async Task A_session_with_no_Heimdall_account_has_no_passkeys_and_every_ceremony_refuses() + { + using var host = new HeimdallHost(realSessions: true); + var attempt = await host.StartAsync(provider: "discord"); + var native = (await host.PostLinkedAccountsAsync(null, "PasskeyEnrolOptions")).Response; + + Assert.NotEqual(HttpStatusCode.OK, native.StatusCode); + Assert.Empty(await Credentials(host)); + Assert.NotNull(attempt); + } + + // ---- assertion and unlink ---- + + [Theory] + [InlineData(SoftwareAuthenticator.Kind.Es256)] + [InlineData(SoftwareAuthenticator.Kind.EdDsa)] + public async Task A_strong_passkey_unlinks_through_an_app_asserted_step_up_inside_the_completion_that_holds_the_bearer(SoftwareAuthenticator.Kind kind) + { + using var host = new HeimdallHost(realSessions: true, traceHttp: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(kind); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var starts = host.Heimdall.StartBodies.Count; + + var attempt = await Unlink(host, member, authenticator, member.Patreon); + + // The assertion alone starts an ordinary fresh sign-in (no step-up asked of a provider) and asks Heimdall for nothing else yet. + Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count); + Assert.DoesNotContain("stepUp", host.Heimdall.StartBodies[^1]); + Assert.Empty(host.Heimdall.StepUps); + Assert.Empty(host.Heimdall.Unlinks); + Assert.Empty(host.Heimdall.OrphanMints); + + var bearer = host.Token(member.Account); + var done = await Finish(host, member, attempt, bearer); + + Assert.Equal(Page + "?result=unlinked", Location(done)); + var stepUp = Assert.Single(host.Heimdall.StepUps); + Assert.Equal(HeimdallHost.AppSecret, stepUp.Secret); + Assert.Equal("Bearer " + bearer, stepUp.Authorization); + var body = JsonDocument.Parse(stepUp.Body).RootElement; + Assert.Equal("bifrost-passkey", body.GetProperty("method").GetString()); + Assert.Matches("^[A-Za-z0-9_-]{43}$", body.GetProperty("nonce").GetString()!); + var unlink = Assert.Single(host.Heimdall.Unlinks); + Assert.Equal(member.Patreon, unlink.Id); + Assert.NotEqual("Bearer " + bearer, unlink.Authorization); + var minted = JsonDocument.Parse(System.Text.Encoding.UTF8.GetString( + Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlDecode(unlink.Authorization!["Bearer ".Length..].Split('.')[1]))).RootElement; + Assert.Equal("bifrost-passkey", minted.GetProperty("step_up_method").GetString()); + Assert.DoesNotContain(host.Heimdall.Identities, x => (string?)x["id"] == member.Patreon); + + // No bearer, minted or not, reaches a cookie, a redirect, a page or a log. + var minted2 = unlink.Authorization["Bearer ".Length..]; + var seen = string.Join("\n", done.Headers.Select(x => x.Key + "=" + string.Join(",", x.Value))) + "\n" + attempt.Response.Headers + + "\n" + string.Join("\n", host.Logs.Lines) + "\n" + (await host.GetLinkedAccountsAsync(member.Session)).Html; + Assert.DoesNotContain(bearer, seen); + Assert.DoesNotContain(minted2, seen); + Assert.DoesNotContain("PUID-CANARY", seen); + var held = host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1").Unprotect(attempt.Cookie!); + Assert.DoesNotContain("eyJ", held); + } + + [Fact] + public async Task A_stolen_session_cannot_unlink_without_the_passkey() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var starts = host.Heimdall.StartBodies.Count; + + // No challenge was ever asked for, or the thief signs with its own key: nothing is verified, no sign-in starts. + var noChallenge = await PostAssertion(host, member, new Dictionary(), member.Patreon); + var (json, _) = await Challenge(host, member, member.Patreon); + var thief = new SoftwareAuthenticator(); + var fields = thief.Assert(json); + var withOwnKey = await PostAssertion(host, member, fields, member.Patreon); + var viaGoogleRoute = await host.StartUnlinkAsync(member.Session, member.Patreon); + + Assert.Equal(Page + "?result=passkey_failed", Location(noChallenge.Response)); + Assert.Equal(Page + "?result=passkey_failed", Location(withOwnKey.Response)); + Assert.Equal(Page + "?result=passkey_required", Location(viaGoogleRoute.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Empty(host.Heimdall.StepUps); + Assert.Empty(host.Heimdall.Unlinks); + } + + [Fact] + public async Task An_assertion_cannot_be_replayed_and_a_double_submit_starts_one_sign_in() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var (json, _) = await Challenge(host, member, member.Patreon); + var fields = authenticator.Assert(json); + var starts = host.Heimdall.StartBodies.Count; + + var both = await Task.WhenAll(PostAssertion(host, member, new(fields), member.Patreon), PostAssertion(host, member, new(fields), member.Patreon)); + var replay = await PostAssertion(host, member, new(fields), member.Patreon); + + Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count); + Assert.Single(both, x => Location(x.Response)!.StartsWith("https://heimdall.test")); + Assert.Equal(Page + "?result=passkey_expired", Location(replay.Response)); + } + + [Fact] + public async Task A_challenge_for_one_identity_does_not_unlink_another() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host, google: true); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var (json, _) = await Challenge(host, member, member.Patreon); + var starts = host.Heimdall.StartBodies.Count; + + var other = await PostAssertion(host, member, authenticator.Assert(json), member.Discord); + + Assert.Equal(Page + "?result=passkey_expired", Location(other.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + + [Fact] + public async Task An_assertion_for_another_account_or_origin_or_without_user_verification_is_refused() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var other = await NewMember(host); + var otherKey = new SoftwareAuthenticator(); + await AddPasskey(host, other, otherKey); + await CoolOff(host, other); + await ReadList(host, member); + var starts = host.Heimdall.StartBodies.Count; + + // Another account's session, with its own strong passkey, presents this member's credential for its own challenge. + var (otherJson, _) = await Challenge(host, other, other.Patreon); + Assert.Contains("challenge", otherJson); + var borrowed = await PostAssertion(host, other, authenticator.Assert(otherJson), other.Patreon); + Assert.Equal(Page + "?result=passkey_failed", Location(borrowed.Response)); + + foreach (var tamper in new Action[] + { + a => a.Origin = "https://bifrost.evil.example", + a => a.RpId = "evil.example", + a => a.UserVerified = false + }) + { + var (json, _) = await Challenge(host, member, member.Patreon); + var tampered = authenticator.Assert(json); + var clone = new SoftwareAuthenticator(); + // Same key material is needed to sign, so tamper on the real authenticator and restore afterwards. + var origin = (authenticator.Origin, authenticator.RpId, authenticator.UserVerified); + tamper(authenticator); + tampered = authenticator.Assert(json); + (authenticator.Origin, authenticator.RpId, authenticator.UserVerified) = origin; + var done = await PostAssertion(host, member, tampered, member.Patreon); + Assert.Equal(Page + "?result=passkey_failed", Location(done.Response)); + Assert.NotNull(clone); + } + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + + [Fact] + public async Task A_sign_count_that_does_not_advance_is_refused_as_a_cloned_authenticator() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host, google: true); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + + authenticator.SignCount = 4; // the next use is 5 + var first = await Unlink(host, member, authenticator, member.Patreon); + Assert.StartsWith("https://heimdall.test", Location(first.Response)); + Assert.Equal(5, (await Credentials(host)).Single().SignCount); + Assert.NotNull((await Credentials(host)).Single().LastUsedUtc); + + authenticator.SignCount = 4; // a use that counts 5 again + var (json, _) = await Challenge(host, member, member.Discord); + var same = await PostAssertion(host, member, authenticator.Assert(json), member.Discord); + authenticator.SignCount = 2; // counts 3: lower + var (json2, _) = await Challenge(host, member, member.Discord); + var lower = await PostAssertion(host, member, authenticator.Assert(json2), member.Discord); + authenticator.SignCount = 5; // counts 6: higher + var (json3, _) = await Challenge(host, member, member.Discord); + var higher = await PostAssertion(host, member, authenticator.Assert(json3), member.Discord); + + Assert.Equal(Page + "?result=passkey_failed", Location(same.Response)); + Assert.Equal(Page + "?result=passkey_failed", Location(lower.Response)); + Assert.StartsWith("https://heimdall.test", Location(higher.Response)); + Assert.Equal(6, (await Credentials(host)).Single().SignCount); + } + + [Fact] + public async Task A_passkey_counts_as_strong_exactly_at_forty_eight_hours() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + + host.Clock.Advance(PasskeyService.CoolingOff - TimeSpan.FromSeconds(1)); + await ReadList(host, member); + var (_, early) = await host.GetLinkedAccountsAsync(member.Session); + var (earlyJson, _) = await Challenge(host, member, member.Patreon); + var earlyGoogle = await host.StartUnlinkAsync(member.Session, member.Patreon); + + Assert.Contains("cooling off until", early); + Assert.DoesNotContain("Unlink with passkey", early); + Assert.Contains("Unlinking needs strong proof", early); + Assert.Contains("(your newest one counts from", early); + Assert.Equal("passkey_required", ErrorOf(earlyJson)); + Assert.Equal(Page + "?result=proof_unavailable", Location(earlyGoogle.Response)); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + await ReadList(host, member); + var (_, exact) = await host.GetLinkedAccountsAsync(member.Session); + var attempt = await Unlink(host, member, authenticator, member.Patreon); + + Assert.Contains("Unlink with passkey", exact); + Assert.DoesNotContain("cooling off until", exact); + Assert.StartsWith("https://heimdall.test", Location(attempt.Response)); + } + + [Fact] + public async Task A_revoked_passkey_is_not_strong_and_Google_proof_returns() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host, google: true); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var (json, _) = await Challenge(host, member, member.Patreon); + var fields = authenticator.Assert(json); + var id = (await Credentials(host)).Single().Id.ToString(); + await host.PostLinkedAccountsAsync(member.Session, "PasskeyRevoke", new Dictionary { ["credentialId"] = id }); + var starts = host.Heimdall.StartBodies.Count; + + var withRevoked = await PostAssertion(host, member, fields, member.Patreon); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + + Assert.Equal(Page + "?result=passkey_failed", Location(withRevoked.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.DoesNotContain("Unlink with passkey", html); + Assert.Contains(">Unlink", html); + + // A challenge asked for after the revoke is refused, and one answered by a key revoked in between does not verify. + var (after, _) = await Challenge(host, member, member.Patreon); + Assert.Equal("passkey_required", ErrorOf(after)); + } + + [Fact] + public async Task A_credential_revoked_between_challenge_and_assertion_does_not_verify() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var (json, _) = await Challenge(host, member, member.Patreon); + await host.PostLinkedAccountsAsync(member.Session, "PasskeyRevoke", new Dictionary { ["credentialId"] = (await Credentials(host)).Single().Id.ToString() }); + var starts = host.Heimdall.StartBodies.Count; + + var done = await PostAssertion(host, member, authenticator.Assert(json), member.Patreon); + + Assert.Equal(Page + "?result=passkey_failed", Location(done.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + + [Fact] + public async Task The_assertion_fact_expires_and_is_spent_once_and_without_it_Heimdall_is_never_asked() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + + // Expired: the fresh sign-in comes back after the fact's five minutes. + var late = await Unlink(host, member, authenticator, member.Patreon); + host.Clock.Advance(PasskeyCeremonies.ProofLifetime + TimeSpan.FromSeconds(1)); + var lateDone = await Finish(host, member, late); + Assert.Equal(Page + "?result=passkey_expired", Location(lateDone)); + + // Spent once: the attempt cookie can be presented again with another code, but the fact is gone. + await ReadList(host, member); + authenticator.SignCount = 2; + var once = await Unlink(host, member, authenticator, member.Patreon); + var first = await Finish(host, member, once); + var second = await Finish(host, member, once); + Assert.Equal(Page + "?result=unlinked", Location(first)); + Assert.Equal(Page + "?result=passkey_expired", Location(second)); + + Assert.Single(host.Heimdall.StepUps); + Assert.Single(host.Heimdall.Unlinks); + } + + [Fact] + public async Task The_fact_is_bound_to_the_identity_and_account_it_was_made_for() + { + var ceremonies = new PasskeyCeremonies(new TestClock()); + var fact = ceremonies.Issue(CeremonyKind.UnlinkProof, "account-a", "identity-1", null, TimeSpan.FromMinutes(5))!; + + Assert.Null(ceremonies.Take(CeremonyKind.UnlinkProof, "account-b", "identity-1", fact)); + Assert.Null(ceremonies.Take(CeremonyKind.UnlinkProof, "account-a", "identity-2", fact)); + Assert.Null(ceremonies.Take(CeremonyKind.EnrolTicket, "account-a", "identity-1", fact)); + Assert.NotNull(ceremonies.Take(CeremonyKind.UnlinkProof, "account-a", "identity-1", fact)); + Assert.Null(ceremonies.Take(CeremonyKind.UnlinkProof, "account-a", "identity-1", fact)); + } + + [Theory] + [InlineData(HttpStatusCode.Forbidden, "step_up_unavailable")] + [InlineData(HttpStatusCode.Conflict, "step_up_replayed")] + [InlineData(HttpStatusCode.TooManyRequests, "step_up_rate_limited")] + [InlineData(HttpStatusCode.Unauthorized, "sign_in_required")] + [InlineData(HttpStatusCode.InternalServerError, "error")] + public async Task A_Heimdall_step_up_refusal_is_shown_with_a_closed_code_and_nothing_is_unlinked_or_retried(HttpStatusCode status, string code) + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var attempt = await Unlink(host, member, authenticator, member.Patreon); + host.Heimdall.StepUpStatus = status; + + var done = await Finish(host, member, attempt); + + Assert.Equal(Page + "?result=" + code, Location(done)); + Assert.Single(host.Heimdall.StepUps); + Assert.Empty(host.Heimdall.Unlinks); + Assert.Contains(host.Heimdall.Identities, x => (string?)x["id"] == member.Patreon); + Assert.DoesNotContain("HEIMDALL FREE TEXT", await (await host.GetLinkedAccountsAsync(member.Session, "?result=" + code)).Response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task A_Heimdall_that_is_down_for_the_step_up_changes_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var attempt = await Unlink(host, member, authenticator, member.Patreon); + host.Heimdall.StepUpDown = true; + + var done = await Finish(host, member, attempt); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Empty(host.Heimdall.Unlinks); + } + + [Fact] + public async Task Every_step_up_carries_a_fresh_nonce_so_the_replay_guard_never_fires() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host, google: true); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var results = new List(); + foreach (var identity in new[] { member.Patreon, member.Discord }) + { + var attempt = await Unlink(host, member, authenticator, identity); + // Same Heimdall session (sid) for both: the fake keys replay on sid and nonce. + results.Add(Location(await Finish(host, member, attempt, host.Token(member.Account, mutate: c => c["sid"] = "one-session")))); + await ReadList(host, member); + } + + var nonces = host.Heimdall.StepUps.Select(x => JsonDocument.Parse(x.Body).RootElement.GetProperty("nonce").GetString()).ToList(); + Assert.Equal(2, nonces.Distinct().Count()); + Assert.All(results, x => Assert.Equal(Page + "?result=unlinked", x)); + } + + [Fact] + public async Task With_a_strong_passkey_the_Google_route_is_closed_and_without_one_it_is_as_before() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host, google: true); + + var before = await host.StartUnlinkAsync(member.Session, member.Patreon); + Assert.StartsWith("https://heimdall.test", Location(before.Response)); + Assert.Contains("stepUp", host.Heimdall.StartBodies[^1]); + + await AddPasskey(host, member, new SoftwareAuthenticator()); + var cooling = await host.StartUnlinkAsync(member.Session, member.Patreon); + Assert.StartsWith("https://heimdall.test", Location(cooling.Response)); + + await CoolOff(host, member); + var starts = host.Heimdall.StartBodies.Count; + var after = await host.StartUnlinkAsync(member.Session, member.Patreon); + + Assert.Equal(Page + "?result=passkey_required", Location(after.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + + [Fact] + public async Task The_passkey_script_is_served_first_party_and_is_the_only_script_the_page_adds() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + using var client = host.NewClient(); + var script = await client.GetAsync("/js/passkeys.js"); + var source = await script.Content.ReadAsStringAsync(); + + Assert.Contains("src=\"/js/passkeys.js", html); + Assert.DoesNotContain("